/* ============================================================ RESTO SUITE — BUSINESS IDEA MODULE Writes: plan.idea (its own namespace only) Adapter: askInterview(history) — v1 heuristic extraction, API-ready for a real LLM later (same interface). ============================================================ */ const { useState: useStateI, useRef: useRefI, useEffect: useEffectI } = React; /* ---------- Thai-market presets & benchmarks ---------- */ const CATEGORIES = ["QSR", "Café", "Casual dining", "Fine dining", "Bar", "Cloud kitchen", "Bakery", "Buffet"]; const CUISINE_PRESETS = ["Thai", "Isaan", "Japanese", "Ramen", "Sushi", "Korean", "Chinese", "Italian", "Pizza", "Western", "Seafood", "Fusion", "Vegan"]; const CUSTOMERS = ["Students", "Office workers", "Families", "Tourists", "Expats", "Hi-so"]; const LOCATION_TYPES = ["Shopping mall", "Street / shophouse", "Community mall", "CBD office", "Tourist zone", "Campus"]; const SERVICE_STYLES = ["Counter service", "Table service", "Self service", "Delivery only"]; const EXPERIENCE = ["First-timer", "Worked in F&B", "Opened before"]; const RISK = ["Cautious", "Balanced", "Aggressive"]; const INVOLVEMENT = ["Owner-operator", "Investor"]; const PRICE_BANDS = { "Budget": { range: "฿60–150 / head", rangeTh: "฿60–150 ต่อคน", mid: 100 }, "Mid": { range: "฿150–400 / head", rangeTh: "฿150–400 ต่อคน", mid: 260 }, "Premium": { range: "฿400–900 / head", rangeTh: "฿400–900 ต่อคน", mid: 600 }, "Luxury": { range: "฿900+ / head", rangeTh: "฿900+ ต่อคน", mid: 1400 }, }; function priceRange(band, lang) { return lang === "th" ? PRICE_BANDS[band].rangeTh : PRICE_BANDS[band].range; } /* rough fit-out benchmark per seat, by category — labelled as benchmark, never fact */ const FITOUT_PER_SEAT = { "QSR": 30000, "Café": 40000, "Casual dining": 45000, "Fine dining": 120000, "Bar": 55000, "Cloud kitchen": null, "Bakery": 40000, "Buffet": 35000, }; const CLOUD_KITCHEN_FITOUT = 900000; /* rough monthly-sales benchmark from format + price band — always labelled benchmark */ function salesBenchmark(idea, t) { const band = PRICE_BANDS[idea.priceBand]; const seats = idea.format.seats || 0; if (!band || seats <= 0) return null; const salesEst = seats * 1.8 * band.mid * 26; // seats × 1.8 turns × band mid × 26 days const basis = t ? t("idea.benchSeatsTurns", { seats, mid: fmtBaht(band.mid) }) : `${seats} seats × 1.8 turns × ${fmtBaht(band.mid)} × 26 days`; return { salesEst, healthyRent: Math.round(salesEst * 0.10), basis }; } function fitoutEstimate(idea, t, lang) { if (!idea.category) return null; if (idea.category === "Cloud kitchen") return { value: CLOUD_KITCHEN_FITOUT, basis: t ? t("idea.benchCloudKitchen") : "typical Bangkok cloud-kitchen setup" }; const per = FITOUT_PER_SEAT[idea.category]; const seats = idea.format.seats || 30; if (!per) return null; const catLabel = t ? optLabel(lang, idea.category) : idea.category.toLowerCase(); const basis = t ? t("idea.benchPerSeat", { per: fmtBaht(per), seats, cat: catLabel }) : `${fmtBaht(per)}/seat × ${seats} seats (Thai ${idea.category.toLowerCase()} benchmark)`; return { value: per * seats, basis }; } /* ============================================================ ADAPTER — askInterview (v1: heuristics · FUTURE: real LLM call) Interface stays stable: ({ questionId, answer, idea }) => { patch, heard } ============================================================ */ function extractNumber(s, unitWords) { const t = s.toLowerCase().replace(/,/g, ""); const re = new RegExp(`(\\d+(?:\\.\\d+)?)\\s*(?:${unitWords.join("|")})`, "i"); const m = t.match(re); return m ? parseFloat(m[1]) : null; } function parseMoneyTh(s) { const t = s.toLowerCase().replace(/,/g, ""); let m = t.match(/(\d+(?:\.\d+)?)\s*(?:m\b|million|ล้าน)/i); if (m) return parseFloat(m[1]) * 1000000; m = t.match(/(\d+(?:\.\d+)?)\s*(?:k\b|พัน|thousand)/i); if (m) return parseFloat(m[1]) * 1000; m = t.match(/฿?\s*(\d{4,})/); if (m) return parseFloat(m[1]); return null; } const hasAny = (s, words) => words.some((w) => s.toLowerCase().includes(w)); const INTERVIEW = [ { id: "concept", extract: (a, idea) => { const patch = {}; const heard = []; const catMap = [ ["Café", ["cafe", "café", "coffee", "คาเฟ่", "กาแฟ"]], ["Bar", ["bar", "cocktail", "บาร์", "เหล้า", "craft beer"]], ["Cloud kitchen", ["cloud", "delivery only", "ghost kitchen"]], ["Bakery", ["bakery", "bread", "croissant", "เบเกอรี่", "ขนมปัง"]], ["Buffet", ["buffet", "บุฟเฟ่ต์", "หมูกระทะ", "shabu", "ชาบู"]], ["Fine dining", ["fine dining", "tasting menu", "chef's table", "omakase"]], ["QSR", ["fast", "quick", "street", "grab and go", "kiosk", "ข้าวแกง", "ตามสั่ง"]], ]; for (const [cat, kws] of catMap) if (hasAny(a, kws)) { patch.category = cat; break; } const cuiMap = [ ["Ramen", ["ramen", "ราเมง", "ราเมน"]], ["Sushi", ["sushi", "ซูชิ"]], ["Japanese", ["japanese", "ญี่ปุ่น", "izakaya"]], ["Korean", ["korean", "เกาหลี"]], ["Isaan", ["isaan", "อีสาน", "ส้มตำ", "somtum"]], ["Thai", ["thai", "ไทย"]], ["Pizza", ["pizza", "พิซซ่า"]], ["Italian", ["italian", "pasta", "อิตาเลียน"]], ["Chinese", ["chinese", "จีน", "dim sum", "ติ่มซำ"]], ["Seafood", ["seafood", "ซีฟู้ด", "ทะเล"]], ["Vegan", ["vegan", "plant", "เจ", "มังสวิรัติ"]], ["Western", ["western", "steak", "burger", "สเต๊ก", "เบอร์เกอร์"]], ]; for (const [cui, kws] of cuiMap) if (hasAny(a, kws)) { patch.cuisine = cui; break; } if (!patch.category && patch.cuisine) patch.category = "Casual dining"; if (patch.category) heard.push(["Category", patch.category]); if (patch.cuisine) heard.push(["Cuisine", patch.cuisine]); return { patch, heard }; }, }, { id: "name", extract: (a) => { const patch = {}; const heard = []; const dash = a.split(/\s+[—–-]\s+|:/); if (dash.length > 1) { patch.conceptName = dash[0].trim(); patch.tagline = dash.slice(1).join(" ").trim(); } else if (a.trim().length > 1 && a.trim().split(/\s+/).length <= 6) patch.conceptName = a.trim(); else patch.tagline = a.trim(); if (patch.conceptName) heard.push(["Name", patch.conceptName]); if (patch.tagline) heard.push(["Tagline", "“" + patch.tagline + "”"]); return { patch, heard }; }, }, { id: "customer", extract: (a) => { const found = []; const map = [ ["Students", ["student", "นักศึกษา", "นักเรียน", "uni"]], ["Office workers", ["office", "ออฟฟิศ", "พนักงาน", "worker", "lunch crowd"]], ["Families", ["famil", "ครอบครัว", "kids", "เด็ก"]], ["Tourists", ["tourist", "นักท่องเที่ยว", "travel"]], ["Expats", ["expat", "ต่างชาติ", "foreigner"]], ["Hi-so", ["hi-so", "ไฮโซ", "premium", "wealthy", "high end", "high-end"]], ]; for (const [c, kws] of map) if (hasAny(a, kws)) found.push(c); return { patch: found.length ? { targetCustomer: found } : {}, heard: found.length ? [["Target customer", found.join(", ")]] : [] }; }, }, { id: "price", extract: (a) => { const n = parseMoneyTh(a) || extractNumber(a, ["baht", "บาท", "฿", ""]); if (!n) return { patch: {}, heard: [] }; const band = n < 150 ? "Budget" : n < 400 ? "Mid" : n < 900 ? "Premium" : "Luxury"; return { patch: { priceBand: band }, heard: [["Price band", `${band} (${PRICE_BANDS[band].range})`]] }; }, }, { id: "channels", extract: (a) => { const nums = (a.match(/\d+/g) || []).map(Number).filter((n) => n <= 100); if (nums.length >= 3 && nums[0] + nums[1] + nums[2] === 100) { const mix = { dineIn: nums[0], delivery: nums[1], takeaway: nums[2] }; return { patch: { channelMix: mix }, heard: [["Channel mix", `${mix.dineIn} dine-in / ${mix.delivery} delivery / ${mix.takeaway} takeaway`]] }; } if (hasAny(a, ["delivery only", "เดลิเวอรี่อย่างเดียว"])) return { patch: { channelMix: { dineIn: 0, delivery: 90, takeaway: 10 } }, heard: [["Channel mix", "delivery-led"]] }; if (hasAny(a, ["mostly dine", "dine-in", "นั่งกิน", "นั่งทาน"])) return { patch: { channelMix: { dineIn: 75, delivery: 15, takeaway: 10 } }, heard: [["Channel mix", "dine-in-led (75/15/10)"]] }; return { patch: {}, heard: [] }; }, }, { id: "format", extract: (a) => { const patch = { format: {} }; const heard = []; const seats = extractNumber(a, ["seats?", "ที่นั่ง", "covers"]); const sqm = extractNumber(a, ["sqm", "sq\\.?m", "ตร\\.?ม", "ตารางเมตร", "square"]); if (seats) { patch.format.seats = Math.round(seats); heard.push(["Seats", String(Math.round(seats))]); } if (sqm) { patch.format.sqm = Math.round(sqm); heard.push(["Size", Math.round(sqm) + " m²"]); } for (const s of SERVICE_STYLES) if (a.toLowerCase().includes(s.toLowerCase().split(" ")[0])) { patch.format.serviceStyle = s; heard.push(["Service", s]); break; } return { patch: Object.keys(patch.format).length ? patch : {}, heard, mergeFormat: true }; }, }, { id: "location", extract: (a) => { const map = [ ["Shopping mall", ["mall", "ห้าง", "เซ็นทรัล", "central", "department"]], ["Community mall", ["community"]], ["CBD office", ["cbd", "silom", "สีลม", "sathorn", "สาทร", "asoke", "อโศก", "office district"]], ["Tourist zone", ["tourist", "khao san", "ข้าวสาร", "beach", "old town", "นักท่องเที่ยว"]], ["Campus", ["campus", "university", "มหาวิทยาลัย", "มหาลัย"]], ["Street / shophouse", ["street", "shophouse", "ตึกแถว", "soi", "ซอย", "ริมถนน"]], ]; for (const [loc, kws] of map) if (hasAny(a, kws)) return { patch: { locationType: loc }, heard: [["Location type", loc]] }; return { patch: {}, heard: [] }; }, }, { id: "usp", extract: (a) => (a.trim().length > 3 ? { patch: { usp: a.trim() }, heard: [["Your edge", "“" + a.trim() + "”"]] } : { patch: {}, heard: [] }), }, { id: "experience", extract: (a) => { let e = ""; if (hasAny(a, ["opened", "own", "เคยเปิด", "เจ้าของ"])) e = "Opened before"; else if (hasAny(a, ["worked", "chef", "cook", "เคยทำ", "ทำงาน", "barista", "manager"])) e = "Worked in F&B"; else if (hasAny(a, ["no", "first", "never", "ไม่เคย", "ครั้งแรก", "มือใหม่"])) e = "First-timer"; return e ? { patch: { founder: { experience: e } }, heard: [["Experience", e]], mergeFounder: true } : { patch: {}, heard: [] }; }, }, { id: "capital", extract: (a) => { const patch = { founder: {} }; const heard = []; const cap = parseMoneyTh(a); if (cap) { patch.founder.capital = cap; heard.push(["Capital", fmtBaht(cap)]); } if (hasAny(a, ["myself", "run it", "เอง", "ทำเอง", "owner"])) { patch.founder.involvement = "Owner-operator"; heard.push(["Involvement", "Owner-operator"]); } else if (hasAny(a, ["invest", "ลงทุนอย่างเดียว", "partner runs", "hire"])) { patch.founder.involvement = "Investor"; heard.push(["Involvement", "Investor"]); } return { patch: Object.keys(patch.founder).length ? patch : {}, heard, mergeFounder: true }; }, }, ]; function askInterview({ questionId, answer, idea }) { // DEFAULT (v1): rule/heuristic extraction from the answer text. // FUTURE: replace body with a real LLM call — keep this signature. const q = INTERVIEW.find((x) => x.id === questionId); if (!q) return { patch: {}, heard: [] }; return q.extract(answer, idea); } /* ============================================================ COHERENCE + READINESS ============================================================ */ function computeIdea(idea, t, lang) { const mixSum = idea.channelMix.dineIn + idea.channelMix.delivery + idea.channelMix.takeaway; const fields = [ ["conceptName", !!idea.conceptName], ["category", !!idea.category], ["cuisine", !!idea.cuisine], ["targetCustomer", idea.targetCustomer.length > 0], ["priceBand", !!idea.priceBand], ["channelMix", mixSum === 100], ["seats", idea.format.seats > 0], ["serviceStyle", !!idea.format.serviceStyle], ["locationType", !!idea.locationType], ["usp", idea.usp.trim().length > 10], ["experience", !!idea.founder.experience], ["capital", idea.founder.capital > 0], ]; const filled = fields.filter(([, ok]) => ok).length; const completeness = filled / fields.length; const ol = (v) => optLabel(lang, v); const flags = []; const genericUsp = /^(good|great|delicious|อร่อย|tasty|quality)\s*(food|taste)?\.?$/i; const weakUsp = !idea.usp || genericUsp.test(idea.usp.trim()) || idea.usp.trim().length < 12; if (mixSum !== 100) flags.push({ tone: "red", text: t("idea.flagMix", { mixSum }), blocking: true }); if (idea.category === "Fine dining" && idea.channelMix.delivery >= 35) flags.push({ tone: "amber", text: t("idea.flagFineDelivery") }); if ((idea.priceBand === "Premium" || idea.priceBand === "Luxury") && (idea.locationType === "Campus" || idea.locationType === "Community mall")) flags.push({ key: "price", tone: "amber", text: t("idea.flagPricePlace", { band: ol(idea.priceBand), loc: ol(idea.locationType) }) }); if ((idea.priceBand === "Premium" || idea.priceBand === "Luxury") && idea.targetCustomer.includes("Students")) flags.push({ key: "price", tone: "amber", text: t("idea.flagPriceStudents") }); if (idea.competition === "Many" && weakUsp) flags.push({ key: "usp", tone: "amber", text: t("idea.flagCompetitionUsp") }); if (idea.channelMix.delivery >= 40) flags.push({ tone: "amber", text: t("idea.flagDeliveryPct", { pct: idea.channelMix.delivery }) }); const rentBench = salesBenchmark(idea, t); if (rentBench && idea.rentExpectation > rentBench.healthyRent) { const gap = idea.rentExpectation - rentBench.healthyRent; flags.push({ key: "rent", tone: "amber", text: t("idea.flagRent", { rent: fmtBaht(idea.rentExpectation), gap: fmtBaht(gap), est: fmtBaht(rentBench.salesEst), basis: rentBench.basis }) }); } const est = fitoutEstimate(idea, t, lang); if (est && idea.founder.capital > 0 && idea.founder.capital < est.value * 0.7) flags.push({ key: "capital", tone: "red", text: t("idea.flagCapital", { cap: fmtBaht(idea.founder.capital), est: fmtBaht(est.value), basis: est.basis }) }); if (idea.usp && weakUsp) flags.push({ key: "usp", tone: "amber", text: t("idea.flagUsp") }); if (idea.category === "Bar" && (idea.locationType === "CBD office" || idea.locationType === "Campus") && idea.targetCustomer.includes("Office workers")) flags.push({ tone: "amber", text: t("idea.flagBarOffice") }); if (idea.founder.experience === "First-timer" && (idea.category === "Fine dining" || idea.format.seats > 80)) flags.push({ tone: "amber", text: t("idea.flagFirstTimerScale") }); const amber = flags.filter((f) => f.tone === "amber").length; const red = flags.filter((f) => f.tone === "red").length; const coherence = Math.max(0, 40 - amber * 8 - red * 20); const readiness = Math.round(completeness * 60 + coherence); const blocking = flags.some((f) => f.blocking); let verdict; if (blocking || red > 0) verdict = { label: t("idea.verdictContradictions"), tone: "red" }; else if (readiness >= 75) verdict = { label: t("idea.verdictReady"), tone: "green" }; else verdict = { label: t("idea.verdictSharpen"), tone: "amber" }; /* the single riskiest assumption to test first: rent breach > capital gap > price mismatch > weak USP */ const RISK_ORDER = [ ["rent", t("idea.riskRent")], ["capital", t("idea.riskCapital")], ["price", t("idea.riskPrice")], ["usp", t("idea.riskUsp")], ]; let riskiest = null; for (const [key, text] of RISK_ORDER) { if (flags.some((f) => f.key === key)) { riskiest = { key, text }; break; } } return { readiness, completeness, filled, total: fields.length, flags, verdict, mixSum, fitout: est, rentBench, riskiest }; } Object.assign(window, { CATEGORIES, CUISINE_PRESETS, CUSTOMERS, LOCATION_TYPES, SERVICE_STYLES, EXPERIENCE, RISK, INVOLVEMENT, PRICE_BANDS, priceRange, INTERVIEW, askInterview, computeIdea, fitoutEstimate, salesBenchmark, });