The Pokémon TCG API is deprecated (no new keys, existing keys end 2027-03-01) and was returning 5xx errors. TCGdex is free and needs no API key. - src/lib/api.ts: fetch the card list, then each card and set, with retries and limited concurrency; map to the existing TcgCard shape. Pokémon TCG Pocket cards are skipped. - Keep the old card IDs for the 10 cards whose TCGdex ID differs, so saved checklists stay valid (verified by name, set and number). - Fall back to the old image host for cards TCGdex has no scan for, and to a placeholder if an image fails to load. - route.ts: serve the last cached data (marked stale) when a refresh fails; drop the API key handling. - Remove the API key variables from compose and the env example; update README. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
201 lines
9.0 KiB
TypeScript
201 lines
9.0 KiB
TypeScript
"use client";
|
||
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';
|
||
import { useUsdGbpRate } from '@/lib/useExchangeRate';
|
||
import { dispatchReverseOverride, loadOverridesClient } from '@/lib/overrides';
|
||
|
||
export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; checked: VariantState; onToggle: (id:string, key: VariantKey)=>void }) {
|
||
const rate = useUsdGbpRate();
|
||
const [imageFailed, setImageFailed] = React.useState<boolean>(false);
|
||
const hasHolo = !!(card.variants?.holofoil || (card.tcgplayer as any)?.prices?.holofoil);
|
||
const hasReverseDetected = !!(card.variants?.reverseHolofoil || (card.tcgplayer as any)?.prices?.reverseHolofoil);
|
||
const [reverseOverride, setReverseOverride] = React.useState<boolean>(false);
|
||
const [reverseRemoved, setReverseRemoved] = React.useState<boolean>(false);
|
||
const hasReverse = (hasReverseDetected && !reverseRemoved) || reverseOverride;
|
||
const prices = (card as any).tcgplayer?.prices || {};
|
||
const rarityFull = card.rarity || '';
|
||
const rarityAbbr = (() => {
|
||
const r = rarityFull.toLowerCase();
|
||
// Specific token-based shortcuts first
|
||
if (r.includes('vmax')) return 'VMAX';
|
||
if (r.includes('vstar')) return 'VSTAR';
|
||
if (r.includes('gx')) return 'GX';
|
||
if (r.includes('ex')) return 'EX';
|
||
if (r.includes('v ')) return 'V';
|
||
if (r === 'v') return 'V';
|
||
if (r.includes('secret')) return 'SR';
|
||
if (r.includes('ultra')) return 'UR';
|
||
if (r.includes('reverse')) return 'RH';
|
||
if (r.includes('holo')) return 'HR';
|
||
if (r.includes('promo')) return 'P';
|
||
// Specific before generic 'rare'
|
||
if (r.includes('illustration') && r.includes('rare')) return 'IR';
|
||
if (r.includes('rare')) return 'R';
|
||
if (r.includes('uncommon')) return 'U';
|
||
if (r.includes('common')) return 'C';
|
||
// Fallback: first letter of first word uppercased
|
||
return rarityFull ? rarityFull.charAt(0).toUpperCase() : '';
|
||
})();
|
||
|
||
// Initialize local override based on server overrides to show the − toggle on reloads
|
||
React.useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
const map = await loadOverridesClient();
|
||
if (!cancelled && map && typeof map === 'object') {
|
||
if (map[card.id]) {
|
||
setReverseOverride(true);
|
||
setReverseRemoved(false);
|
||
}
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [card.id]);
|
||
const getPrice = (key: VariantKey): number | undefined => {
|
||
const map: Record<VariantKey, string> = {
|
||
base: 'normal',
|
||
holofoil: 'holofoil',
|
||
reverseHolofoil: 'reverseHolofoil',
|
||
};
|
||
const entry = prices[map[key]];
|
||
if (!entry) return undefined;
|
||
const value = entry.market ?? entry.mid ?? entry.low ?? entry.high;
|
||
return typeof value === 'number' ? value : undefined;
|
||
};
|
||
const fmtGBP = (usd?: number) => {
|
||
if (typeof usd !== 'number') return undefined;
|
||
const gbp = usd * rate;
|
||
try {
|
||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', maximumFractionDigits: 2 }).format(gbp);
|
||
} catch {
|
||
return `£${gbp.toFixed(2)}`;
|
||
}
|
||
};
|
||
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">
|
||
<Image
|
||
src={imageFailed ? '/card-placeholder.svg' : card.images.small}
|
||
onError={() => setImageFailed(true)}
|
||
alt={`${card.name} #${card.number}`}
|
||
fill
|
||
className="object-contain p-3"
|
||
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 25vw, 20vw"
|
||
/>
|
||
</div>
|
||
<div className="p-3 flex flex-col gap-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
{(() => {
|
||
const titleText = `${card.name} #${card.number}`;
|
||
const len = titleText.length;
|
||
const size = len > 34 ? 'text-xs' : len > 24 ? 'text-sm' : 'text-base';
|
||
return (
|
||
<div className="flex items-center gap-1 min-w-0">
|
||
<h3 className={`font-medium ${size} leading-tight truncate`} title={titleText}>
|
||
{card.name} <span className="text-slate-500">#{card.number}</span>
|
||
</h3>
|
||
{/* Show + only if no detected reverse and no override; show − only if override currently active */}
|
||
{(!hasReverseDetected && !reverseOverride) && (
|
||
<button
|
||
type="button"
|
||
title="Add Reverse variant"
|
||
aria-label="Add Reverse variant"
|
||
className="ml-1 inline-flex h-5 w-5 items-center justify-center rounded border border-slate-300 text-slate-600 hover:bg-slate-50 text-xs flex-shrink-0"
|
||
onClick={async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await fetch('/api/magikarp/override', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: card.id, reverseHolofoil: true }),
|
||
});
|
||
setReverseOverride(true);
|
||
// Notify app to recompute availableVariants immediately
|
||
dispatchReverseOverride(card.id, true);
|
||
} catch {}
|
||
}}
|
||
>
|
||
+
|
||
</button>
|
||
)}
|
||
{reverseOverride && (
|
||
<button
|
||
type="button"
|
||
title="Remove Reverse variant"
|
||
aria-label="Remove Reverse variant"
|
||
className="ml-1 inline-flex h-5 w-5 items-center justify-center rounded border border-slate-300 text-slate-600 hover:bg-slate-50 text-xs flex-shrink-0"
|
||
onClick={async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await fetch('/api/magikarp/override', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: card.id, reverseHolofoil: false }),
|
||
});
|
||
setReverseOverride(false);
|
||
setReverseRemoved(true);
|
||
// Notify app to recompute availableVariants immediately
|
||
dispatchReverseOverride(card.id, false);
|
||
} catch {}
|
||
}}
|
||
>
|
||
−
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
{rarityFull ? (
|
||
<span
|
||
className="text-[0.7rem] rounded bg-slate-100 px-2 py-0.5 text-slate-600"
|
||
title={rarityFull}
|
||
>
|
||
{rarityAbbr}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<SetBadge set={card.set} />
|
||
<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.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} (base) as collected`}
|
||
/>
|
||
<span>Base{fmtGBP(getPrice('base')) ? ` — ${fmtGBP(getPrice('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{fmtGBP(getPrice('holofoil')) ? ` — ${fmtGBP(getPrice('holofoil'))}` : ''}</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{fmtGBP(getPrice('reverseHolofoil')) ? ` — ${fmtGBP(getPrice('reverseHolofoil'))}` : ''}</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|