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>
  );
}
↖ kaspirius