add separate checkboxes for variants
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>)
|
/>)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user