Files
magikarp-collection/src/components/CardItem.tsx
Foohoo 1612834577 Switch card data source from pokemontcg.io to TCGdex
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>
2026-10-18 13:14:25 +00:00

201 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}