add cost for the variants using USD to GBP conversion
This commit is contained in:
@@ -4,10 +4,33 @@ 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';
|
import type { VariantKey, VariantState } from '@/lib/checklist';
|
||||||
|
import { useUsdGbpRate } from '@/lib/useExchangeRate';
|
||||||
|
|
||||||
export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; checked: VariantState; onToggle: (id:string, key: VariantKey)=>void }) {
|
export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; checked: VariantState; onToggle: (id:string, key: VariantKey)=>void }) {
|
||||||
|
const rate = useUsdGbpRate();
|
||||||
const hasHolo = !!(card.variants?.holofoil || (card.tcgplayer as any)?.prices?.holofoil);
|
const hasHolo = !!(card.variants?.holofoil || (card.tcgplayer as any)?.prices?.holofoil);
|
||||||
const hasReverse = !!(card.variants?.reverseHolofoil || (card.tcgplayer as any)?.prices?.reverseHolofoil);
|
const hasReverse = !!(card.variants?.reverseHolofoil || (card.tcgplayer as any)?.prices?.reverseHolofoil);
|
||||||
|
const prices = (card as any).tcgplayer?.prices || {};
|
||||||
|
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 (
|
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">
|
||||||
@@ -34,7 +57,7 @@ export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; ch
|
|||||||
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} (base) as collected`}
|
aria-label={`Mark ${card.name} #${card.number} (base) as collected`}
|
||||||
/>
|
/>
|
||||||
<span>Base</span>
|
<span>Base{fmtGBP(getPrice('base')) ? ` — ${fmtGBP(getPrice('base'))}` : ''}</span>
|
||||||
</label>
|
</label>
|
||||||
{hasHolo && (
|
{hasHolo && (
|
||||||
<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">
|
||||||
@@ -45,7 +68,7 @@ export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; ch
|
|||||||
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} (holo) as collected`}
|
aria-label={`Mark ${card.name} #${card.number} (holo) as collected`}
|
||||||
/>
|
/>
|
||||||
<span>Holo</span>
|
<span>Holo{fmtGBP(getPrice('holofoil')) ? ` — ${fmtGBP(getPrice('holofoil'))}` : ''}</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{hasReverse && (
|
{hasReverse && (
|
||||||
@@ -57,7 +80,7 @@ export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; ch
|
|||||||
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} (reverse) as collected`}
|
aria-label={`Mark ${card.name} #${card.number} (reverse) as collected`}
|
||||||
/>
|
/>
|
||||||
<span>Reverse</span>
|
<span>Reverse{fmtGBP(getPrice('reverseHolofoil')) ? ` — ${fmtGBP(getPrice('reverseHolofoil'))}` : ''}</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
src/lib/currency.ts
Normal file
18
src/lib/currency.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function getUsdGbpRate(): number {
|
||||||
|
// Allow override via env var; must be NEXT_PUBLIC_* to be available on client
|
||||||
|
const raw = process.env.NEXT_PUBLIC_USD_GBP_RATE;
|
||||||
|
const parsed = raw ? Number(raw) : NaN;
|
||||||
|
// Fallback default rate; update as needed
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUsdToGbp(usd?: number): string | undefined {
|
||||||
|
if (typeof usd !== 'number') return undefined;
|
||||||
|
const gbp = usd * getUsdGbpRate();
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', maximumFractionDigits: 2 }).format(gbp);
|
||||||
|
} catch {
|
||||||
|
// Fallback simple formatting
|
||||||
|
return `£${gbp.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/lib/useExchangeRate.ts
Normal file
64
src/lib/useExchangeRate.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const CACHE_KEY = 'fx-usd-gbp-v1';
|
||||||
|
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
const DEFAULT_RATE = 0.78;
|
||||||
|
|
||||||
|
function readCache(): { rate: number; ts: number } | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const obj = JSON.parse(raw) as { rate: number; ts: number };
|
||||||
|
if (typeof obj?.rate !== 'number' || typeof obj?.ts !== 'number') return null;
|
||||||
|
if (Date.now() - obj.ts > TTL_MS) return null;
|
||||||
|
return obj;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(rate: number) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CACHE_KEY, JSON.stringify({ rate, ts: Date.now() }));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsdGbpRate(): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.frankfurter.app/latest?from=USD&to=GBP');
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = await res.json();
|
||||||
|
const rate = json?.rates?.GBP;
|
||||||
|
return typeof rate === 'number' ? rate : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsdGbpRate(): number {
|
||||||
|
const [rate, setRate] = useState<number>(DEFAULT_RATE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = readCache();
|
||||||
|
if (cached) {
|
||||||
|
setRate(cached.rate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const live = await fetchUsdGbpRate();
|
||||||
|
if (!cancelled && typeof live === 'number') {
|
||||||
|
setRate(live);
|
||||||
|
writeCache(live);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return rate;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user