Code — how this page is built
Under the hood
The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.
CurrencyGameview on GitHub ↗
Loads the currency list, then debounces a fetch to the keyless Frankfurter API on amount/from/to changes. Aborts in-flight requests; falls back gracefully if offline.
'use client';
import * as React from 'react';
const API = 'https://api.frankfurter.dev/v1';
export function CurrencyGame() {
const [currencies, setCurrencies] = React.useState<Record<string, string>>({});
const [from, setFrom] = React.useState('EUR');
const [to, setTo] = React.useState('USD');
const [amount, setAmount] = React.useState('100');
const [result, setResult] = React.useState<string | null>(null);
const [date, setDate] = React.useState('');
const [err, setErr] = React.useState(false);
React.useEffect(() => {
fetch(`${API}/currencies`)
.then((r) => r.json())
.then(setCurrencies)
.catch(() => setErr(true));
}, []);
React.useEffect(() => {
const amt = Number(amount);
if (!Number.isFinite(amt) || amt <= 0) {
setResult(null);
return;
}
if (from === to) {
setResult(amt.toFixed(2));
return;
}
const ctrl = new AbortController();
const id = window.setTimeout(() => {
fetch(`${API}/latest?amount=${amt}&from=${from}&to=${to}`, { signal: ctrl.signal })
.then((r) => r.json())
.then((d) => {
setResult(d.rates?.[to]?.toFixed(2) ?? null);
setDate(d.date ?? '');
setErr(false);
})
.catch((e) => {
if (e.name !== 'AbortError') setErr(true);
});
}, 250);
return () => {
ctrl.abort();
window.clearTimeout(id);
};
}, [amount, from, to]);
const opts: [string, string][] = Object.keys(currencies).length
? Object.entries(currencies)
: [
['EUR', 'Euro'],
['USD', 'US Dollar'],
['GBP', 'British Pound'],
];
return (
<div className="flex w-full max-w-sm flex-col items-center gap-5">
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full border-b border-foreground/30 bg-transparent py-2 text-center text-3xl font-bold outline-none"
/>
<div className="flex w-full items-center gap-3">
<Select value={from} onChange={setFrom} options={opts} />
<button
onClick={() => { setFrom(to); setTo(from); }}
className="font-mono text-lg opacity-60 hover:opacity-100"
aria-label="swap"
>
⇄
</button>
<Select value={to} onChange={setTo} options={opts} />
</div>
<div className="text-center">
<div className="text-4xl font-bold tracking-tight">
{result != null ? `${result}` : err ? '—' : '…'}
</div>
<div className="mt-1 font-mono text-[11px] uppercase tracking-[0.12em] opacity-50">
{err ? 'rates unavailable' : date ? `${to} · ECB rates ${date}` : to}
</div>
</div>
</div>
);
}
function Select({
value, onChange, options,
}: { value: string; onChange: (v: string) => void; options: [string, string][] }) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 border border-foreground/25 bg-[var(--tile)] px-2 py-2 font-mono text-sm outline-none"
>
{options.map(([code, name]) => (
<option key={code} value={code}>
{code} — {name}
</option>
))}
</select>
);
}