/* Root App — wires inputs ↔ engine ↔ results, owns sharing & PDF actions */ const { useState, useEffect, useMemo, useRef } = React; // Pulls live USD/AMD from the same source as the site's currency ticker, with the // same localStorage cache (6h TTL). Returns the AMD-per-USD rate as a number. const FX_API_KEY = "f50027e2feddbf7bf054a8c4"; const FX_CACHE_KEY = "sicosa_fx_v1"; const FX_TTL_MS = 6 * 60 * 60 * 1000; async function fetchLiveFxAmd({ force = false } = {}) { if (!force) { try { const raw = localStorage.getItem(FX_CACHE_KEY); if (raw) { const c = JSON.parse(raw); if (c && c.rates && Number.isFinite(c.rates.AMD) && Date.now() - c.ts < FX_TTL_MS) { return c.rates.AMD; } } } catch {} } const res = await fetch(`https://v6.exchangerate-api.com/v6/${FX_API_KEY}/latest/USD`); if (!res.ok) throw new Error("fx fetch failed"); const data = await res.json(); if (data.result !== "success") throw new Error("fx api error"); try { localStorage.setItem(FX_CACHE_KEY, JSON.stringify({ ts: Date.now(), rates: data.conversion_rates })); } catch {} return data.conversion_rates.AMD; } function App() { const E = window.SicosaEngine; // Initial state: shareable URL hash > defaults const [inputs, setInputs] = useState(() => { const shared = window.Share.hydrate(); const fuel = shared && shared.fuelType || "diesel"; const base = E.defaultsFor(fuel); const merged = { ...base, ...(shared || {}) }; // Back-compat: older shared links may not include customs duty if (!Number.isFinite(merged.customsDutyAmdPerTon)) merged.customsDutyAmdPerTon = 0; return merged; }); const [toast, setToast] = useState(null); // Track whether density has been manually edited; if so, don't auto-fill on fuel change const densityTouched = useRef(false); // Track whether fxRate has been manually edited; if so, don't override with live rate const fxTouched = useRef(false); const set = (patch) => { setInputs((s) => { const next = { ...s, ...patch }; // Track density edits if ("density" in patch) densityTouched.current = true; // Track fxRate edits (only user edits — auto-fill uses setInputs directly) if ("fxRate" in patch) fxTouched.current = true; // Auto-fill density on fuel change (only if user hasn't manually edited) if ("fuelType" in patch) { const fuel = E.FUELS[patch.fuelType]; if (fuel) { if (!densityTouched.current || s.density === E.FUELS[s.fuelType]?.density) { next.density = fuel.density; densityTouched.current = false; } } } return next; }); }; // Compute results — engine + errors const { result, errors } = useMemo(() => { const errs = E.validate(inputs); if (Object.keys(errs).length === 0) { try { return { result: E.calculate(inputs), errors: {} }; } catch (e) { return { result: null, errors: { _engine: e.message } }; } } return { result: null, errors: errs }; }, [inputs]); // Note: URL hash is intentionally NOT updated on every input change. // It is only written when the user clicks "Copy Link" (window.Share.copyShareLink). // This keeps fresh opens clean — no stale densities or FX rates carry over from // previous sessions. Inbound share links (?…#q=…) still hydrate correctly. // Lucide icon refresh on every render useEffect(() => { if (window.lucide && window.lucide.createIcons) window.lucide.createIcons(); }); // Auto-fetch live USD/AMD rate from the same source as the site ticker, then apply +0.5%. // User-edited rates win (fxTouched). Shared URL (?fxRate=…) also wins (treated as touched). useEffect(() => { const shared = window.Share.hydrate(); if (shared && Number.isFinite(shared.fxRate)) { fxTouched.current = true; return; } fetchLiveFxAmd().then((amd) => { if (!Number.isFinite(amd) || fxTouched.current) return; const adjusted = Math.round(amd * 1.005 * 100) / 100; // +0.5%, 2 decimals setInputs((s) => ({ ...s, fxRate: adjusted })); }).catch(() => { /* silent — keep default */ }); }, []); // Toast auto-dismiss useEffect(() => { if (!toast) return; const id = setTimeout(() => setToast(null), 2400); return () => clearTimeout(id); }, [toast]); // Actions const onCopyLink = async () => { const r = await window.Share.copyShareLink(inputs); setToast(r.ok ? "Link copied — paste anywhere to share" : "Link ready in address bar"); }; const onExportPDF = () => { setTimeout(() => window.exportPDF(), 40); }; const onReset = () => { densityTouched.current = false; fxTouched.current = false; // Clear any shared-link hash so refresh won't restore old values try { window.history.replaceState(null, "", window.location.pathname + window.location.search); } catch {} setInputs(E.defaultsFor(inputs.fuelType)); setToast("Reset to defaults for " + E.FUELS[inputs.fuelType].label); // Force-pull a fresh live rate after reset (bypass localStorage cache) fetchLiveFxAmd({ force: true }).then((amd) => { if (!Number.isFinite(amd) || fxTouched.current) return; const adjusted = Math.round(amd * 1.005 * 100) / 100; setInputs((s) => ({ ...s, fxRate: adjusted })); }).catch(() => {}); }; // For results panel — if validation is failing, show last good or empty const safeResult = result; return (
set({ fuelType: f })} />
Inputs
All fields editable · density auto-fills
{safeResult ? ( set({ includeFixedCosts: !inputs.includeFixedCosts })} customsDutyAmdPerTon={inputs.customsDutyAmdPerTon || 0} onChangeCustomsDuty={(v) => set({ customsDutyAmdPerTon: v })} /> ) : null}
{safeResult ? :
{Object.values(errors).join(" · ")}
}
{/* Hidden print-only doc, visible only when printing / Save as PDF */} {safeResult ? : null} {toast ?
{toast}
: null}
); } ReactDOM.createRoot(document.getElementById("root")).render();