add separate checkboxes for variants

This commit is contained in:
2025-08-17 12:37:32 +01:00
parent b9e7e21919
commit e9f132e9dd
5 changed files with 114 additions and 28 deletions

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { TcgCard } from '@/types/pokemon';
import { loadChecklist, saveChecklist } from '@/lib/checklist';
import type { ChecklistV2, VariantKey } from '@/lib/checklist';
import CardGrid from '@/components/CardGrid';
import Header from '@/components/Header';
import SetFilter from '@/components/SetFilter';
@@ -11,13 +12,13 @@ export default function Page() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [collected, setCollected] = useState<Set<string>>(new Set());
const [checklist, setChecklist] = useState<ChecklistV2>({});
const [setId, setSetId] = useState('');
const [note, setNote] = useState<string | null>(null);
const [tab, setTab] = useState<'uncollected' | 'collected'>('uncollected');
useEffect(() => {
setCollected(loadChecklist());
setChecklist(loadChecklist());
}, []);
useEffect(() => {
@@ -39,15 +40,34 @@ export default function Page() {
load();
}, []);
function toggleCollected(id: string) {
setCollected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
function toggleCollected(id: string, key: VariantKey) {
setChecklist((prev) => {
const next: ChecklistV2 = { ...prev };
const current = { ...(next[id] || {}) };
current[key] = !current[key];
// Clean up empty records to keep storage tidy
const hasAny = Object.values(current).some(Boolean);
if (hasAny) next[id] = current; else delete next[id];
saveChecklist(next);
return next;
});
}
function availableVariants(card: TcgCard): VariantKey[] {
const list: VariantKey[] = ['base'];
const hasHolo = !!(card.variants?.holofoil || (card as any).tcgplayer?.prices?.holofoil);
const hasReverse = !!(card.variants?.reverseHolofoil || (card as any).tcgplayer?.prices?.reverseHolofoil);
if (hasHolo) list.push('holofoil');
if (hasReverse) list.push('reverseHolofoil');
return list;
}
function isFullyCollected(card: TcgCard): boolean {
const needed = availableVariants(card);
const state = checklist[card.id] || {};
return needed.every((k) => !!state[k]);
}
const filtered = useMemo(() => {
const byQuery = (() => {
if (!query.trim()) return cards;
@@ -64,14 +84,14 @@ export default function Page() {
return bySet;
}, [cards, query, setId]);
const filteredCollectedCount = useMemo(() => filtered.filter((c) => collected.has(c.id)).length, [filtered, collected]);
const filteredUncollectedCount = useMemo(() => filtered.filter((c) => !collected.has(c.id)).length, [filtered, collected]);
const filteredCollectedCount = useMemo(() => filtered.filter((c) => isFullyCollected(c)).length, [filtered, checklist]);
const filteredUncollectedCount = useMemo(() => filtered.length - filteredCollectedCount, [filtered, filteredCollectedCount]);
const displayed = useMemo(() => {
return tab === 'collected'
? filtered.filter((c) => collected.has(c.id))
: filtered.filter((c) => !collected.has(c.id));
}, [filtered, collected, tab]);
? filtered.filter((c) => isFullyCollected(c))
: filtered.filter((c) => !isFullyCollected(c));
}, [filtered, checklist, tab]);
const setOptions = useMemo(() => {
const map = new Map<string, string>();
@@ -87,7 +107,7 @@ export default function Page() {
query={query}
onQueryChange={setQuery}
total={cards.length}
collectedCount={collected.size}
collectedCount={useMemo(() => cards.filter((c) => isFullyCollected(c)).length, [cards, checklist])}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
@@ -138,7 +158,7 @@ export default function Page() {
<div className="mb-4 text-sm text-slate-600">
Showing {displayed.length} of {filtered.length} cards in this tab.
</div>
<CardGrid cards={displayed} collected={collected} onToggle={toggleCollected} />
<CardGrid cards={displayed} checklist={checklist} onToggle={toggleCollected} />
</>
)}
</main>

View File

@@ -2,15 +2,16 @@
import React from 'react';
import type { TcgCard } from '@/types/pokemon';
import CardItem from './CardItem';
import type { ChecklistV2, VariantKey } from '@/lib/checklist';
export default function CardGrid({
cards,
collected,
checklist,
onToggle,
}: {
cards: TcgCard[];
collected: Set<string>;
onToggle: (id: string) => void;
checklist: ChecklistV2;
onToggle: (id: string, key: VariantKey) => void;
}) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
@@ -18,7 +19,7 @@ export default function CardGrid({
<CardItem
key={card.id}
card={card}
checked={collected.has(card.id)}
checked={checklist[card.id] || {}}
onToggle={onToggle}
/>)
)}

View File

@@ -3,8 +3,11 @@ import Image from 'next/image';
import React from 'react';
import type { TcgCard } from '@/types/pokemon';
import SetBadge from './SetBadge';
import type { VariantKey, VariantState } from '@/lib/checklist';
export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; checked: boolean; onToggle: (id:string)=>void }) {
export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; checked: VariantState; onToggle: (id:string, key: VariantKey)=>void }) {
const hasHolo = !!(card.variants?.holofoil || (card.tcgplayer as any)?.prices?.holofoil);
const hasReverse = !!(card.variants?.reverseHolofoil || (card.tcgplayer as any)?.prices?.reverseHolofoil);
return (
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm hover:shadow-md transition">
<div className="relative aspect-[3/4] w-full bg-slate-100">
@@ -22,17 +25,41 @@ export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; ch
{card.rarity ? <span className="text-[0.7rem] rounded bg-slate-100 px-2 py-0.5 text-slate-600">{card.rarity}</span> : null}
</div>
<SetBadge set={card.set} />
<div className="pt-1">
<div className="pt-1 flex flex-wrap gap-2">
<label className="inline-flex items-center gap-2 text-xs bg-white px-2 py-1 rounded border border-slate-200">
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(card.id)}
checked={!!checked.base}
onChange={() => onToggle(card.id, 'base')}
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
aria-label={`Mark ${card.name} #${card.number} as collected`}
aria-label={`Mark ${card.name} #${card.number} (base) as collected`}
/>
<span>Collected</span>
<span>Base</span>
</label>
{hasHolo && (
<label className="inline-flex items-center gap-2 text-xs bg-white px-2 py-1 rounded border border-slate-200">
<input
type="checkbox"
checked={!!checked.holofoil}
onChange={() => onToggle(card.id, 'holofoil')}
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
aria-label={`Mark ${card.name} #${card.number} (holo) as collected`}
/>
<span>Holo</span>
</label>
)}
{hasReverse && (
<label className="inline-flex items-center gap-2 text-xs bg-white px-2 py-1 rounded border border-slate-200">
<input
type="checkbox"
checked={!!checked.reverseHolofoil}
onChange={() => onToggle(card.id, 'reverseHolofoil')}
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
aria-label={`Mark ${card.name} #${card.number} (reverse) as collected`}
/>
<span>Reverse</span>
</label>
)}
</div>
</div>
</div>

View File

@@ -1,9 +1,15 @@
export const CHECKLIST_KEY = 'magikarp-checklist-v1';
export const CHECKLIST_KEY_V1 = 'magikarp-checklist-v1';
export const CHECKLIST_KEY_V2 = 'magikarp-checklist-v2';
export function loadChecklist(): Set<string> {
export type VariantKey = 'base' | 'holofoil' | 'reverseHolofoil';
export type VariantState = Partial<Record<VariantKey, boolean>>;
export type ChecklistV2 = Record<string, VariantState>; // cardId -> variant flags
// Legacy loader (v1)
export function loadChecklistV1(): Set<string> {
if (typeof window === 'undefined') return new Set();
try {
const raw = localStorage.getItem(CHECKLIST_KEY);
const raw = localStorage.getItem(CHECKLIST_KEY_V1);
if (!raw) return new Set();
const arr = JSON.parse(raw) as string[];
return new Set(arr);
@@ -12,10 +18,30 @@ export function loadChecklist(): Set<string> {
}
}
export function saveChecklist(set: Set<string>) {
// New storage (v2)
export function loadChecklist(): ChecklistV2 {
if (typeof window === 'undefined') return {};
try {
const v2 = localStorage.getItem(CHECKLIST_KEY_V2);
if (v2) return JSON.parse(v2) as ChecklistV2;
// migrate from v1 if present
const v1 = loadChecklistV1();
if (v1.size === 0) return {};
const migrated: ChecklistV2 = {};
v1.forEach((id) => {
migrated[id] = { base: true };
});
saveChecklist(migrated);
return migrated;
} catch {
return {};
}
}
export function saveChecklist(state: ChecklistV2) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(CHECKLIST_KEY, JSON.stringify(Array.from(set)));
localStorage.setItem(CHECKLIST_KEY_V2, JSON.stringify(state));
} catch {
// ignore
}

View File

@@ -20,6 +20,18 @@ export type TcgCard = {
large: string;
};
set: TcgSet;
// Optional variant availability flags, derived from API pricing/variant data
variants?: {
holofoil?: boolean;
reverseHolofoil?: boolean;
};
// Subset of tcgplayer info used to infer variant availability
tcgplayer?: {
prices?: {
holofoil?: unknown;
reverseHolofoil?: unknown;
};
};
};
export type CardsResponse = {