const { useState, useEffect, useRef, useMemo } = React; // ============ CONFIG ============ const SHOP = "TAKARA SCALE AGENCY"; const EMAIL = "inquiry@takarascale.jp"; // <-- replace with your real inbox const SHIP_FROM_JPY = 1620; // lowest flat per-kit Air Packet rate (Zone 1) — used for "from" appeal // ============ DATA ============ // status: "in" -> In stock -> Buy button // "out" -> Out of stock -> Waiting list // (Stock states are placeholders for now — update `status` per kit later.) // Products are loaded from products.json (single source, shared with product.php). let PRODUCTS = []; async function loadProducts() { try { const res = await fetch("products.json?v=" + Date.now(), { cache: "no-store" }); const data = await res.json(); const list = Array.isArray(data) ? data : (data && data.products) || []; if (Array.isArray(list) && list.length) { PRODUCTS = list; return true; } } catch (e) {} return false; } const productById = (id) => PRODUCTS.find((p) => p.id === id) || null; // ---- shipping zones + rate table (shared with paypal-lib.php via shipping.json) ---- let SHIP = { zones: [{ id: 4, name: "Zone 4", blurb: "United States" }], rates: [[1000, 1620, 1830, 2500, 3090, 3260], [2000, 2620, 3030, 4300, 5190, 5860]] }; async function loadShipping() { try { const r = await fetch("shipping.json?v=" + Date.now()); const d = await r.json(); if (d && d.zones) SHIP = d; return true; } catch (e) {} return false; } // flat per-kit shipping in JPY; a packet holds up to 2 kits (mirrors the PHP) function shippingJPY(kits, zoneId) { if (kits <= 0) return 0; const zi = Math.max(1, Math.min(5, zoneId)); const rowFor = (g) => SHIP.rates.find((r) => g <= r[0]) || SHIP.rates[SHIP.rates.length - 1]; const full = rowFor(2000)[zi], half = rowFor(1000)[zi]; return Math.floor(kits / 2) * full + (kits % 2 ? half : 0); } // ---- PayPal public config (client id / mode / gating) ---- let PAYPAL = { mode: "sandbox", public_enabled: false, configured: false, client_id: "" }; async function loadPaypal() { try { const r = await fetch("paypal-config.php?v=" + Date.now()); PAYPAL = await r.json(); return true; } catch (e) {} return false; } // ---- cart store (localStorage), with a tiny pub/sub so components re-render ---- const cart = { items: (() => { try { return JSON.parse(localStorage.getItem("tsa_cart") || "[]"); } catch (e) { return []; } })(), subs: [], _save() { try { localStorage.setItem("tsa_cart", JSON.stringify(this.items)); } catch (e) {} this.subs.forEach((f) => f()); }, count() { return this.items.reduce((n, i) => n + i.qty, 0); }, qtyOf(id) { const i = this.items.find((x) => x.id === id); return i ? i.qty : 0; }, add(id, q = 1) { const i = this.items.find((x) => x.id === id); if (i) i.qty = Math.min(20, i.qty + q); else this.items.push({ id, qty: q }); this._save(); }, setQty(id, q) { q = Math.max(0, Math.min(20, q)); if (q === 0) this.items = this.items.filter((x) => x.id !== id); else { const i = this.items.find((x) => x.id === id); if (i) i.qty = q; } this._save(); }, remove(id) { this.items = this.items.filter((x) => x.id !== id); this._save(); }, clear() { this.items = []; this._save(); }, }; function useCart() { const [, f] = useState(0); useEffect(() => { const l = () => f((n) => n + 1); cart.subs.push(l); return () => { cart.subs = cart.subs.filter((x) => x !== l); }; }, []); return cart; } // precise charge amount in the active currency (mirrors pp_amount in PHP, for display) function chargeAmount(jpy, currency) { const c = CURRENCIES[currency] || CURRENCIES.JPY; if (currency === "JPY") return { value: Math.round(jpy), display: "¥" + Math.round(jpy).toLocaleString("en-US") }; const v = jpy * c.perYen; const r = Math.round(v * 100) / 100; return { value: r, display: c.sym + r.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }; } // is checkout shown to this visitor? (live+enabled+configured, or owner test via ?shop=1) function checkoutVisible() { const owner = /[?&]shop=1\b/.test(location.search); return PAYPAL.configured && (PAYPAL.public_enabled || owner); } const SCALES = ["All", "1/20", "1/24"]; const AVAIL = [ { k: "all", label: "All" }, { k: "in", label: "In stock" }, { k: "inquiry", label: "Enquire" }, { k: "out", label: "Sold" }, ]; // ============ CURRENCY ============ // FX vs JPY — units of foreign currency per ¥1. These are fallbacks; live rates // (fetched from open.er-api.com, JPY base) overwrite perYen at runtime when available. const CURRENCIES = { JPY: { sym: "¥", perYen: 1, label: "¥ JPY" }, USD: { sym: "$", perYen: 1 / 150, label: "$ USD" }, EUR: { sym: "€", perYen: 1 / 165, label: "€ EUR" }, GBP: { sym: "£", perYen: 1 / 190, label: "£ GBP" }, AUD: { sym: "A$", perYen: 1 / 100, label: "A$ AUD" }, CAD: { sym: "C$", perYen: 1 / 110, label: "C$ CAD" }, }; const CURRENCY_KEYS = Object.keys(CURRENCIES); // map an ISO country code to one of our currencies (for IP auto-localisation) const EU_CC = ["DE","FR","IT","ES","NL","BE","AT","IE","PT","FI","GR","LU","SK","SI","EE","LV","LT","CY","MT","HR"]; function countryToCur(cc) { cc = (cc || "").toUpperCase(); if (cc === "JP") return "JPY"; if (cc === "GB") return "GBP"; if (cc === "AU" || cc === "NZ") return "AUD"; if (cc === "CA") return "CAD"; if (cc === "US") return "USD"; if (EU_CC.includes(cc)) return "EUR"; return "USD"; } let activeCur = "JPY"; // set synchronously by on each render let ratesLive = false; // true once live FX rates have been applied // Overwrite perYen from an {CUR: per-1-JPY} map (skips JPY / bad values) function applyRates(rates) { let any = false; for (const k of CURRENCY_KEYS) { if (k === "JPY") continue; if (typeof rates[k] === "number" && rates[k] > 0) { CURRENCIES[k].perYen = rates[k]; any = true; } } if (any) ratesLive = true; return any; } // Fetch live FX once a day (cached in localStorage); falls back silently on failure async function loadRates() { const now = Date.now(); try { const cached = JSON.parse(localStorage.getItem("tsa_rates") || "null"); if (cached && cached.r && now - cached.t < 12 * 3600 * 1000) return applyRates(cached.r); } catch (e) {} try { const res = await fetch("https://open.er-api.com/v6/latest/JPY"); const data = await res.json(); if (data && data.rates) { const ok = applyRates(data.rates); try { localStorage.setItem("tsa_rates", JSON.stringify({ t: now, r: data.rates })); } catch (e) {} return ok; } } catch (e) {} return false; } // POST a form submission to the server mailer (contact.php). Throws on failure. async function sendInquiry(payload) { const res = await fetch("contact.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); let data = {}; try { data = await res.json(); } catch (e) {} if (!res.ok || !data.ok) throw new Error(data.error || "Could not send. Please email us directly."); return data; } const yen = (n) => "¥" + n.toLocaleString("en-US"); // money() formats a JPY amount in the currently-selected currency (≈ = approx) function money(n) { const c = CURRENCIES[activeCur] || CURRENCIES.JPY; if (activeCur === "JPY") return yen(n); const v = n * c.perYen; const r = v >= 50 ? Math.round(v / 5) * 5 : Math.round(v); return c.sym + r.toLocaleString("en-US"); } const statusLabel = (s) => s === "in" ? "In stock" : s === "inquiry" ? "Enquire" : "Sold out"; // total photos a product has (front + decal + box[] + runner[] + note images) function imgCount(p) { const im = p.images || {}; let n = 0; if (im.front) n++; if (im.decal) n++; if (Array.isArray(im.box)) n += im.box.length; if (Array.isArray(im.runner)) n += im.runner.length; if (Array.isArray(p.notes)) n += p.notes.filter((nt) => nt && nt.img).length; if (n === 0 && p.img) n = 1; // legacy single-image products return n; } // ============ MAILTO ============ function buildMailto({ subject, body }) { return `mailto:${EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; } // ============ reveal hook ============ function useReveal(dep) { const ref = useRef(null); useEffect(() => { if (!ref.current) return; const els = ref.current.querySelectorAll(".card"); if (!("IntersectionObserver" in window)) { els.forEach((el) => el.classList.add("in")); return; } const io = new IntersectionObserver( (entries) => entries.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } }), { threshold: 0.08 } ); els.forEach((el) => io.observe(el)); // safety net: if the observer hasn't revealed a card shortly after mount, show them anyway const t = setTimeout(() => { els.forEach((el) => el.classList.add("in")); }, 1200); return () => { clearTimeout(t); io.disconnect(); }; }, [dep]); return ref; } // close an overlay (modal / cart) when the browser/phone back button is pressed function useBackClose(onClose) { useEffect(() => { let byPop = false; window.history.pushState({ ovl: 1 }, ""); const onPop = () => { byPop = true; onClose(); }; window.addEventListener("popstate", onPop); return () => { window.removeEventListener("popstate", onPop); if (!byPop) window.history.back(); // closed via button → pop our history entry }; }, []); } // ============ MT FUJI SVG ============ function FujiSilhouette({ className }) { return ( ); } // ============ ICONS ============ const Icon = { japan: ( ), search: ( ), box: ( ), cart: ( ), bell: ( ), }; // ============ TOP BAR ============ function NoticeBar() { const ref = useRef(null); // keep the sticky header offset (--notice-h) in sync with this bar's height useEffect(() => { const sync = () => { const h = ref.current ? ref.current.offsetHeight : 0; document.documentElement.style.setProperty("--notice-h", h + "px"); }; sync(); window.addEventListener("resize", sync); return () => window.removeEventListener("resize", sync); }, []); return (
Checkout is temporarily under maintenance — please place orders via the enquiry form (Ask).
); } function TopBar() { return (
Based in Japan · Flat tracked shipping from {money(SHIP_FROM_JPY)} / kit · Worldwide
); } // ============ HEADER ============ function Header({ onContact, currency, setCurrency, onCart }) { const c = useCart(); const n = c.count(); return (
TAKARA SCALE AGENCY Discontinued model kit export, from Japan
); } // ============ HERO EMBLEM (Rising sun + Mt Fuji) ============ function HeroEmblem() { const cx = 260, cy = 250, r0 = 122, r1 = 244; const p = (r, ang) => `${(cx + r * Math.cos(ang)).toFixed(1)} ${(cy + r * Math.sin(ang)).toFixed(1)}`; const rays = Array.from({ length: 32 }, (_, i) => { const a = (i * (360 / 32)) * Math.PI / 180; const w = 0.045; return `M${p(r0, a - w)} L${p(r1, a)} L${p(r0, a + w)} Z`; }); return ( ); } // ============ HERO ============ function Hero({ ready }) { const inStock = PRODUCTS.filter((p) => p.status === "in").length; return (
Discontinued · Collector grade

Rare racing model kits, found in Japan.

A rotating stock of out-of-production Tamiya, Fujimi and Hasegawa racing kits — Grand Prix, sports and GT. Buy what's in stock outright; for sold-out boxings, join the waiting list and we'll source the next one from the Japanese market.

Browse the collection
); } // ============ FEATURES ============ function Features() { const items = [ { ic: Icon.japan, h: "Based in Japan", p: "We operate inside Japan — the world's deepest market for discontinued Tamiya, Fujimi and Hasegawa kits." }, { ic: Icon.search, h: "Second-hand access", p: "Direct reach into the Japanese second-hand and collector market, where long-deleted kits are far easier to find than abroad." }, { ic: Icon.box, h: "Condition first", p: "Unless noted, every kit is genuine, sealed and unbuilt. We send condition photos before you commit." }, ]; return (
{items.map((it, i) => (
{it.ic}

{it.h}

{it.p}

))}
); } // ============ PRODUCT CARD ============ function Card({ p, onBuy, onWaitlist, onInquiry }) { const isIn = p.status === "in"; const isInquiry = p.status === "inquiry"; const statusCls = isInquiry ? "status--inquiry" : `status--${p.status}`; const detail = `product.php?id=${p.id}`; return (
{p.scale} {p.maker}
{statusLabel(p.status)} {`${p.maker}
{p.maker} · {p.scale}

{p.title}

{p.sub}

{p.specs.slice(0, 4).map((s) => {s})} {p.specs.length > 4 && +{p.specs.length - 4} more}
View details & photos →
{isIn ? "Price" : isInquiry ? "Guide price" : "Status"}
{isIn || isInquiry ? money(p.price) : "Sold out"}
{isIn && } {isInquiry && } {!isIn && !isInquiry && }
); } // ============ STOCK ============ function Stock({ onBuy, onWaitlist, onInquiry, ready }) { const [scale, setScale] = useState("All"); const [avail, setAvail] = useState("all"); const [sort, setSort] = useState("featured"); const list = useMemo(() => { let l = PRODUCTS.filter((p) => (scale === "All" || p.scale === scale) && (avail === "all" || p.status === avail)); const arr = (p) => (p.arrived ? Date.parse(p.arrived) || 0 : 0); // newest arrival first if dated if (sort === "featured") { // Featured: available first → Rank S→A→B → most photos → newest arrival; sold/out at the bottom const av = (p) => (p.status === "out" ? 0 : 1); const rk = (p) => ({ S: 3, A: 2, B: 1 }[p.rank] || 0); l = [...l].sort((a, b) => av(b) - av(a) || rk(b) - rk(a) || imgCount(b) - imgCount(a) || arr(b) - arr(a) || PRODUCTS.indexOf(a) - PRODUCTS.indexOf(b)); } else if (sort === "price-asc") l = [...l].sort((a, b) => a.price - b.price); else if (sort === "price-desc") l = [...l].sort((a, b) => b.price - a.price); else if (sort === "name") l = [...l].sort((a, b) => a.title.localeCompare(b.title)); else if (sort === "stock") l = [...l].sort((a, b) => (a.status === b.status ? 0 : a.status === "in" ? -1 : 1)); return l; }, [scale, avail, sort, ready]); // pagination — 10 per page const PER = 10; const [page, setPage] = useState(1); useEffect(() => { setPage(1); }, [scale, avail, sort]); const pages = Math.max(1, Math.ceil(list.length / PER)); const cur = Math.min(page, pages); const pageList = list.slice((cur - 1) * PER, cur * PER); const goPage = (n) => { setPage(n); const el = document.getElementById("stock"); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); }; const ref = useReveal(scale + avail + sort + cur + ready); return (
The Collection

Buy in stock, waitlist the rest

Genuine and, unless noted, sealed and unbuilt. In-stock kits ship now — sold-out boxings take a waiting list, and we'll source the next one from Japan.

Flat tracked worldwide shipping from {money(SHIP_FROM_JPY)} / kit · see rates →

Scale {SCALES.map((s) => ( ))}
Status {AVAIL.map((a) => ( ))}
{list.length} {list.length === 1 ? "kit" : "kits"}
{!ready ? (
Loading the collection…
) : list.length > 0 ? (
{pageList.map((p) => )}
{pages > 1 && (
{Array.from({ length: pages }, (_, i) => i + 1).map((n) => ( ))}
)}
) : ( onWaitlist(null)} /> )}
); } function EmptyState({ scale, avail, onRequest }) { const what = avail === "in" ? "in-stock" : avail === "out" ? "waiting-list" : ""; return (
No {scale !== "All" ? scale + " " : ""}{what} kits right now

But we can find one.

Tell us the exact kit you want and we'll source it from the Japanese market — or add you to the waiting list.

); } // ============ SOURCING ============ function Sourcing({ onRequest }) { return (
Ask the Agency

Tell us what you're after.
We'll find it, price it, ship it.

Found a kit in our stock, want something we don't list yet, or just have a question about condition or shipping? This is the one place to ask. We're based inside Japan with direct reach into the domestic second-hand market, collector channels and local shops — so deleted kits that rarely surface abroad are well within reach.

Tell us the kit or question you have.
We'll source it from Japan's domestic market and quote you.
); } function RequestForm({ onRequest }) { const [f, setF] = useState({ name: "", email: "", country: "", scale: "1/20", model: "", notes: "", company: "" }); const [sending, setSending] = useState(false); const [err, setErr] = useState(null); const upd = (k) => (e) => setF({ ...f, [k]: e.target.value }); const submit = async (e) => { e.preventDefault(); if (sending) return; setSending(true); setErr(null); const hasModel = f.model.trim(); try { await sendInquiry({ type: "sourcing", name: f.name, email: f.email, country: f.country, model: f.model, scale: f.scale, message: f.notes, company: f.company, // honeypot }); onRequest({ label: hasModel ? f.model : "your inquiry", kind: "sourcing", ok: true }); } catch (e2) { setErr(e2.message || "Could not send. Please email us directly."); } finally { setSending(false); } }; return (

Ask the agency

Source a kit, check a price, or ask about shipping — we'll reply by email.
{err &&
{err}
}
We'll reply to your email.
); } // ============ TRANSACTION MODAL (buy / waitlist / general) ============ const MODE = { buy: { k: "Order", heading: (p) => p.title, badge: "Buy", intro: (p) => `In stock now · ${money(p.price)}`, label: "Place order →", note: "We'll confirm stock, total and shipping by email.", msgLabel: "Message (optional)", msgReq: false, msgPlaceholder: "Any questions about condition, decals, shipping…", }, inquiry: { k: "Enquiry", heading: (p) => p.title, badge: "Enquire", intro: (p) => `Guide price · ${money(p.price)}`, label: "Send enquiry →", note: "We'll confirm availability, final price and shipping by email.", msgLabel: "Message (optional)", msgReq: false, msgPlaceholder: "Any questions about condition, price, shipping, availability…", }, waitlist: { k: "Waiting list", heading: (p) => p.title, badge: "Out of stock", intro: (p) => `Currently out of stock — join the waiting list`, label: "Join waiting list →", note: "We'll email you the moment one is sourced.", msgLabel: "Anything we should know? (optional)", msgReq: false, msgPlaceholder: "Target budget, condition you'd accept, alternative liveries…", }, general: { k: "Contact", heading: () => "Send an inquiry", badge: null, intro: () => null, label: "Send inquiry →", note: null, msgLabel: "Message", msgReq: true, msgPlaceholder: "What are you looking for?", }, }; function TxModal({ mode, product, onClose, onSent }) { const [f, setF] = useState({ name: "", email: "", country: "", message: "", company: "" }); const [sending, setSending] = useState(false); const [err, setErr] = useState(null); const upd = (k) => (e) => setF({ ...f, [k]: e.target.value }); const cfg = MODE[mode]; const isItem = !!product; useBackClose(onClose); useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, []); const submit = async (e) => { e.preventDefault(); if (sending) return; setSending(true); setErr(null); try { await sendInquiry({ type: mode, name: f.name, email: f.email, country: f.country, message: f.message, item: product ? { maker: product.maker, title: product.title, scale: product.scale, price: product.price } : null, currency: activeCur, company: f.company, // honeypot }); onSent({ label: isItem ? product.title : "your inquiry", kind: mode, ok: true }); } catch (e2) { setErr(e2.message || "Could not send. Please email us directly."); } finally { setSending(false); } }; return (
{ if (e.target === e.currentTarget) onClose(); }}>
{isItem && }
{cfg.k}

{cfg.heading(product || {})}

{isItem &&
{product.maker} · {product.scale} · {cfg.intro(product)}
}
{err &&
{err}
}
{cfg.note || "We'll reply to your email shortly."}
); } // ============ SENT BANNER ============ const SENT = { buy: { k: "Order received", h: "Thank you — message sent", body: (l) => <>We've received your order request for {l}. We'll reply by email to confirm the total, condition photos and shipping to your country. }, inquiry: { k: "Enquiry sent", h: "Thank you — message sent", body: (l) => <>We've received your enquiry about {l}. We'll reply by email with availability, final price and shipping details. }, waitlist: { k: "On the list", h: "Thank you — message sent", body: (l) => <>We've added your waiting-list request for {l}. We'll email you the moment one is sourced. }, sourcing: { k: "Request sent", h: "Thank you — message sent", body: (l) => <>We've received your request for {l}. We'll hunt it down across Japan and reply by email with availability and a price. }, general: { k: "Message sent", h: "Thank you — message sent", body: (l) => <>We've received your message about {l}. We'll reply by email shortly. }, order: { k: "Order confirmed", h: "Thank you — payment received", body: () => <>Your payment went through and your order is confirmed. We'll email your receipt and tracking as soon as it ships from Japan. }, }; function SentBanner({ label, kind, onClose }) { const cfg = SENT[kind] || SENT.inquiry; useBackClose(onClose); return (
{ if (e.target === e.currentTarget) onClose(); }}>
{cfg.k}

{cfg.h}

{cfg.body(label)}
We'll reply to the email address you gave us — please check your inbox (and spam).
); } // ============ INQUIRY CTA ============ function InquiryCTA({ onOpen }) { return (
Inquiry

Found one in stock? It's yours.

In-stock kits can be bought outright — we confirm the total, condition photos and shipping by email. Anything sold out, join the waiting list, or send us a kit to hunt down across Japan.

Request a specific model
); } // ============ FOOTER ============ function Footer() { return ( ); } // ============ CART / CHECKOUT ============ let _ppLoadedCur = null; function loadPaypalSdk(clientId, currency) { return new Promise((resolve) => { if (window.paypal && _ppLoadedCur === currency) return resolve(window.paypal); const old = document.getElementById("paypal-sdk"); if (old) { old.remove(); try { window.paypal = undefined; } catch (e) {} } const s = document.createElement("script"); s.id = "paypal-sdk"; s.src = "https://www.paypal.com/sdk/js?client-id=" + encodeURIComponent(clientId) + "¤cy=" + encodeURIComponent(currency) + "&intent=capture&components=buttons"; s.onload = () => { _ppLoadedCur = currency; resolve(window.paypal); }; s.onerror = () => resolve(null); document.body.appendChild(s); }); } function CartDrawer({ currency, onClose, onInquire, onDone }) { const c = useCart(); const zones = SHIP.zones || []; const [zone, setZone] = useState(() => (zones.find((z) => z.id === 4) ? 4 : (zones[0] ? zones[0].id : 4))); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const ppRef = useRef(null); useBackClose(onClose); useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, []); // resolve cart lines against products const lines = c.items.map((it) => ({ it, p: productById(it.id) })).filter((x) => x.p); const kits = lines.reduce((n, x) => n + x.it.qty, 0); // shipping weight units: 1/12 kits are large → count as 2 units (≈2 kg); others 1 unit const units = lines.reduce((n, x) => n + x.it.qty * (x.p.scale === "1/12" ? 2 : 1), 0); const subtotalJPY = lines.reduce((n, x) => n + x.p.price * x.it.qty, 0); const shipJPY = shippingJPY(units, zone); const totalJPY = subtotalJPY + shipJPY; const showCheckout = checkoutVisible(); // render PayPal buttons when visible useEffect(() => { if (!showCheckout || lines.length === 0 || !ppRef.current) return; let cancelled = false; loadPaypalSdk(PAYPAL.client_id, currency).then((paypal) => { if (cancelled || !paypal || !ppRef.current) return; ppRef.current.innerHTML = ""; try { paypal.Buttons({ style: { layout: "vertical", shape: "rect", label: "paypal" }, createOrder: async () => { setErr(null); const res = await fetch("paypal-create-order.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: cart.items, currency, zone }), }); const d = await res.json(); if (!d.id) { setErr("Could not start checkout. Please try again."); throw new Error("create"); } return d.id; }, onApprove: async (data) => { setBusy(true); try { const res = await fetch("paypal-capture-order.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ orderID: data.orderID }), }); const d = await res.json(); if (d.ok) { cart.clear(); onDone({ label: "your order", kind: "order", ok: true }); } else setErr("Payment didn't complete. You were not charged — please try again."); } catch (e) { setErr("Network error during payment."); } finally { setBusy(false); } }, onError: () => setErr("PayPal had a problem. Please try again, or send an enquiry."), }).render(ppRef.current); } catch (e) { setErr("Could not load PayPal."); } }); return () => { cancelled = true; }; }, [currency, zone, showCheckout, lines.length]); return (
{ if (e.target === e.currentTarget) onClose(); }}>
Cart

Your kits

{lines.length === 0 ? (
Your cart is empty. Add a kit from the collection.
) : ( {lines.map(({ it, p }) => (
{p.title}
{p.maker} · {p.scale} · {chargeAmount(p.price, currency).display}
{it.qty}
))}
Subtotal ({kits} kit{kits > 1 ? "s" : ""}){chargeAmount(subtotalJPY, currency).display}
Shipping (tracked){chargeAmount(shipJPY, currency).display}
Total{chargeAmount(totalJPY, currency).display}
{err &&
{err}
} {busy &&
Finalising payment…
} {showCheckout ? (

Charged in {currency} via PayPal{PAYPAL.mode !== "live" ? " (SANDBOX / test mode)" : ""}. Tracked worldwide shipping included.

) : (

Card checkout is being finalised. For now we confirm your order and total by email — we'll reply fast.

)}
)}
); } // ============ APP ============ function App() { // modal = { mode, product } | null const [modal, setModal] = useState(null); const [sent, setSent] = useState(null); // currency — USD by default; auto-localise by IP once if there's no saved choice const hadSavedCur = useRef(false); const [currency, setCurrency] = useState(() => { try { const s = localStorage.getItem("tsa_cur"); if (s && CURRENCIES[s]) { hadSavedCur.current = true; return s; } } catch (e) {} return "USD"; }); activeCur = currency; // make selection available to module-level money() useEffect(() => { try { localStorage.setItem("tsa_cur", currency); } catch (e) {} }, [currency]); useEffect(() => { if (hadSavedCur.current) return; fetch("https://ipapi.co/json/").then((r) => r.json()).then((d) => { const cur = countryToCur(d && d.country_code); if (cur && CURRENCIES[cur]) setCurrency(cur); }).catch(() => {}); }, []); // pull live FX rates once on load, then re-render so converted prices update const [, bumpRates] = useState(0); useEffect(() => { loadRates().then((ok) => { if (ok) bumpRates((n) => n + 1); }); }, []); // load products.json + shipping + paypal config before showing the store const [ready, setReady] = useState(false); useEffect(() => { Promise.all([loadProducts(), loadShipping(), loadPaypal()]).then(() => setReady(true)); }, []); const [cartOpen, setCartOpen] = useState(false); const openBuy = (p) => setModal({ mode: "buy", product: p }); const openInquiry = (p) => setModal({ mode: "inquiry", product: p }); const openWaitlist = (p) => setModal(p ? { mode: "waitlist", product: p } : { mode: "general", product: null }); const openGeneral = () => setModal({ mode: "general", product: null }); const addToCart = (p) => { cart.add(p.id); setCartOpen(true); }; return (
setCartOpen(true)} /> setSent(r)} />