/* ============================================================
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 (
);
}
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%"}
);
}
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")}
{msg &&
}
);
}
Object.assign(window, {
fmt, fmtBaht, fmtPct, fmtMonths, parseNum, money, toneVar, useMedia,
PLAN_KEY, PLAN_DEFAULTS, FIN_DEFAULTS, IDEA_DEFAULTS, deepMerge, loadPlan, usePlan,
Amount, TextInput, FieldRow, Stepper, PercentSlider, Segmented, Chips,
card, StepShell, Tip, TotalRow, Flag, Stat, SourceTag, InfoDot, btnPrimary, btnGhost,
ImportExport,
});