NP@naman-parashar-s-team / Live FX ratesPublic
Live FX rates
A JS snippet on QuikRun, callable at quik.run/r/fx-rates.
JS· node:20· 3 runs· 0 forks· updated 13h ago
Live FX rates.js
export default async function handler(req, env) {// ---- Config: currencies we surface, in display order ----const MAJORS = [{ code: "EUR", name: "Euro", symbol: "€", region: "Eurozone", decimals: 4 },{ code: "GBP", name: "British Pound", symbol: "£", region: "United Kingdom", decimals: 4 },{ code: "JPY", name: "Japanese Yen", symbol: "¥", region: "Japan", decimals: 2 },{ code: "CHF", name: "Swiss Franc", symbol: "Fr", region: "Switzerland", decimals: 4 },{ code: "CAD", name: "Canadian Dollar", symbol: "$", region: "Canada", decimals: 4 },{ code: "AUD", name: "Australian Dollar", symbol: "$", region: "Australia", decimals: 4 },{ code: "CNY", name: "Chinese Yuan", symbol: "¥", region: "China", decimals: 4 },{ code: "INR", name: "Indian Rupee", symbol: "₹", region: "India", decimals: 2 },];// ---- Static fallback: last-known-good rates (used only if the fetch fails) ----const FALLBACK = {base_code: "USD",time_last_update_utc: "Mon, 20 Jul 2026 00:02:31 +0000",time_last_update_unix: 1784592151,rates: {USD: 1, EUR: 0.875011, GBP: 0.743859, JPY: 162.488226, CHF: 0.808272,CAD: 1.40202, AUD: 1.433795, CNY: 6.78091, INR: 96.481462,},};// ---- The ONE allowed fetch, with a graceful fallback ----let data = null, live = false;try {const res = await fetch("https://open.er-api.com/v6/latest/USD");if (res.ok) {const json = await res.json();if (json && json.result === "success" && json.rates && typeof json.rates.EUR === "number") {data = json; live = true;}}} catch (_) {// swallow — we fall back below}if (!data) data = FALLBACK;const rates = data.rates || {};const base = data.base_code || "USD";// ---- Helpers ----const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");const num = (v, d) => (typeof v === "number" && isFinite(v))? v.toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }): "—";// Deterministic, stable-per-snapshot micro-trend so the visuals feel alive// without a second fetch. Seeded off code + update time.const seedFor = (code) => {const s = code + "|" + (data.time_last_update_unix || data.time_last_update_utc || "");let h = 2166136261;for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }return (h >>> 0);};const rand = (seed) => {let x = seed || 1;return () => { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; return ((x >>> 0) % 100000) / 100000; };};const trendVals = (code, n) => {const r = rand(seedFor(code));const out = []; let v = 0.5;for (let i = 0; i < n; i++) { v += (r() - 0.5) * 0.34; v = Math.max(0.1, Math.min(0.9, v)); out.push(v); }return out;};const sparkline = (code) => {const w = 92, h = 30, pts = 16;const vals = trendVals(code, pts);const dx = w / (pts - 1);const coords = vals.map((val, i) => [i * dx, h - 3 - val * (h - 6)]);const line = coords.map((c, i) => (i === 0 ? "M" : "L") + c[0].toFixed(1) + " " + c[1].toFixed(1)).join(" ");const area = line + " L" + w + " " + h + " L0 " + h + " Z";const up = vals[vals.length - 1] >= vals[0];const stroke = up ? "#E6FF55" : "#7a7f87";const gid = "g" + code;const last = coords[coords.length - 1];return ('<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" fill="none" aria-hidden="true">' +'<defs><linearGradient id="' + gid + '" x1="0" y1="0" x2="0" y2="1">' +'<stop offset="0" stop-color="' + stroke + '" stop-opacity="0.18"/>' +'<stop offset="1" stop-color="' + stroke + '" stop-opacity="0"/>' +'</linearGradient></defs>' +'<path d="' + area + '" fill="url(#' + gid + ')"/>' +'<path d="' + line + '" stroke="' + stroke + '" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +'<circle cx="' + last[0].toFixed(1) + '" cy="' + last[1].toFixed(1) + '" r="2" fill="' + stroke + '"/>' +"</svg>");};const changeChip = (code) => {const r = rand(seedFor(code) ^ 0x9e3779b9);const pct = (r() - 0.5) * 1.6; // roughly -0.8%..+0.8%const up = pct >= 0;const cls = up ? "chg up" : "chg dn";const arrow = up? '<svg width="9" height="9" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 7 L5 3 L8 7" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>': '<svg width="9" height="9" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3 L5 7 L8 3" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>';return '<span class="' + cls + '">' + arrow + Math.abs(pct).toFixed(2) + "%</span>";};// ---- Rows for majors ----const rows = MAJORS.map((c) => {const rate = rates[c.code];return ('<tr>' +'<td class="c-cur"><span class="sym">' + esc(c.symbol) + '</span>' +'<span class="meta"><span class="cc">' + esc(c.code) + '</span><span class="cn">' + esc(c.name) + '</span></span></td>' +'<td class="c-rate">' + num(rate, c.decimals) + '</td>' +'<td class="c-spark">' + sparkline(c.code) + '</td>' +'<td class="c-chg">' + changeChip(c.code) + '</td>' +'</tr>');}).join("");// ---- Computed cross-rates: JOIN the single response into new pairs ----// rate(X) = units of X per 1 USD. So X/Y = rate(Y) / rate(X).const cross = (a, b) => {const ra = rates[a], rb = rates[b];if (typeof ra !== "number" || typeof rb !== "number" || ra === 0) return null;return rb / ra;};const crossDefs = [{ a: "EUR", b: "GBP", label: "EUR / GBP", note: "Euro priced in Pounds", d: 4 },{ a: "EUR", b: "JPY", label: "EUR / JPY", note: "Euro priced in Yen", d: 2 },{ a: "GBP", b: "CHF", label: "GBP / CHF", note: "Pound priced in Francs", d: 4 },];const crossCards = crossDefs.map((x) => ('<div class="cross">' +'<div class="cross-top"><span class="cross-label">' + esc(x.label) + '</span><span class="cross-tag">derived</span></div>' +'<div class="cross-val">' + num(cross(x.a, x.b), x.d) + '</div>' +'<div class="cross-note">' + esc(x.note) + '</div>' +'</div>')).join("");// ---- Timestamp / status ----const ts = esc(data.time_last_update_utc || "unknown");const statusLabel = live ? "LIVE" : "CACHED";const dotClass = live ? "dot dot-live" : "dot dot-stale";const html ='<!doctype html><html lang="en"><head><meta charset="utf-8"/>' +'<meta name="viewport" content="width=device-width, initial-scale=1"/>' +'<title>FX Rates · USD base</title><style>' +'*{box-sizing:border-box;margin:0;padding:0}' +':root{--bg:#0b0b0c;--surface:#111214;--surface2:#141518;--border:#23252a;--text:#eaeaea;--muted:#6b6f76;--accent:#E6FF55}' +'html,body{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}' +'body{padding:56px 20px 72px;min-height:100vh;background-image:radial-gradient(1100px 500px at 82% -12%,rgba(230,255,85,0.10),transparent 62%),radial-gradient(700px 340px at -5% 4%,rgba(230,255,85,0.045),transparent 58%);background-attachment:fixed}' +'.wrap{max-width:780px;margin:0 auto}' +'.mono{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace}' +'.kicker{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:11px;letter-spacing:0.24em;text-transform:uppercase;color:var(--muted)}' +'header{margin-bottom:30px}' +'.top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}' +'h1{font-size:34px;line-height:1.05;font-weight:640;letter-spacing:-0.025em;margin:14px 0 10px}' +'h1 .amp{color:var(--accent)}' +'.sub{color:var(--muted);font-size:14.5px;line-height:1.55;max-width:50ch}' +'.status{display:inline-flex;align-items:center;gap:8px;padding:8px 13px;border:1px solid var(--border);border-radius:999px;background:var(--surface);font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:11px;letter-spacing:0.16em;white-space:nowrap}' +'.status.live{color:var(--accent);border-color:rgba(230,255,85,0.28)}' +'.status.cached{color:var(--muted)}' +'.dot{width:7px;height:7px;border-radius:50%}' +'.dot-live{background:var(--accent);animation:pulse 2s infinite}' +'.dot-stale{background:var(--muted)}' +'@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(230,255,85,0.5)}70%{box-shadow:0 0 0 7px rgba(230,255,85,0)}100%{box-shadow:0 0 0 0 rgba(230,255,85,0)}}' +'.base-badge{display:inline-flex;align-items:center;gap:10px;margin-top:20px;padding:11px 15px;border:1px solid var(--border);border-radius:12px;background:linear-gradient(180deg,rgba(255,255,255,0.025),transparent)}' +'.base-badge .lab{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;letter-spacing:0.2em;text-transform:uppercase;color:var(--muted)}' +'.base-badge .val{font-family:ui-monospace,Menlo,monospace;font-size:16px;font-weight:600;color:var(--accent);letter-spacing:0.02em}' +'.card{background:var(--surface);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 1px 0 rgba(255,255,255,0.02) inset,0 24px 60px -30px rgba(0,0,0,0.8)}' +'.card-head{display:flex;align-items:center;justify-content:space-between;padding:17px 20px;border-bottom:1px solid var(--border)}' +'.card-head .t{font-size:13px;font-weight:600;letter-spacing:0.005em}' +'table{width:100%;border-collapse:collapse}' +'th{font-family:ui-monospace,Menlo,monospace;font-size:10px;letter-spacing:0.16em;text-transform:uppercase;color:var(--muted);text-align:left;font-weight:500;padding:11px 20px 10px}' +'th.n,td.c-rate,td.c-chg{text-align:right}' +'th.sp{text-align:left}' +'thead tr{border-bottom:1px solid var(--border)}' +'td{padding:13px 20px;border-bottom:1px solid rgba(35,37,42,0.55);vertical-align:middle}' +'tbody tr:last-child td{border-bottom:none}' +'tbody tr{transition:background .12s ease}' +'tbody tr:hover{background:rgba(230,255,85,0.025)}' +'.c-cur{display:flex;align-items:center;gap:13px}' +'.sym{width:34px;height:34px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:9px;background:var(--surface2);font-family:ui-monospace,Menlo,monospace;font-size:14px;color:var(--text)}' +'.meta{display:flex;flex-direction:column;gap:1px;min-width:0}' +'.cc{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:13.5px;letter-spacing:0.02em}' +'.cn{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +'.c-rate{font-family:ui-monospace,Menlo,monospace;font-size:15.5px;font-variant-numeric:tabular-nums;font-weight:500;letter-spacing:0.01em}' +'.c-spark{width:100px}.c-spark svg{display:block}' +'.c-chg{width:96px}' +'.chg{display:inline-flex;align-items:center;gap:4px;font-family:ui-monospace,Menlo,monospace;font-size:12px;font-variant-numeric:tabular-nums;padding:3px 8px;border-radius:7px;white-space:nowrap}' +'.chg.up{color:var(--accent);background:rgba(230,255,85,0.08)}' +'.chg.dn{color:#9aa0a8;background:rgba(255,255,255,0.04)}' +'.section-head{display:flex;align-items:baseline;justify-content:space-between;margin:34px 0 14px;gap:12px}' +'.section-head .hint{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--muted)}' +'.cross-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}' +'.cross{position:relative;background:var(--surface);border:1px solid var(--border);border-radius:15px;padding:17px;overflow:hidden;transition:border-color .15s ease}' +'.cross:hover{border-color:rgba(230,255,85,0.28)}' +'.cross-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}' +'.cross-label{font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:0.04em;color:var(--text)}' +'.cross-tag{font-family:ui-monospace,Menlo,monospace;font-size:8.5px;letter-spacing:0.14em;text-transform:uppercase;color:var(--muted);border:1px solid var(--border);padding:2px 6px;border-radius:5px}' +'.cross-val{font-family:ui-monospace,Menlo,monospace;font-size:26px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-0.015em}' +'.cross-note{color:var(--muted);font-size:11.5px;margin-top:5px}' +'footer{margin-top:30px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;color:var(--muted);font-size:12px}' +'.foot-brand{display:inline-flex;align-items:center;gap:8px}' +'.foot-brand b{color:var(--text);font-weight:600}' +'.q{color:#0b0b0c;background:var(--accent);width:18px;height:18px;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;font-family:ui-monospace,Menlo,monospace;font-weight:700;font-size:12px}' +'.foot-ts{font-family:ui-monospace,Menlo,monospace;font-size:11px}' +'.disclaimer{margin-top:14px;color:var(--muted);font-size:11px;line-height:1.5;opacity:0.75}' +'@media(max-width:600px){body{padding:32px 16px 56px}h1{font-size:27px}.cross-grid{grid-template-columns:1fr}.c-spark,th.sp{display:none}.cn{display:none}.sub{font-size:13.5px}}' +'</style></head><body><div class="wrap">' +'<header><div class="top"><div>' +'<div class="kicker">Foreign Exchange</div>' +'<h1>Live FX <span class="amp">rates</span></h1>' +'<p class="sub">Major currencies against the US Dollar, with cross-rates computed from a single upstream snapshot.</p>' +'</div>' +'<div class="status ' + (live ? "live" : "cached") + '"><span class="' + dotClass + '"></span>' + statusLabel + '</div>' +'</div>' +'<div class="base-badge"><span class="lab">Base</span><span class="val">1 ' + esc(base) + '</span></div>' +'</header>' +'<div class="card">' +'<div class="card-head"><span class="t">Major currencies</span><span class="kicker">per 1 ' + esc(base) + '</span></div>' +'<table><thead><tr><th>Currency</th><th class="n">Rate</th><th class="sp">Trend</th><th class="n">Move</th></tr></thead>' +'<tbody>' + rows + '</tbody></table>' +'</div>' +'<div class="section-head"><span class="kicker">Computed cross-rates</span><span class="hint">joined from one call</span></div>' +'<div class="cross-grid">' + crossCards + '</div>' +'<footer><span class="foot-brand"><span class="q">q</span> <b>QuikRun</b> · API glue</span>' +'<span class="foot-ts">Updated ' + ts + '</span></footer>' +'<p class="disclaimer">Trend and move indicators are illustrative, derived deterministically from the current snapshot for visual context. Rates are informational and not intended for trading.</p>' +'</div></body></html>';return new Response(html, {status: 200,headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=300" },});}
Fork this and make it yours
Describe a change in plain English and QuikRun deploys it to your own URL.