/* ============================================================ RESTO SUITE — SHARED CORE Schema: ONE plan object in localStorage under "resto.plan.v1" plan = { idea, market, location, validation, financials, meta } Each module edits ONLY its own namespace; reads others read-only. Migrates legacy "resto.v3" (standalone financial tool) → plan.financials. ============================================================ */ const { useState, useEffect, useRef, useMemo } = React; /* ---------- format helpers ---------- */ const fmt = (n) => (isFinite(n) ? Math.round(n).toLocaleString("en-US") : "—"); const fmtBaht = (n) => "฿" + fmt(n); const fmtPct = (n, d = 0) => (isFinite(n) ? (n * 100).toFixed(d) + "%" : "—"); const fmtMonths = (n) => { if (!isFinite(n) || n <= 0) return "—"; if (n < 24) return Math.round(n) + " mo"; return (n / 12).toFixed(1) + " yr"; }; const parseNum = (s) => { const v = parseFloat(String(s).replace(/[^0-9.]/g, "")); return isNaN(v) ? 0 : v; }; const money = (n) => (isFinite(n) && n !== 0 ? fmtBaht(Math.abs(n)) : "—"); /* ---------- tones ---------- */ const toneVar = (t, kind) => { const map = { green: { fg: "var(--green)", bg: "var(--green-tint)", bd: "rgba(62,142,90,.28)" }, red: { fg: "var(--red)", bg: "var(--red-tint)", bd: "rgba(194,72,59,.28)" }, amber: { fg: "var(--amber)", bg: "var(--amber-tint)", bd: "rgba(183,121,31,.28)" }, muted: { fg: "var(--muted)", bg: "var(--surface-2)", bd: "var(--border)" }, }; return (map[t] || map.muted)[kind]; }; /* ---------- hooks ---------- */ function useMedia(q) { const [m, setM] = useState(() => (typeof matchMedia !== "undefined" ? matchMedia(q).matches : false)); useEffect(() => { const mm = matchMedia(q); const h = (e) => setM(e.matches); mm.addEventListener("change", h); setM(mm.matches); return () => mm.removeEventListener("change", h); }, [q]); return m; } /* ============================================================ PLAN STORE ============================================================ */ const PLAN_KEY = "resto.plan.v1"; const FIN_DEFAULTS = { step: 0, revMode: "simple", monthlySales: 0, detailed: { seats: 0, turns: 0, avgCheck: 0, openDays: 0 }, investment: { renovation: 0, equipment: 0, furniture: 0, deposit: 0, licenses: 0, management: 0, inventory: 0, reserve: 0 }, invItems: { renovation: [], equipment: [], furniture: [], deposit: [], licenses: [], management: [], inventory: [], reserve: [] }, space: { shopSqm: 0, kitchenSqm: 0 }, foodPct: 0.32, staff: 0, rent: 0, utilities: 0, marketing: 0, management: 0, other: 0, costItems: { staff: [], rent: [], utilities: [], marketing: [], management: [], other: [] }, tax: { vatEnabled: true, vatRate: 0.07, pricesIncludeVat: true, inputVatablePct: 0.55, citEnabled: true, citMode: "sme", citFlatRate: 0.20, ssoEnabled: true, ssoHeadcount: 0, }, chart: "bar", }; const IDEA_DEFAULTS = { mode: "form", conceptName: "", tagline: "", category: "", cuisine: "", targetCustomer: [], priceBand: "", channelMix: { dineIn: 70, delivery: 20, takeaway: 10 }, format: { seats: 0, sqm: 0, serviceStyle: "" }, locationType: "", competition: "", rentExpectation: 0, usp: "", founder: { experience: "", capital: 0, risk: "", involvement: "" }, interview: { qIndex: 0, answers: {} }, step: 0, }; const PLAN_DEFAULTS = { idea: IDEA_DEFAULTS, market: null, location: { sites: [], selectedId: null }, validation: null, financials: FIN_DEFAULTS, meta: { updatedAt: null, currency: "THB", country: "TH" }, }; function deepMerge(def, val) { if (val === null || val === undefined) return def; if (Array.isArray(def) || Array.isArray(val)) return val; if (typeof def === "object" && typeof val === "object") { const out = { ...def }; for (const k of Object.keys(val)) out[k] = k in def ? deepMerge(def[k], val[k]) : val[k]; return out; } return val; } function loadPlan() { try { const raw = localStorage.getItem(PLAN_KEY); if (raw) return deepMerge(PLAN_DEFAULTS, JSON.parse(raw)); } catch (e) {} // migrate legacy standalone financial tool state try { const legacy = JSON.parse(localStorage.getItem("resto.v3")) || JSON.parse(localStorage.getItem("resto.v2")); if (legacy) { const { coaching, ...fin } = legacy; return deepMerge(PLAN_DEFAULTS, { financials: fin }); } } catch (e) {} return JSON.parse(JSON.stringify(PLAN_DEFAULTS)); } function usePlan() { const [plan, setPlan] = useState(loadPlan); useEffect(() => { try { localStorage.setItem(PLAN_KEY, JSON.stringify({ ...plan, meta: { ...plan.meta, updatedAt: new Date().toISOString() } })); } catch (e) {} }, [plan]); // setNS("idea", patch) merges patch into that namespace only const setNS = (ns, patch) => setPlan((p) => ({ ...p, [ns]: { ...(p[ns] || {}), ...(typeof patch === "function" ? patch(p[ns]) : patch) } })); const replacePlan = (next) => setPlan(deepMerge(PLAN_DEFAULTS, next)); return { plan, setNS, replacePlan }; } /* ============================================================ INPUT PRIMITIVES (identical to financial tool) ============================================================ */ function Amount({ value, onChange, large }) { const [focus, setFocus] = useState(false); const [draft, setDraft] = useState(""); const display = focus ? draft : value ? fmt(value) : ""; return ( ); } function TextInput({ value, onChange, placeholder, wide, onEnter }) { return ( onChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && onEnter) onEnter(); }} style={{ width: wide ? "100%" : 240, padding: "12px 14px", background: "var(--surface-2)", border: "1.5px solid var(--border)", borderRadius: 12, fontFamily: "inherit", fontSize: 15, fontWeight: 500, color: "var(--ink)", outline: "none", transition: "border-color .15s, box-shadow .15s", }} onFocus={(e) => { e.target.style.borderColor = "var(--accent)"; e.target.style.boxShadow = "0 0 0 4px var(--accent-tint)"; e.target.style.background = "var(--surface)"; }} onBlur={(e) => { e.target.style.borderColor = "var(--border)"; e.target.style.boxShadow = "none"; e.target.style.background = "var(--surface-2)"; }} /> ); } function FieldRow({ label, hint, children, last, top }) { return (
{label}
{hint &&
{hint}
}
{children}
); } function Stepper({ value, onChange, min = 0, max = 9999, step = 1, suffix }) { const btn = (dir) => ( ); return (
{btn(-1)}
{value}{suffix ? {suffix} : null}
{btn(1)}
); } function PercentSlider({ value, onChange }) { const pct = Math.round(value * 100); const inRange = pct >= 28 && pct <= 35; return (
{pct}% {inRange ? "✓ healthy range" : "healthy 28–35%"}
onChange(parseInt(e.target.value) / 100)} style={{ width: "100%" }} />
); } function Segmented({ value, onChange, options, small }) { return (
{options.map((o) => { const active = value === o.v; return ( ); })}
); } function Chips({ options, value, onChange, multi, renderLabel }) { const isOn = (o) => (multi ? (value || []).includes(o) : value === o); const toggle = (o) => { if (multi) { const cur = value || []; onChange(isOn(o) ? cur.filter((x) => x !== o) : [...cur, o]); } else onChange(isOn(o) ? "" : o); }; return (
{options.map((o) => { const on = isOn(o); return ( ); })}
); } /* ---------- layout primitives ---------- */ const card = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: "var(--radius)", padding: 26, boxShadow: "var(--shadow)" }; function StepShell({ kicker, title, lead, children, k }) { return (
{kicker}

{title}

{lead}

{children}
); } function Tip({ children }) { return (
i {children}
); } const TotalRow = ({ label, value, tone }) => (
{label} {value}
); function Flag({ tone, text }) { const c = toneVar(tone, "fg"), bg = toneVar(tone, "bg"); const mark = tone === "green" ? "✓" : tone === "red" ? "!" : "›"; return (
{mark} {text}
); } function Stat({ label, value, sub, tone }) { const fg = tone === "green" ? "var(--green)" : tone === "red" ? "var(--red)" : tone === "amber" ? "var(--amber)" : "var(--ink)"; return (
{label}
{value}
{sub &&
{sub}
}
); } /* source/confidence label — the "estimate vs your input vs benchmark" pattern */ function SourceTag({ kind, confidence }) { const { t } = useLang(); const label = kind === "input" ? t("common.yourInput") : kind === "benchmark" ? t("common.benchmark") : t("common.estimate"); const conf = confidence === "low" ? t("common.low") : confidence; return ( {label}{conf ? ` · ${conf}` : ""} ); } /* round "!" info marker with hover/focus tooltip — Thai tax explainers etc. */ function InfoDot({ text }) { const [open, setOpen] = useState(false); const [flip, setFlip] = useState({ x: false, y: false }); const ref = useRef(null); const show = () => { const r = ref.current ? ref.current.getBoundingClientRect() : { left: 0, bottom: 0 }; setFlip({ x: r.left > window.innerWidth - 330, y: r.bottom > window.innerHeight - 230 }); setOpen(true); }; const tipStyle = { position: "absolute", zIndex: 60, width: 300, maxWidth: "78vw", background: "var(--surface)", border: "1px solid var(--border)", boxShadow: "var(--shadow-lg)", borderRadius: 12, padding: "12px 14px", fontSize: 12.5, lineHeight: 1.5, color: "var(--ink)", fontWeight: 400, fontStyle: "normal", fontFamily: "'Hanken Grotesk',system-ui,sans-serif", textAlign: "left", display: "block", whiteSpace: "normal", animation: "fadeIn .15s", }; tipStyle[flip.y ? "bottom" : "top"] = "calc(100% + 8px)"; tipStyle[flip.x ? "right" : "left"] = -8; return ( setOpen(false)}> {open && {text}} ); } const btnPrimary = { border: "none", cursor: "pointer", borderRadius: 13, padding: "14px 28px", fontSize: 15.5, fontWeight: 700, fontFamily: "inherit", background: "var(--accent)", color: "#fff", boxShadow: "0 1px 2px rgba(27,59,95,.3), 0 10px 24px -10px rgba(27,59,95,.55)", transition: "transform .12s, filter .12s" }; const btnGhost = { border: "1.5px solid var(--border-strong)", cursor: "pointer", borderRadius: 13, padding: "14px 26px", fontSize: 15.5, fontWeight: 600, fontFamily: "inherit", background: "var(--surface)", color: "var(--ink)" }; /* ============================================================ IMPORT / EXPORT PANEL ============================================================ */ function ImportExport({ plan, replacePlan, onClose }) { const { t } = useLang(); const [text, setText] = useState(""); const [msg, setMsg] = useState(null); const json = JSON.stringify(plan, null, 2); const download = () => { const blob = new Blob([json], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "resto-plan.json"; a.click(); URL.revokeObjectURL(a.href); }; const applyText = (raw) => { try { const obj = JSON.parse(raw); if (typeof obj !== "object" || !obj) throw new Error(); replacePlan(obj); setMsg({ tone: "green", text: t("ie.imported") }); } catch (e) { setMsg({ tone: "red", text: t("ie.invalidJson") }); } }; const onFile = (e) => { const f = e.target.files[0]; if (!f) return; const r = new FileReader(); r.onload = () => applyText(r.result); r.readAsText(f); }; return (
e.stopPropagation()} style={{ ...card, width: "min(560px, 100%)", maxHeight: "85vh", overflowY: "auto", padding: 30 }}>

{t("ie.title")}

{t("ie.desc")}
{t("ie.importHeading")}
{t("ie.importDesc")}