// ══════════════════════════════════════════════════════════════════════════════ // GLMario — Mario AI chat (PWA port of gustai-mario-bot WP plugin v2.3.2) // Full-screen tab (replaces Profile slot in bottom nav). // No backend yet → stub AI replies routed by intent (wholesale, vegan, // shipping, human_request, general). Real LLM goes behind `gustaiAskMario()` // — swap that one function out for a fetch() to your /chat endpoint. // ══════════════════════════════════════════════════════════════════════════════ // Default config — overridden at runtime by admin-saved values from GLSettings const MARIO_DEFAULTS = { botName: 'Mario', greeting: "Ciao! I'm Mario 🍝 Ask me anything about Gustai Lab — pasta, sauces, subscriptions, delivery, you name it.", afterHoursMsg: "Our team is back at 8am Bangkok time — but I'm here 24/7. Ask me anything!", waNumber: '66837108887', hoursStart: '08:00', hoursEnd: '17:00', consentText: 'By chatting, you agree that we may store this conversation to improve service. We never share your data.', privacyUrl: '#privacy', language: 'auto', soundEnabled: true, quickReplies: [ 'How does the subscription work?', 'Tell me about your pasta', 'Vegan / dairy-free options?', 'Wholesale for restaurants', 'Delivery in Phuket?', 'Talk to a human', ], }; // Admin-saved values arrive via GLSettings.marioConfigSync() (mario_config key). // If GLSettings isn't loaded yet, we fall through to defaults. function getMarioConfig() { const admin = (window.GLSettings && typeof window.GLSettings.marioConfigSync === 'function') ? window.GLSettings.marioConfigSync() : null; if (!admin) return MARIO_DEFAULTS; return { botName: admin.bot_name || MARIO_DEFAULTS.botName, greeting: admin.greeting || MARIO_DEFAULTS.greeting, afterHoursMsg: admin.after_hours_msg || MARIO_DEFAULTS.afterHoursMsg, waNumber: admin.whatsapp_number || MARIO_DEFAULTS.waNumber, hoursStart: admin.hours_start || MARIO_DEFAULTS.hoursStart, hoursEnd: admin.hours_end || MARIO_DEFAULTS.hoursEnd, consentText: admin.consent_text || MARIO_DEFAULTS.consentText, privacyUrl: admin.privacy_url || MARIO_DEFAULTS.privacyUrl, language: admin.ui_language || MARIO_DEFAULTS.language, soundEnabled: admin.sound_enabled !== false, quickReplies: Array.isArray(admin.quick_replies) && admin.quick_replies.length ? admin.quick_replies : MARIO_DEFAULTS.quickReplies, }; } // Live config — read once per render (cheap synchronous localStorage read). // Re-evaluated each time GLMario mounts so admin changes apply on next visit. const MARIO_CONFIG = new Proxy({}, { get(_, prop) { return getMarioConfig()[prop]; }, }); // ─── BUSINESS HOURS (Bangkok UTC+7) ───────────────────────────────────────── function marioIsBusinessHours() { const now = new Date(); const bkMs = now.getTime() + (now.getTimezoneOffset() + 420) * 60000; const bk = new Date(bkMs); const cur = bk.getHours() * 60 + bk.getMinutes(); const parts = (s) => { const p = s.split(':'); return parseInt(p[0],10) * 60 + parseInt(p[1] || 0, 10); }; return cur >= parts(MARIO_CONFIG.hoursStart) && cur < parts(MARIO_CONFIG.hoursEnd); } // ─── INTENT DETECTION (lifted from WP plugin) ─────────────────────────────── function marioDetectIntent(text) { const t = (text || '').toLowerCase(); if (/wholesale|bulk|b2b|restaurant|hotel|chef|distributor|supplier|large.?order|minimum.?order/i.test(t)) return 'wholesale'; if (/vegan|plant.?based|dairy.?free|egg.?free|gluten.?free/i.test(t)) return 'dietary'; if (/human|person|staff|team|real|agent|speak.?to|talk.?to/i.test(t)) return 'human_request'; if (/ship|deliver|postage|courier|freight|arrival|dispatch|when.*arrive/i.test(t)) return 'shipping'; if (/subscribe|subscription|recurring|standing.?order|prepay|how.?does.*work/i.test(t)) return 'subscription'; if (/menu|pasta|sauce|product|item|set|fresh/i.test(t)) return 'menu'; if (/price|cost|how.?much|baht|thb|฿/i.test(t)) return 'pricing'; return 'general'; } // ─── REAL AI REPLY (calls Supabase edge function `mario-chat`, stub fallback) ─ // When window.GLMarioAPI is configured (db client wired up), this hits Gemini. // Otherwise falls through to the canned per-intent answers below — same UX, // no backend required for design / demo. async function gustaiAskMario(userText, history, opts) { opts = opts || {}; const intent = marioDetectIntent(userText); // Try the real backend first if (window.GLMarioAPI && window.GLMarioAPI.isConfigured()) { const messages = (history || []).map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.text || m.content || '', })); const res = await window.GLMarioAPI.chat({ messages, userName: opts.userName, sessionId: opts.sessionId, language: opts.language, }); if (res.ok && res.text) { return { text: res.text, followup: intent === 'wholesale' ? 'wholesale_lead' : intent === 'human_request' ? 'whatsapp_handoff' : undefined, }; } // backend down or not configured → fall through to canned reply console.warn('[Mario] backend unavailable, using stub:', res.error); } // Simulate latency 600–1400ms (only used by stub fallback) await new Promise(r => setTimeout(r, 600 + Math.random() * 800)); switch (intent) { case 'wholesale': return { text: "Our **wholesale program** handles hotels, restaurants and chef-led venues across Phuket and Phang Nga.\n\n• Minimum order: 20 boxes / week\n• 15–25% off retail (volume tiered)\n• Custom pasta shapes & sauce pairings on request\n• Dedicated chef contact + monthly tasting drops\n\nWant me to set up a sample box and a call with our wholesale lead?", followup: 'wholesale_lead', }; case 'dietary': return { text: "Here's the dietary breakdown for our launch sets:\n\n• **Vegetarian**: Truffle Mafaldine, Burrata & Pomodoro, Pesto, Cacio e Pepe\n• **Contains dairy**: Truffle, Burrata, Pesto, Cacio e Pepe\n• **Contains nuts**: Pesto (pine nuts)\n• **Contains shellfish**: Squid Ink Tagliolini\n\nWe can make the **Pesto sauce dairy-free on request** (replace Parmigiano with nutritional yeast). Vegan pasta is on the roadmap — want me to add you to that waitlist?", }; case 'shipping': return { text: "We deliver **fresh across Phuket** — pasta and sauce arrive cold-packed and ready to cook.\n\n• Free delivery over ฿1000\n• ฿60 flat below that\n• Daily cutoff: **11am** for same-day evening drop (4–7pm)\n• Subscription drops land on your chosen weekday between 4–7pm\n\nWhich neighbourhood are you in? I can confirm cut-off times for your area.", }; case 'subscription': return { text: "Here's how the **Gustai subscription** works:\n\n1. Pick the pasta + sauce sets you want **in every box** (your standing order)\n2. Choose your **schedule** — weekly, every 2 weeks, or monthly\n3. Choose how many deliveries to **prepay** (we cap at ~6 months so you come back to taste new drops)\n4. Pay once via **PromptPay QR**, we deliver every cycle\n\nNo card on file, no auto-renew surprise — when your prepay runs out you decide if you want another run.\n\nWant me to walk you through building one?", }; case 'pricing': return { text: "Our pasta + sauce sets are **฿280–฿400 per box** depending on the set.\n\nEach box serves 2 generously and includes:\n• ~200g fresh-extruded pasta\n• Signature sauce (made same day)\n• Aged Parmigiano Reggiano on the side\n\nSubscribe and you save up to **12%** on the per-box price. Want to see the full menu?", }; case 'menu': return { text: "Our launch menu has **6 pasta + sauce sets**, all bronze-extruded fresh that morning:\n\n• **Truffle Mafaldine** — creamy black truffle (฿350)\n• **Squid Ink Tagliolini** — spicy seafood (฿320)\n• **Classic Rigatoni** — wagyu bolognese (฿280)\n• **Burrata & Pomodoro** — tomato basil (฿400)\n• **Pesto alla Genovese** — pine nut & basil (฿300)\n• **Cacio e Pepe** — pecorino & black pepper (฿290)\n\nWhich one would you try first?", }; case 'human_request': return { text: marioIsBusinessHours() ? "Of course — our team is online right now. Tap below to chat on WhatsApp:" : MARIO_CONFIG.afterHoursMsg + "\n\nOr reach the team on WhatsApp:", followup: 'whatsapp_handoff', }; case 'general': default: return { text: "I can help with the menu, subscriptions, delivery in Phuket, dietary info, wholesale, or anything else about Gustai Lab. What do you want to know?", }; } } // ─── MARKDOWN-LITE RENDERING ──────────────────────────────────────────────── function marioFormat(text) { // **bold** → // bullet lines (• or *) →
  • // double newline → paragraph break if (!text) return ''; const escape = (s) => s.replace(/&/g,'&').replace(//g,'>'); const lines = text.split('\n'); const out = []; let inUl = false; lines.forEach(line => { const trimmed = line.trim(); const bullet = /^[•*-]\s+(.+)$/.exec(trimmed); if (bullet) { if (!inUl) { out.push(''); inUl = false; } if (trimmed) out.push('

    ' + escape(line).replace(/\*\*(.*?)\*\*/g, '$1') + '

    '); } }); if (inUl) out.push(''); return out.join(''); } // ─── PERSISTENCE ──────────────────────────────────────────────────────────── const MARIO_KEY = 'gl_mario_session_v1'; function loadMarioSession() { try { return JSON.parse(localStorage.getItem(MARIO_KEY) || '{}'); } catch (e) { return {}; } } function saveMarioSession(s) { try { localStorage.setItem(MARIO_KEY, JSON.stringify(s)); } catch (e) {} } // ─── MAIN COMPONENT ───────────────────────────────────────────────────────── function GLMario({ app, go }) { const persisted = React.useRef(loadMarioSession()).current; const [messages, setMessages] = React.useState(persisted.messages || []); const [consented, setConsented] = React.useState(!!persisted.consented); const [consentChecked, setConsentChecked] = React.useState(false); const [leadCaptured, setLeadCaptured] = React.useState(!!persisted.leadCaptured); const [leadFormOpen, setLeadFormOpen] = React.useState(false); const [leadName, setLeadName] = React.useState(persisted.userName || (app && app.userName) || ''); const [leadContact, setLeadContact] = React.useState((app && app.phone) || ''); const [draft, setDraft] = React.useState(''); const [showQR, setShowQR] = React.useState(true); const [typing, setTyping] = React.useState(false); const [showWA, setShowWA] = React.useState(false); const scrollRef = React.useRef(null); // Stable session id for analytics + rate-limiting (one per device, persists) const sessionId = React.useRef( persisted.sessionId || ('s_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)) ).current; const sessionStart = React.useRef(persisted.sessionStart || Date.now()).current; const intentsSeen = React.useRef(new Set(persisted.intentsSeen || [])).current; // Persist on every relevant change React.useEffect(() => { saveMarioSession({ messages, consented, leadCaptured, userName: leadName, sessionId, sessionStart, intentsSeen: Array.from(intentsSeen), }); }, [messages, consented, leadCaptured, leadName]); // Push session-end analytics on tab close / unmount React.useEffect(() => { const report = () => { if (!window.GLMarioAPI || !consented) return; const userMsgs = messages.filter(m => m.role === 'user').length; window.GLMarioAPI.report({ session_id: sessionId, message_count: messages.length, user_messages: userMsgs, had_lead: leadCaptured, had_handoff: showWA, duration_sec: Math.round((Date.now() - sessionStart) / 1000), intents_seen: Array.from(intentsSeen), }); }; window.addEventListener('beforeunload', report); return () => { window.removeEventListener('beforeunload', report); report(); }; }, [messages, leadCaptured, showWA, consented]); // Auto-scroll on new messages or typing indicator React.useEffect(() => { if (!scrollRef.current) return; requestAnimationFrame(() => { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }); }, [messages, typing]); // Greeting on first consent React.useEffect(() => { if (consented && messages.length === 0) { const greetings = [{ role: 'assistant', text: MARIO_CONFIG.greeting, ts: Date.now() }]; if (!marioIsBusinessHours()) greetings.push({ role: 'assistant', text: MARIO_CONFIG.afterHoursMsg, ts: Date.now() + 1 }); setMessages(greetings); } }, [consented]); const handleConsent = () => setConsented(true); const sendMessage = async (text) => { text = (text || '').trim(); if (!text) return; const userMsg = { role: 'user', text, ts: Date.now() }; const next = [...messages, userMsg]; setMessages(next); setDraft(''); setShowQR(false); setShowWA(false); setTyping(true); const intent = marioDetectIntent(text); intentsSeen.add(intent); if (intent === 'human_request') { // Skip API — direct WA handoff setTimeout(() => { setTyping(false); const reply = marioIsBusinessHours() ? "Of course — our team is online right now. Tap below to chat on WhatsApp:" : MARIO_CONFIG.afterHoursMsg + "\n\nOr reach the team on WhatsApp:"; setMessages(m => [...m, { role: 'assistant', text: reply, ts: Date.now() }]); setShowWA(true); if (!leadCaptured) setLeadFormOpen(true); }, 700); return; } try { const res = await gustaiAskMario(text, next, { userName: leadName || (app && app.userName) || undefined, sessionId, language: MARIO_CONFIG.language === 'auto' ? undefined : MARIO_CONFIG.language, }); setTyping(false); setMessages(m => [...m, { role: 'assistant', text: res.text, ts: Date.now() }]); if (res.followup === 'whatsapp_handoff') setShowWA(true); if (res.followup === 'wholesale_lead' && !leadCaptured) { setTimeout(() => setLeadFormOpen(true), 700); } } catch (e) { setTyping(false); setMessages(m => [...m, { role: 'assistant', text: "I'm having connectivity issues. Please try again or tap the WhatsApp button.", ts: Date.now() }]); setShowWA(true); } }; const submitLead = async () => { const name = leadName.trim() || 'Anonymous'; const contact = leadContact.trim(); if (!contact) return; setLeadCaptured(true); setLeadFormOpen(false); // Pick the most relevant intent we've seen this session for routing const inquiryType = intentsSeen.has('wholesale') ? 'wholesale' : intentsSeen.has('subscription') ? 'subscription' : intentsSeen.has('dietary') ? 'dietary' : intentsSeen.has('shipping') ? 'shipping' : 'general'; const transcript = messages.map(m => ({ role: m.role, content: m.text || '', ts: m.ts, })); if (window.GLMarioAPI) { try { await window.GLMarioAPI.captureLead({ sessionId, name, contact, inquiryType, transcript, }); } catch (e) { console.warn('[Mario] lead capture failed:', e); } } const first = name.split(' ')[0]; setMessages(m => [...m, { role: 'assistant', text: `Got it, ${first}! Our team will be in touch shortly. Is there anything else I can help with?`, ts: Date.now() }]); }; const resetChat = () => { setMessages([]); setShowQR(true); setShowWA(false); setLeadFormOpen(false); setLeadCaptured(false); saveMarioSession({}); // Greeting will repopulate via useEffect }; const openWhatsApp = () => { const txt = encodeURIComponent(`Hi! I was just chatting with Mario on the Gustai app${leadName ? ` — this is ${leadName}` : ''}.`); window.open(`https://wa.me/${MARIO_CONFIG.waNumber}?text=${txt}`, '_blank'); }; // ─── RENDER ────────────────────────────────────────────────────────────── return (
    {/* Header */}
    🍝
    MARIO
    {marioIsBusinessHours() ? 'Online — replies instantly' : 'AI 24/7 · Team from 8am BKK'}
    {messages.length > 0 && consented && ( )}
    {/* Body */}
    {!consented ? ( /* Consent gate */
    🍝
    Chat with Mario

    {MARIO_CONFIG.consentText}

    ) : ( <> {messages.map((m, i) => ( ))} {typing && } {showWA && (
    )} )}
    {/* Quick replies (collapsible chip row) */} {consented && showQR && !leadFormOpen && messages.length <= 2 && (
    {MARIO_CONFIG.quickReplies.map((q, i) => ( ))}
    )} {/* Lead form */} {consented && leadFormOpen && (

    Before connecting, can we get your name & contact? 🙏

    setLeadName(e.target.value)} style={{ width: '100%', padding: '10px 12px', borderRadius: 8, border: `1px solid ${GL.line}`, fontSize: 14, marginBottom: 8, boxSizing: 'border-box' }}/> setLeadContact(e.target.value)} style={{ width: '100%', padding: '10px 12px', borderRadius: 8, border: `1px solid ${GL.line}`, fontSize: 14, marginBottom: 10, boxSizing: 'border-box' }}/>
    )} {/* Input area */} {consented && !leadFormOpen && (