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 React, { useEffect, useMemo, useState } from 'react';
import type { TcgCard } from '@/types/pokemon'; import type { TcgCard } from '@/types/pokemon';
import { loadChecklist, saveChecklist } from '@/lib/checklist'; import { loadChecklist, saveChecklist } from '@/lib/checklist';
import type { ChecklistV2, VariantKey } from '@/lib/checklist';
import CardGrid from '@/components/CardGrid'; import CardGrid from '@/components/CardGrid';
import Header from '@/components/Header'; import Header from '@/components/Header';
import SetFilter from '@/components/SetFilter'; import SetFilter from '@/components/SetFilter';
@@ -11,13 +12,13 @@ export default function Page() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [collected, setCollected] = useState<Set<string>>(new Set()); const [checklist, setChecklist] = useState<ChecklistV2>({});
const [setId, setSetId] = useState(''); const [setId, setSetId] = useState('');
const [note, setNote] = useState<string | null>(null); const [note, setNote] = useState<string | null>(null);
const [tab, setTab] = useState<'uncollected' | 'collected'>('uncollected'); const [tab, setTab] = useState<'uncollected' | 'collected'>('uncollected');
useEffect(() => { useEffect(() => {
setCollected(loadChecklist()); setChecklist(loadChecklist());
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -39,15 +40,34 @@ export default function Page() {
load(); load();
}, []); }, []);
function toggleCollected(id: string) { function toggleCollected(id: string, key: VariantKey) {
setCollected((prev) => { setChecklist((prev) => {
const next = new Set(prev); const next: ChecklistV2 = { ...prev };
if (next.has(id)) next.delete(id); else next.add(id); 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); saveChecklist(next);
return 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 filtered = useMemo(() => {
const byQuery = (() => { const byQuery = (() => {
if (!query.trim()) return cards; if (!query.trim()) return cards;
@@ -64,14 +84,14 @@ export default function Page() {
return bySet; return bySet;
}, [cards, query, setId]); }, [cards, query, setId]);
const filteredCollectedCount = 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.filter((c) => !collected.has(c.id)).length, [filtered, collected]); const filteredUncollectedCount = useMemo(() => filtered.length - filteredCollectedCount, [filtered, filteredCollectedCount]);
const displayed = useMemo(() => { const displayed = useMemo(() => {
return tab === 'collected' return tab === 'collected'
? filtered.filter((c) => collected.has(c.id)) ? filtered.filter((c) => isFullyCollected(c))
: filtered.filter((c) => !collected.has(c.id)); : filtered.filter((c) => !isFullyCollected(c));
}, [filtered, collected, tab]); }, [filtered, checklist, tab]);
const setOptions = useMemo(() => { const setOptions = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -87,7 +107,7 @@ export default function Page() {
query={query} query={query}
onQueryChange={setQuery} onQueryChange={setQuery}
total={cards.length} 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"> <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"> <div className="mb-4 text-sm text-slate-600">
Showing {displayed.length} of {filtered.length} cards in this tab. Showing {displayed.length} of {filtered.length} cards in this tab.
</div> </div>
<CardGrid cards={displayed} collected={collected} onToggle={toggleCollected} /> <CardGrid cards={displayed} checklist={checklist} onToggle={toggleCollected} />
</> </>
)} )}
</main> </main>

View File

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

View File

@@ -3,8 +3,11 @@ import Image from 'next/image';
import React from 'react'; import React from 'react';
import type { TcgCard } from '@/types/pokemon'; import type { TcgCard } from '@/types/pokemon';
import SetBadge from './SetBadge'; 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 ( return (
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm hover:shadow-md transition"> <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"> <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} {card.rarity ? <span className="text-[0.7rem] rounded bg-slate-100 px-2 py-0.5 text-slate-600">{card.rarity}</span> : null}
</div> </div>
<SetBadge set={card.set} /> <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"> <label className="inline-flex items-center gap-2 text-xs bg-white px-2 py-1 rounded border border-slate-200">
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={!!checked.base}
onChange={() => onToggle(card.id)} onChange={() => onToggle(card.id, 'base')}
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" 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> </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> </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(); if (typeof window === 'undefined') return new Set();
try { try {
const raw = localStorage.getItem(CHECKLIST_KEY); const raw = localStorage.getItem(CHECKLIST_KEY_V1);
if (!raw) return new Set(); if (!raw) return new Set();
const arr = JSON.parse(raw) as string[]; const arr = JSON.parse(raw) as string[];
return new Set(arr); 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; if (typeof window === 'undefined') return;
try { try {
localStorage.setItem(CHECKLIST_KEY, JSON.stringify(Array.from(set))); localStorage.setItem(CHECKLIST_KEY_V2, JSON.stringify(state));
} catch { } catch {
// ignore // ignore
} }

View File

@@ -20,6 +20,18 @@ export type TcgCard = {
large: string; large: string;
}; };
set: TcgSet; 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 = { export type CardsResponse = {