// Eco Computer AI Chatbot // Reads docs/manifest.json, parses all listed files, answers questions via Claude AI (function() { // ─── FILE PARSER ───────────────────────────────────────────────────────────── async function parseFileContent(filePath) { const ext = filePath.split('.').pop().toLowerCase(); // Plain text / Markdown / CSV if (['txt', 'md', 'csv'].includes(ext)) { const res = await fetch(filePath); if (!res.ok) throw new Error('Cannot fetch ' + filePath); return await res.text(); } // PDF via PDF.js if (ext === 'pdf') { const res = await fetch(filePath); if (!res.ok) throw new Error('Cannot fetch ' + filePath); const arrayBuffer = await res.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(arrayBuffer) }).promise; let text = ''; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const content = await page.getTextContent(); text += content.items.map(item => item.str).join(' ') + '\n'; } return text.trim(); } // Word .docx via Mammoth if (ext === 'docx') { const res = await fetch(filePath); if (!res.ok) throw new Error('Cannot fetch ' + filePath); const arrayBuffer = await res.arrayBuffer(); const result = await mammoth.extractRawText({ arrayBuffer }); return result.value.trim(); } // Excel .xlsx / .xls via SheetJS if (['xlsx', 'xls'].includes(ext)) { const res = await fetch(filePath); if (!res.ok) throw new Error('Cannot fetch ' + filePath); const arrayBuffer = await res.arrayBuffer(); const workbook = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' }); let text = ''; workbook.SheetNames.forEach(name => { text += 'Sheet: ' + name + '\n'; text += XLSX.utils.sheet_to_csv(workbook.Sheets[name]) + '\n\n'; }); return text.trim(); } return '[Unsupported file type: ' + ext + ']'; } // ─── ICONS ─────────────────────────────────────────────────────────────────── const BotIcon = () => ( ); const SendIcon = () => ( ); const CloseIcon = () => ( ); const FileIcon = () => ( ); const SpinnerIcon = () => ( ); // ─── DOT TYPING ANIMATION ──────────────────────────────────────────────────── function TypingDots() { return (
); } // ─── SUGGESTED QUESTIONS ───────────────────────────────────────────────────── const SUGGESTIONS = [ "📦 Track my order", "✏️ Amend my order", "🛒 My cart", "What laptops are available?", "What is the warranty policy?", ]; const WHATSAPP_PHONE = "60123286692"; // mirrors BUSINESS_WHATSAPP in config.php // Keywords that hint the user wants to track an order const TRACKING_RX = /\b(my order|track\b|order status|where(?:'s| is) my (?:order|parcel|delivery)|order number|order id|update on (?:my )?order)/i; const ORDERNO_RX = /\bEC-?\d{6,8}-?[A-Z0-9]+\b/i; // Keywords that hint the user wants to AMEND or CANCEL an order const AMEND_RX = /\b(amend|modify|edit) (?:my |the )?order|\bcancel (?:my |the |this )?order|\b(?:add|remove) (?:an? )?(?:item|product|more) (?:to|from) (?:my |the )?order|\bchange (?:the )?(?:qty|quantity)\b|\bcancellation\b/i; // Keywords that hint the user wants to manage their SHOPPING CART (not an order) const CART_RX = /\b(?:my|the|shopping|view|show|open|empty|clear)\s+cart\b|\b(?:add|put)\b.{0,25}\bto\s+(?:my\s+|the\s+)?cart\b|\bremove\b.{0,25}\bfrom\s+(?:my\s+|the\s+)?cart\b|\bcheck(?:\s*)out\b|\bmy basket\b|\bwhat'?s in my cart\b/i; // ─── CHATBOT COMPONENT ──────────────────────────────────────────────────────── function EcoChatbot() { const [open, setOpen] = React.useState(false); const [messages, setMessages] = React.useState([]); const [input, setInput] = React.useState(''); const [thinking, setThinking] = React.useState(false); const [docContext, setDocContext] = React.useState(null); const [loadingDocs, setLoadingDocs] = React.useState(false); const [loadError, setLoadError] = React.useState(null); const [docNames, setDocNames] = React.useState([]); const [unread, setUnread] = React.useState(false); const bottomRef = React.useRef(null); const inputRef = React.useRef(null); const historyRef = React.useRef([]); // stores {role, content} pairs for Claude // ─── Order tracking state machine ───────────────────────────────────────── // trackingStep: null | 'awaiting_order' | 'awaiting_phone' const [trackingStep, setTrackingStep] = React.useState(null); const trackingOrderRef = React.useRef(null); // ─── Order amendment state machine ──────────────────────────────────────── // amendStep: null | 'await_order' | 'await_phone' | 'choose_action' // | 'add_choose_product' | 'add_choose_qty' | 'add_confirm' // | 'upd_choose_item' | 'upd_choose_qty' | 'upd_confirm' // | 'rm_choose_item' | 'rm_confirm' // | 'cancel_reason' | 'cancel_confirm' const [amendStep, setAmendStep] = React.useState(null); const amendOrderRef = React.useRef(null); // order_number const amendPhoneRef = React.useRef(null); // phone digits const amendOrderDataRef = React.useRef(null); // last snapshot from lookup/amend const amendActionRef = React.useRef(null); // 'add_item' | 'update_quantity' | 'remove_item' | 'cancel' const amendPayloadRef = React.useRef({}); // working draft for the next call const productsListRef = React.useRef(null); // cached active product list // ─── Shopping cart state machine ────────────────────────────────────────── // Drives window.ecoCart (the live on-page cart). Steps: // menu | add_search | add_pick | add_qty | add_confirm // qty_pick | qty_value | qty_confirm | rm_pick | rm_confirm // clear_confirm | checkout_confirm const [cartStep, setCartStep] = React.useState(null); const cartPickRef = React.useRef([]); // numbered list currently on screen const cartDraftRef = React.useRef({}); // working draft for the pending change // ─── Resizable panel size (persisted to localStorage) ───────────────────── const DEFAULT_SIZE = { w: 260, h: 365 }; const MIN_SIZE = { w: 220, h: 300 }; const [panelSize, setPanelSize] = React.useState(() => { try { const saved = JSON.parse(localStorage.getItem('eco-chat-size') || 'null'); if (saved && saved.w >= MIN_SIZE.w && saved.h >= MIN_SIZE.h) return saved; } catch (e) {} return DEFAULT_SIZE; }); const resizingRef = React.useRef(null); function startResize(e) { e.preventDefault(); e.stopPropagation(); resizingRef.current = { startX: e.clientX, startY: e.clientY, startW: panelSize.w, startH: panelSize.h, }; const maxW = Math.min(window.innerWidth - 60, 720); const maxH = Math.min(window.innerHeight - 110, 900); function onMove(ev) { const s = resizingRef.current; if (!s) return; // Anchor is bottom-right: dragging the TOP-LEFT corner OUTWARD = grow. const dx = s.startX - ev.clientX; const dy = s.startY - ev.clientY; const w = Math.max(MIN_SIZE.w, Math.min(maxW, s.startW + dx)); const h = Math.max(MIN_SIZE.h, Math.min(maxH, s.startH + dy)); setPanelSize({ w, h }); } function onUp() { resizingRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); try { localStorage.setItem('eco-chat-size', JSON.stringify({ w: panelSize.w, h: panelSize.h })); } catch (e) {} } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); } // Save size to localStorage when it stops changing React.useEffect(() => { const t = setTimeout(() => { try { localStorage.setItem('eco-chat-size', JSON.stringify(panelSize)); } catch (e) {} }, 300); return () => clearTimeout(t); }, [panelSize]); function startTracking(preOrderNo) { if (preOrderNo) { trackingOrderRef.current = preOrderNo.toUpperCase().replace(/\s+/g, ''); addBotMessage( "Got it — order **" + trackingOrderRef.current + "** 📦\n\n" + "To protect your privacy, please share the **phone number** you used when placing this order." ); setTrackingStep('awaiting_phone'); } else { addBotMessage( "Sure — I can help you track your order! 📦\n\n" + "What's your **order number**? (looks like *EC-20260516-XXXX*)" ); setTrackingStep('awaiting_order'); } } function whatsappFallback(reason, orderNo) { const msg = encodeURIComponent( "Hi Eco Computer! I need help with my order" + (orderNo ? " " + orderNo : "") + ". " + (reason || "") ); return "https://wa.me/" + WHATSAPP_PHONE + "?text=" + msg; } function formatStatus(status) { const map = { pending: "⏳ **Pending** — we've received your order and are awaiting payment.", confirmed: "✅ **Confirmed** — your order has been confirmed.", paid: "💳 **Paid** — payment received, preparing your order.", shipped: "🚚 **Shipped** — your order is on the way!", delivered: "📬 **Delivered** — your order has been delivered.", cancelled: "❌ **Cancelled** — this order has been cancelled.", }; return map[status] || ("**" + status + "**"); } function formatOrderResult(d) { const lines = []; lines.push("Here are your order details 👇"); lines.push(""); lines.push("**Order:** " + d.order_number); lines.push("**Status:** " + formatStatus(d.status)); lines.push("**Ordered:** " + new Date(d.created_at.replace(' ', 'T')).toLocaleString('en-MY', { dateStyle: 'medium', timeStyle: 'short' })); lines.push("**Method:** " + (d.delivery_method === 'delivery' ? '🚚 Delivery' : '🏪 Self pickup')); if (d.delivery_method === 'delivery' && d.delivery_address) { lines.push("**Address:** " + d.delivery_address); } lines.push(""); lines.push("**Items:**"); d.items.forEach(it => { lines.push("- " + it.qty + " × " + it.name + " — RM " + it.line.toLocaleString()); }); lines.push(""); lines.push("**Total:** RM " + d.total.toLocaleString()); lines.push(""); lines.push("Need anything else? Just ask, or [chat with us on WhatsApp](" + whatsappFallback("Following up on order " + d.order_number + ".", d.order_number) + ")."); return lines.join('\n'); } async function handleTrackingInput(text) { // Step 1: collect order number if (trackingStep === 'awaiting_order') { const m = text.match(ORDERNO_RX); const orderNo = (m ? m[0] : text.trim()).toUpperCase().replace(/\s+/g, ''); if (orderNo.length < 6) { addBotMessage("Hmm, that doesn't look like a valid order number. It should look like **EC-20260516-XXXX**. Mind sharing it again?"); return; } trackingOrderRef.current = orderNo; addBotMessage("Thanks! Now please share the **phone number** you used when placing this order (for verification)."); setTrackingStep('awaiting_phone'); return; } // Step 2: collect phone + look up if (trackingStep === 'awaiting_phone') { const phone = text.replace(/\D/g, ''); if (phone.length < 7) { addBotMessage("That doesn't look like a complete phone number. Please share the **full phone number** you used on the order."); return; } const orderNo = trackingOrderRef.current; setThinking(true); try { const res = await fetch('order_lookup.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_number: orderNo, phone }), }); const data = await res.json(); if (data.found) { addBotMessage(formatOrderResult(data)); } else if (data.rate_limited) { addBotMessage("Too many lookup attempts — please wait a minute. In the meantime, you can [chat with us on WhatsApp](" + whatsappFallback("I tried to track my order but hit a rate limit.", orderNo) + ")."); } else { addBotMessage( "I couldn't find an order matching **" + orderNo + "** and that phone number 🤔\n\n" + "Things to double-check:\n" + "- Order number is exactly as in your confirmation message\n" + "- Phone number matches the one used when ordering\n\n" + "Or [chat with us on WhatsApp](" + whatsappFallback("I'm trying to track order " + orderNo + " but the lookup couldn't find it.", orderNo) + ") and we'll help right away." ); } } catch (e) { addBotMessage("Sorry, I couldn't connect to the order system. Please [chat with us on WhatsApp](" + whatsappFallback("I tried tracking my order but the chatbot couldn't reach the server.", trackingOrderRef.current) + ") and we'll help."); } finally { setThinking(false); setTrackingStep(null); trackingOrderRef.current = null; } return; } } function cancelTracking() { setTrackingStep(null); trackingOrderRef.current = null; addBotMessage("No problem — let me know if you need anything else!"); } // ────────────────────────────────────────────────────────────────────────── // ORDER AMENDMENT FLOW // ────────────────────────────────────────────────────────────────────────── async function ensureProducts() { if (productsListRef.current && productsListRef.current.length) return productsListRef.current; try { const res = await fetch('products_list.php'); const data = await res.json(); productsListRef.current = data.products || []; return productsListRef.current; } catch (e) { productsListRef.current = []; return []; } } function resetAmendState() { setAmendStep(null); amendOrderRef.current = null; amendPhoneRef.current = null; amendOrderDataRef.current = null; amendActionRef.current = null; amendPayloadRef.current = {}; } function cancelAmendment(reasonMsg) { resetAmendState(); addBotMessage(reasonMsg || "No problem — let me know if you need anything else!"); } function startAmendment(preOrderNo) { amendOrderRef.current = null; amendPhoneRef.current = null; amendOrderDataRef.current = null; amendActionRef.current = null; amendPayloadRef.current = {}; if (preOrderNo) { amendOrderRef.current = preOrderNo.toUpperCase().replace(/\s+/g, ''); addBotMessage( "Sure — let's amend order **" + amendOrderRef.current + "** ✏️\n\n" + "To protect your privacy, please share the **phone number** you used when placing this order." ); setAmendStep('await_phone'); } else { addBotMessage( "Sure — I can help you amend or cancel an order ✏️\n\n" + "What's your **order number**? (looks like *EC-20260516-XXXX*)" ); setAmendStep('await_order'); } } function showActionMenu(order) { const activeItems = (order.items || []).filter(it => it.active !== false); const lines = []; lines.push("Verified ✅ Here's your current order:"); lines.push(""); lines.push("**Order:** " + order.order_number); lines.push("**Status:** " + formatStatus(order.status)); if (activeItems.length === 0) { lines.push(""); lines.push("_(No active items in this order.)_"); } else { lines.push(""); lines.push("**Current items:**"); activeItems.forEach((it, i) => { lines.push((i + 1) + ". " + it.qty + " × " + it.name + " — RM " + it.line.toLocaleString()); }); } lines.push(""); lines.push("**Total:** RM " + order.total.toLocaleString()); lines.push(""); lines.push("What would you like to do? Type the number:"); lines.push("**1.** ➕ Add an item"); lines.push("**2.** ✏️ Change a quantity"); lines.push("**3.** ❌ Remove an item"); lines.push("**4.** 🛑 Cancel the entire order"); lines.push(""); lines.push("Or type **done** to finish."); addBotMessage(lines.join('\n')); setAmendStep('choose_action'); } async function amendLookupAndStart() { const orderNo = amendOrderRef.current; const phone = amendPhoneRef.current; const AMENDABLE = ['pending', 'confirmed']; setThinking(true); try { const res = await fetch('order_lookup.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_number: orderNo, phone }), }); const data = await res.json(); if (data.found) { if (!AMENDABLE.includes(data.status)) { cancelAmendment( "Your order **" + orderNo + "** is already *" + data.status + "* — it can't be amended here anymore.\n\n" + "Please [message us on WhatsApp](" + whatsappFallback("I need to amend order " + orderNo + " (status: " + data.status + ").", orderNo) + ") and we'll help you directly." ); return; } amendOrderDataRef.current = data; showActionMenu(data); } else if (data.rate_limited) { cancelAmendment( "Too many lookup attempts — please wait a minute. Or [message us on WhatsApp](" + whatsappFallback("I tried to amend my order but hit a rate limit.", orderNo) + ")." ); } else { cancelAmendment( "I couldn't find an order matching **" + orderNo + "** and that phone number 🤔\n\n" + "Things to double-check:\n" + "- Order number is exactly as in your confirmation message\n" + "- Phone number matches the one used when ordering\n\n" + "Or [message us on WhatsApp](" + whatsappFallback("I'm trying to amend order " + orderNo + " but lookup didn't find it.", orderNo) + ")." ); } } catch (e) { cancelAmendment( "Sorry, I couldn't reach the order system. Please [WhatsApp us](" + whatsappFallback("Server issue while trying to amend.", amendOrderRef.current) + ")." ); } finally { setThinking(false); } } async function callAmend(payload) { setThinking(true); try { const res = await fetch('order_amend.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_number: amendOrderRef.current, phone: amendPhoneRef.current, ...payload, }), }); const data = await res.json(); if (data.success) { amendOrderDataRef.current = data.order; if (data.action === 'cancel') { cancelAmendment( "✅ " + data.message + "\n\n" + "All items have been returned to stock. The order record is kept for your reference.\n\n" + "If you change your mind or need anything else, just ask — or [WhatsApp us](" + whatsappFallback("Cancelled order " + amendOrderRef.current + " via chatbot.", amendOrderRef.current) + ")." ); return; } const lines = []; lines.push("✅ " + data.message); lines.push(""); lines.push("**Updated total:** RM " + data.order.total.toLocaleString()); lines.push(""); lines.push("Anything else? Type the number:"); lines.push("**1.** ➕ Add an item"); lines.push("**2.** ✏️ Change a quantity"); lines.push("**3.** ❌ Remove an item"); lines.push("**4.** 🛑 Cancel the entire order"); lines.push(""); lines.push("Or type **done** to finish."); addBotMessage(lines.join('\n')); setAmendStep('choose_action'); } else if (data.rate_limited) { addBotMessage( "Too many amendment attempts — please wait a minute. Or [WhatsApp us](" + whatsappFallback("Hit rate limit while amending " + amendOrderRef.current + ".", amendOrderRef.current) + ")." ); } else if (data.locked) { cancelAmendment( "This order is now *" + (data.status || 'locked') + "* and can't be changed here. Please [WhatsApp us](" + whatsappFallback("Need help with order " + amendOrderRef.current + " — chatbot says it's locked.", amendOrderRef.current) + ")." ); } else { addBotMessage( "⚠️ " + (data.message || "Couldn't apply that change.") + "\n\n" + "Type a number to try again, or **done** to finish." ); setAmendStep('choose_action'); } } catch (e) { addBotMessage( "Sorry, the server didn't respond. Please [WhatsApp us](" + whatsappFallback("Server error while amending " + amendOrderRef.current + ".", amendOrderRef.current) + ")." ); } finally { setThinking(false); } } async function handleAmendInput(text) { const t = text.trim(); const tl = t.toLowerCase(); // Universal escape if (/^(done|finish|exit|quit|nevermind|never mind)\b/i.test(t)) { cancelAmendment("Done! Let me know if you need anything else."); return; } switch (amendStep) { case 'await_order': { const m = t.match(ORDERNO_RX); const orderNo = (m ? m[0] : t).toUpperCase().replace(/\s+/g, ''); if (orderNo.length < 6) { addBotMessage("Hmm, that doesn't look like a valid order number. Format: **EC-20260516-XXXX**. Mind sharing it again?"); return; } amendOrderRef.current = orderNo; addBotMessage("Thanks! Now please share the **phone number** you used when placing this order (for verification)."); setAmendStep('await_phone'); return; } case 'await_phone': { const phone = t.replace(/\D/g, ''); if (phone.length < 7) { addBotMessage("That doesn't look like a complete phone number. Please share the full phone number used on the order."); return; } amendPhoneRef.current = phone; await amendLookupAndStart(); return; } case 'choose_action': { if (tl === '1' || /^add\b/i.test(t)) { amendActionRef.current = 'add_item'; const products = await ensureProducts(); if (!products.length) { cancelAmendment( "Couldn't load the product list. Please [WhatsApp us](" + whatsappFallback("Need help adding an item to order " + amendOrderRef.current + ".", amendOrderRef.current) + ")." ); return; } const lines = ["Which product would you like to add? Type the **SKU** (e.g., *P-7*) or the **number** from the list:", ""]; products.forEach((p, i) => { const stockNote = p.stock > 0 ? "_(" + p.stock + " in stock)_" : "_(out of stock)_"; lines.push("**" + (i + 1) + ".** " + p.sku + " · " + p.name + " — RM " + Number(p.price).toLocaleString() + " " + stockNote); }); lines.push(""); lines.push("Or type **back** to return to the menu."); addBotMessage(lines.join('\n')); setAmendStep('add_choose_product'); return; } if (tl === '2' || /^change|^update|^edit/i.test(t)) { const items = (amendOrderDataRef.current.items || []).filter(it => it.active !== false); if (!items.length) { addBotMessage("Your order has no items to change. Type **1** to add one, or **done** to finish."); return; } amendActionRef.current = 'update_quantity'; const lines = ["Which item would you like to change? Type the **number**:", ""]; items.forEach((it, i) => { lines.push("**" + (i + 1) + ".** " + it.qty + " × " + it.name + " — RM " + it.line.toLocaleString()); }); lines.push(""); lines.push("Or type **back** to return to the menu."); addBotMessage(lines.join('\n')); setAmendStep('upd_choose_item'); return; } if (tl === '3' || /^remove/i.test(t)) { const items = (amendOrderDataRef.current.items || []).filter(it => it.active !== false); if (!items.length) { addBotMessage("Your order has no items to remove. Type **1** to add one, or **done** to finish."); return; } amendActionRef.current = 'remove_item'; const lines = ["Which item would you like to remove? Type the **number**:", ""]; items.forEach((it, i) => { lines.push("**" + (i + 1) + ".** " + it.qty + " × " + it.name); }); lines.push(""); lines.push("Or type **back** to return to the menu."); addBotMessage(lines.join('\n')); setAmendStep('rm_choose_item'); return; } if (tl === '4' || /^cancel\b/i.test(t)) { amendActionRef.current = 'cancel'; addBotMessage( "Just to confirm — you want to **cancel order " + amendOrderRef.current + " entirely**?\n\n" + "All active items will be removed and stock returned. The order record itself is kept for your reference.\n\n" + "Type a brief **reason** (optional) — or **yes** to confirm with no reason — or **back** to abort." ); setAmendStep('cancel_reason'); return; } addBotMessage("I didn't catch that. Please type **1**, **2**, **3**, or **4** — or **done** to finish."); return; } case 'add_choose_product': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } const products = await ensureProducts(); let prod = null; const skuMatch = t.toUpperCase().match(/P-\d+/); if (skuMatch) prod = products.find(p => p.sku.toUpperCase() === skuMatch[0]); if (!prod && /^\d+$/.test(t)) { const idx = parseInt(t, 10) - 1; if (idx >= 0 && idx < products.length) prod = products[idx]; } if (!prod) { prod = products.find(p => p.name.toLowerCase() === tl) || products.find(p => p.name.toLowerCase().includes(tl)); } if (!prod) { addBotMessage("I couldn't match that product. Please type a SKU like **P-7**, a number from the list, or **back**."); return; } if (prod.stock <= 0) { addBotMessage("Sorry, **" + prod.name + "** is currently out of stock. Pick another, or type **back**."); return; } amendPayloadRef.current = { sku: prod.sku, _name: prod.name, _price: prod.price, _stock: prod.stock }; addBotMessage("Got it — **" + prod.name + "** (RM " + Number(prod.price).toLocaleString() + " each). How many would you like to add? *(1–" + Math.min(prod.stock, 20) + ")*"); setAmendStep('add_choose_qty'); return; } case 'add_choose_qty': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } const qty = parseInt(t, 10); const p = amendPayloadRef.current; if (!qty || qty < 1) { addBotMessage("Please enter a number ≥ 1, or **back** to choose a different product."); return; } if (qty > p._stock) { addBotMessage("Only **" + p._stock + "** in stock. Try a smaller number, or **back**."); return; } if (qty > 20) { addBotMessage("Max 20 per line. Try a smaller number."); return; } amendPayloadRef.current.quantity = qty; addBotMessage( "Confirm: add **" + qty + " × " + p._name + "** " + "(RM " + Number(p._price).toLocaleString() + " each, subtotal RM " + (qty * p._price).toLocaleString() + ") " + "to order **" + amendOrderRef.current + "**?\n\n" + "Type **yes** to confirm, or **back** to choose differently." ); setAmendStep('add_confirm'); return; } case 'add_confirm': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } if (/^(yes|y|confirm|ok)\b/i.test(t)) { await callAmend({ action: 'add_item', sku: amendPayloadRef.current.sku, quantity: amendPayloadRef.current.quantity, }); return; } addBotMessage("Type **yes** to confirm, or **back** to go back."); return; } case 'upd_choose_item': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } const items = (amendOrderDataRef.current.items || []).filter(it => it.active !== false); const idx = parseInt(t, 10) - 1; if (isNaN(idx) || idx < 0 || idx >= items.length) { addBotMessage("Please type a number from the list, or **back**."); return; } const it = items[idx]; amendPayloadRef.current = { item_id: it.item_id, _name: it.name, _price: it.price, _oldQty: it.qty }; addBotMessage( "**" + it.name + "** — current quantity: **" + it.qty + "**. " + "What's the new quantity? *(0 to remove, or 1–20)*" ); setAmendStep('upd_choose_qty'); return; } case 'upd_choose_qty': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } const qty = parseInt(t, 10); const p = amendPayloadRef.current; if (isNaN(qty) || qty < 0) { addBotMessage("Please enter a number ≥ 0 (use 0 to remove the item)."); return; } if (qty > 20) { addBotMessage("Max 20 per line. Try a smaller number."); return; } if (qty === p._oldQty) { addBotMessage("That's the same as the current quantity. Type a different number, or **back**."); return; } amendPayloadRef.current.quantity = qty; const verb = qty === 0 ? "remove" : "change quantity to **" + qty + "**"; addBotMessage( "Confirm: " + verb + " for **" + p._name + "**?\n\n" + "Type **yes** to confirm, or **back** to go back." ); setAmendStep('upd_confirm'); return; } case 'upd_confirm': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } if (/^(yes|y|confirm|ok)\b/i.test(t)) { await callAmend({ action: 'update_quantity', item_id: amendPayloadRef.current.item_id, quantity: amendPayloadRef.current.quantity, }); return; } addBotMessage("Type **yes** to confirm, or **back** to go back."); return; } case 'rm_choose_item': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } const items = (amendOrderDataRef.current.items || []).filter(it => it.active !== false); const idx = parseInt(t, 10) - 1; if (isNaN(idx) || idx < 0 || idx >= items.length) { addBotMessage("Please type a number from the list, or **back**."); return; } const it = items[idx]; amendPayloadRef.current = { item_id: it.item_id, _name: it.name, _qty: it.qty }; addBotMessage( "Confirm: remove **" + it.qty + " × " + it.name + "** from order **" + amendOrderRef.current + "**?\n\n" + "_(Stock will be returned. The line item is soft-removed — the order record is preserved.)_\n\n" + "Type **yes** to confirm, or **back** to go back." ); setAmendStep('rm_confirm'); return; } case 'rm_confirm': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } if (/^(yes|y|confirm|ok)\b/i.test(t)) { await callAmend({ action: 'remove_item', item_id: amendPayloadRef.current.item_id, }); return; } addBotMessage("Type **yes** to confirm, or **back** to go back."); return; } case 'cancel_reason': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } if (/^(yes|y|confirm|ok)\b/i.test(t)) { amendPayloadRef.current.reason = ''; } else { amendPayloadRef.current.reason = t.slice(0, 500); addBotMessage("Reason noted: \"" + amendPayloadRef.current.reason + "\"."); } addBotMessage( "⚠️ Final confirmation — **cancel order " + amendOrderRef.current + "**?\n\n" + "This soft-cancels the order (record is kept, items returned to stock).\n\n" + "Type **yes** to proceed, or **back** to abort." ); setAmendStep('cancel_confirm'); return; } case 'cancel_confirm': { if (/^back\b/i.test(t)) { showActionMenu(amendOrderDataRef.current); return; } if (/^(yes|y|confirm|ok)\b/i.test(t)) { await callAmend({ action: 'cancel', reason: amendPayloadRef.current.reason || '', }); return; } addBotMessage("Type **yes** to confirm cancellation, or **back** to go back."); return; } } } // ────────────────────────────────────────────────────────────────────────── // SHOPPING CART FLOW (drives window.ecoCart — the live on-page cart) // Works for guests + logged-in customers; every change is confirmed first. // ────────────────────────────────────────────────────────────────────────── function cartBridge() { return (typeof window !== 'undefined' && window.ecoCart && window.ecoCart.ready) ? window.ecoCart : null; } function money(n) { return "RM " + Number(n || 0).toLocaleString('en-MY'); } function resetCartFlow() { setCartStep(null); cartPickRef.current = []; cartDraftRef.current = {}; } function cancelCartFlow(msg) { resetCartFlow(); addBotMessage(msg || "No problem — your cart is unchanged. Anything else? 🌿"); } function renderCartLines(b) { const items = b.list(); const lines = ["**Your cart:**"]; items.forEach((it, i) => lines.push((i + 1) + ". " + it.qty + " × " + it.name + " — " + money(it.line))); lines.push(""); lines.push("**Total: " + money(b.total()) + "** (" + b.count() + " item" + (b.count() === 1 ? '' : 's') + ")"); return lines.join('\n'); } function showCartMenu(preface) { const b = cartBridge(); if (!b) { addBotMessage("Sorry, the cart isn't ready yet — please try again in a moment."); resetCartFlow(); return; } const items = b.list(); const lines = []; if (preface) { lines.push(preface); lines.push(""); } if (!items.length) { lines.push("🛒 Your cart is currently **empty**."); lines.push(""); lines.push("Want to add something? Type **add** and tell me what you're looking for — or type **done** to finish."); addBotMessage(lines.join('\n')); setCartStep('menu'); return; } lines.push(renderCartLines(b)); lines.push(""); lines.push("What would you like to do? Type the number:"); lines.push("**1.** ➕ Add an item"); lines.push("**2.** ✏️ Change a quantity"); lines.push("**3.** ❌ Remove an item"); lines.push("**4.** 🗑️ Empty the cart"); lines.push("**5.** 💳 Checkout"); lines.push(""); lines.push("Or type **done** to finish."); addBotMessage(lines.join('\n')); setCartStep('menu'); } function startCart(intent) { const b = cartBridge(); if (!b) { addBotMessage("Sorry, the cart isn't available right now. Please try again in a moment."); return; } // "checkout" intent with a non-empty cart jumps straight to confirm if (intent === 'checkout' && b.list().length) { addBotMessage("Ready to checkout with **" + money(b.total()) + "** (" + b.count() + " item" + (b.count() === 1 ? '' : 's') + ")? Type **yes** to open checkout, or **back** to review your cart."); setCartStep('checkout_confirm'); return; } showCartMenu("Sure — here's your shopping cart 🛒"); } function cartBeginAdd() { addBotMessage("What would you like to add? Tell me a product name, brand, or keyword (e.g. *Dell Latitude*, *ProDesk*, *HP*)."); setCartStep('add_search'); } function cartDoSearch(q) { const b = cartBridge(); const results = b.search(q).filter(p => p.stock === null || p.stock > 0); if (!results.length) { addBotMessage("Hmm, I couldn't find an available product matching **" + q + "** 🤔 Try another keyword, or type **back** to go back."); return; } cartPickRef.current = results.slice(0, 12); const lines = ["Here's what I found — type the number to add it:", ""]; cartPickRef.current.forEach((p, i) => { const stockTxt = p.stock === null ? "" : (p.stock <= 3 ? " _(only " + p.stock + " left)_" : ""); lines.push("**" + (i + 1) + ".** " + p.name + " — " + money(p.price) + stockTxt); }); lines.push(""); lines.push("Or type another keyword, or **back** to go back."); addBotMessage(lines.join('\n')); setCartStep('add_pick'); } function cartShowItemPicker(purpose) { const b = cartBridge(); const items = b.list(); if (!items.length) { showCartMenu("Your cart is empty — there's nothing to " + (purpose === 'remove' ? 'remove' : 'change') + "."); return; } cartPickRef.current = items; const verb = purpose === 'remove' ? "remove" : "change the quantity of"; const lines = ["Which item would you like to " + verb + "? Type the number:", ""]; items.forEach((it, i) => lines.push("**" + (i + 1) + ".** " + it.qty + " × " + it.name + " — " + money(it.line))); lines.push(""); lines.push("Or type **back** to go back."); addBotMessage(lines.join('\n')); setCartStep(purpose === 'remove' ? 'rm_pick' : 'qty_pick'); } async function handleCartInput(text) { const t = text.trim(); const b = cartBridge(); if (!b) { cancelCartFlow("Sorry, the cart isn't available right now."); return; } const isBack = /^(back|menu)$/i.test(t); const isYes = /^(yes|y|yeah|yep|confirm|ok|okay|sure)\b/i.test(t); const isNo = /^(no|n|nope)\b/i.test(t); switch (cartStep) { case 'menu': { if (/^(done|nothing|exit|finish|that'?s all)\b/i.test(t)) { resetCartFlow(); addBotMessage("Done! 🌿 Let me know if you need anything else."); return; } if (/^(1|add)\b/i.test(t)) { cartBeginAdd(); return; } if (/^(2|change|edit|qty|quantity)\b/i.test(t)) { cartShowItemPicker('qty'); return; } if (/^(3|remove|delete)\b/i.test(t)) { cartShowItemPicker('remove'); return; } if (/^(4|empty|clear)\b/i.test(t)) { if (!b.list().length) { showCartMenu("Your cart is already empty 🛒"); return; } addBotMessage("Are you sure you want to **empty your entire cart**? Type **yes** to confirm, or **back** to keep it."); setCartStep('clear_confirm'); return; } if (/^(5|checkout|check out|pay|buy|order)\b/i.test(t)) { if (!b.list().length) { showCartMenu("Your cart is empty — add something first 🛒"); return; } addBotMessage("Ready to checkout with **" + money(b.total()) + "** (" + b.count() + " item" + (b.count() === 1 ? '' : 's') + ")? Type **yes** to open checkout, or **back**."); setCartStep('checkout_confirm'); return; } addBotMessage("Please type **1**–**5**, or **done** to finish."); return; } case 'add_search': { if (isBack) { showCartMenu(); return; } cartDoSearch(t); return; } case 'add_pick': { if (isBack) { showCartMenu(); return; } const idx = parseInt(t, 10) - 1; if (isNaN(idx) || idx < 0 || idx >= cartPickRef.current.length) { cartDoSearch(t); return; } const p = cartPickRef.current[idx]; cartDraftRef.current = p; const cap = p.stock === null ? 20 : Math.min(p.stock, 20); addBotMessage("How many **" + p.name + "** (" + money(p.price) + " each) would you like to add? *(1–" + cap + ")*"); setCartStep('add_qty'); return; } case 'add_qty': { if (isBack) { showCartMenu(); return; } const n = parseInt(t, 10); const p = cartDraftRef.current; const cap = p.stock === null ? 20 : Math.min(p.stock, 20); if (isNaN(n) || n < 1) { addBotMessage("Please enter a number between 1 and " + cap + "."); return; } if (n > cap) { addBotMessage("I can only add up to **" + cap + "** of those. Please enter a smaller number."); return; } cartDraftRef.current = { ...p, _qty: n }; addBotMessage("Add **" + n + " × " + p.name + "** (" + money(p.price * n) + ") to your cart? Type **yes** to confirm, or **back**."); setCartStep('add_confirm'); return; } case 'add_confirm': { if (isBack || isNo) { showCartMenu("Okay, nothing added."); return; } if (isYes) { const p = cartDraftRef.current; const res = b.add({ sku: p.sku, name: p.name, price: p.price, brand: p.brand, id: p.id }, p._qty); if (res.ok) showCartMenu("✅ Added **" + p._qty + " × " + p.name + "** to your cart."); else if (res.error === 'insufficient_stock') showCartMenu("⚠️ Only " + res.stock + " in stock (you already have " + res.inCart + " in your cart). Nothing added."); else showCartMenu("Sorry, I couldn't add that item."); return; } addBotMessage("Type **yes** to confirm, or **back**."); return; } case 'qty_pick': { if (isBack) { showCartMenu(); return; } const idx = parseInt(t, 10) - 1; if (isNaN(idx) || idx < 0 || idx >= cartPickRef.current.length) { addBotMessage("Please type a valid item number, or **back**."); return; } const it = cartPickRef.current[idx]; cartDraftRef.current = it; const st = b.stockFor(it.sku); const cap = st === null ? 20 : Math.min(st, 20); addBotMessage("**" + it.name + "** — current quantity **" + it.qty + "**. What's the new quantity? *(0 to remove, max " + cap + ")*"); setCartStep('qty_value'); return; } case 'qty_value': { if (isBack) { showCartMenu(); return; } const n = parseInt(t, 10); const it = cartDraftRef.current; const st = b.stockFor(it.sku); const cap = st === null ? 20 : Math.min(st, 20); if (isNaN(n) || n < 0) { addBotMessage("Please enter a number (0 to remove)."); return; } if (n > cap) { addBotMessage("Only **" + cap + "** available in stock. Please enter a smaller number."); return; } cartDraftRef.current = { ...it, _qty: n }; if (n === 0) addBotMessage("Set **" + it.name + "** to 0 — this will **remove** it from your cart. Type **yes** to confirm, or **back**."); else addBotMessage("Change **" + it.name + "** from " + it.qty + " to **" + n + "**? Type **yes** to confirm, or **back**."); setCartStep('qty_confirm'); return; } case 'qty_confirm': { if (isBack || isNo) { showCartMenu("Okay, quantity unchanged."); return; } if (isYes) { const it = cartDraftRef.current; const res = b.setQty(it.sku, it._qty); if (res.ok) showCartMenu(res.removed ? ("✅ Removed **" + it.name + "** from your cart.") : ("✅ Updated **" + it.name + "** to **" + it._qty + "**.")); else if (res.error === 'insufficient_stock') showCartMenu("⚠️ Only " + res.stock + " in stock. Quantity unchanged."); else showCartMenu("Sorry, I couldn't update that item."); return; } addBotMessage("Type **yes** to confirm, or **back**."); return; } case 'rm_pick': { if (isBack) { showCartMenu(); return; } const idx = parseInt(t, 10) - 1; if (isNaN(idx) || idx < 0 || idx >= cartPickRef.current.length) { addBotMessage("Please type a valid item number, or **back**."); return; } const it = cartPickRef.current[idx]; cartDraftRef.current = it; addBotMessage("Remove **" + it.qty + " × " + it.name + "** from your cart? Type **yes** to confirm, or **back**."); setCartStep('rm_confirm'); return; } case 'rm_confirm': { if (isBack || isNo) { showCartMenu("Okay, nothing removed."); return; } if (isYes) { const it = cartDraftRef.current; const res = b.remove(it.sku); if (res.ok) showCartMenu("✅ Removed **" + it.name + "** from your cart."); else showCartMenu("That item wasn't in your cart."); return; } addBotMessage("Type **yes** to confirm, or **back**."); return; } case 'clear_confirm': { if (isBack || isNo) { showCartMenu("Okay, your cart is unchanged."); return; } if (isYes) { b.clear(); showCartMenu("🗑️ Your cart is now empty."); return; } addBotMessage("Type **yes** to empty the cart, or **back**."); return; } case 'checkout_confirm': { if (isBack || isNo) { showCartMenu("Okay — let me know when you're ready."); return; } if (isYes) { b.openCheckout(); resetCartFlow(); addBotMessage("💳 I've opened the checkout for you — just fill in your details to place the order. 🌿"); return; } addBotMessage("Type **yes** to open checkout, or **back**."); return; } default: { resetCartFlow(); showCartMenu(); return; } } } // Auto-scroll on new messages React.useEffect(() => { if (bottomRef.current) { bottomRef.current.parentElement.scrollTop = bottomRef.current.offsetTop; } }, [messages, thinking]); // Focus input when opened React.useEffect(() => { if (open && inputRef.current) setTimeout(() => inputRef.current.focus(), 100); }, [open]); // Load docs when chat first opens React.useEffect(() => { if (open && docContext === null && !loadingDocs) { loadDocuments(); } if (open) setUnread(false); }, [open]); async function loadDocuments() { setLoadingDocs(true); setLoadError(null); try { const res = await fetch('docs/manifest.json'); if (!res.ok) throw new Error('manifest.json not found'); const manifest = await res.json(); const files = manifest.files || []; const loaded = []; const names = []; for (const file of files) { try { const content = await parseFileContent(file.path); loaded.push('=== ' + file.name + ' ===\n' + content); names.push(file.name); } catch (e) { loaded.push('=== ' + file.name + ' ===\n[Error reading file: ' + e.message + ']'); names.push(file.name + ' (error)'); } } const ctx = loaded.join('\n\n'); setDocContext(ctx); setDocNames(names); // Greeting message const greeting = names.length > 0 ? 'Hi! I\'m the Eco Computer AI assistant 🌿 I\'ve loaded **' + names.length + ' document' + (names.length > 1 ? 's' : '') + '** and I\'m ready to help. Ask me about our products, pricing, warranty, or services!' : 'Hi! I\'m the Eco Computer AI assistant 🌿 I can answer questions about our products and services. How can I help you today?'; addBotMessage(greeting); } catch (e) { setLoadError(e.message); setDocContext(''); addBotMessage('Hi! I\'m the Eco Computer AI assistant 🌿 I\'m ready to help with questions about Eco Computer Solutions PLT. How can I help?'); } finally { setLoadingDocs(false); } } function addBotMessage(text) { setMessages(prev => [...prev, { role: 'assistant', content: text, id: Date.now() }]); } async function sendMessage(text) { const userText = (text || input).trim(); if (!userText || thinking) return; setInput(''); const userMsg = { role: 'user', content: userText, id: Date.now() }; setMessages(prev => [...prev, userMsg]); // ── Special quick-action chips ── if (userText === "📦 Track my order") { startTracking(); return; } if (userText === "✏️ Amend my order") { startAmendment(); return; } if (userText === "🛒 My cart") { startCart(); return; } // Universal escape (only OUTSIDE confirmation steps that themselves use 'cancel'/'stop' wording) if (trackingStep && /^(cancel|stop|nevermind|never mind|exit|quit)\b/i.test(userText)) { cancelTracking(); return; } if (amendStep && /^(stop|nevermind|never mind|exit|quit)\b/i.test(userText)) { cancelAmendment(); return; } if (cartStep && /^(cancel|stop|nevermind|never mind|exit|quit)\b/i.test(userText)) { cancelCartFlow(); return; } // If currently in a flow, route the input there (bypass Claude) if (amendStep) { await handleAmendInput(userText); return; } if (trackingStep) { await handleTrackingInput(userText); return; } if (cartStep) { await handleCartInput(userText); return; } // Detect shopping-cart intent (cart keywords never overlap with order flows) if (CART_RX.test(userText)) { startCart(/check\s*out/i.test(userText) ? 'checkout' : null); return; } // Detect amend intent BEFORE tracking (more specific) if (AMEND_RX.test(userText)) { const m = userText.match(ORDERNO_RX); startAmendment(m ? m[0] : null); return; } // Otherwise, detect tracking intent in fresh conversation if (TRACKING_RX.test(userText) || ORDERNO_RX.test(userText)) { const m = userText.match(ORDERNO_RX); startTracking(m ? m[0] : null); return; } setThinking(true); // Build messages for Claude const systemPrompt = `You are a helpful AI assistant for Eco Computer Solutions PLT, a Malaysian-based refurbished computer reseller. Answer questions clearly and concisely based on the documents provided. If the answer is not in the documents, use your general knowledge about the company from the website context, but say so. Keep answers friendly and professional. IMPORTANT — ORDER-TRACKING SAFETY: - You do NOT have access to the customer's order database. - NEVER invent order numbers, statuses, delivery dates, or any specific details about a customer's order. - If a customer asks about THEIR order (status, where it is, when it'll arrive, what's in it, etc.), reply briefly and tell them to click the "📦 Track my order" button at the bottom of the chat (or type "track my order") — the system will then ask for their order number and phone and look it up securely. - If a customer wants to ADD items / CHANGE quantity / REMOVE items / CANCEL an order, tell them to click the "✏️ Amend my order" button (or type "amend my order") — the system will verify identity and walk them through the change. Do NOT attempt to amend or cancel orders yourself. - If a customer wants to SEE, ADD TO, CHANGE, REMOVE FROM, EMPTY, or CHECK OUT their SHOPPING CART (the items they're about to buy — NOT a placed order), tell them to click the "🛒 My cart" button (or type "my cart") — the system manages their live cart and confirms every change. Do NOT invent cart contents yourself. ${docContext ? 'DOCUMENTS:\n' + docContext : 'No documents loaded — answer from general knowledge about the company.'}`; // Build conversation history (cap at last 10 exchanges to stay within limits) const history = historyRef.current.slice(-20); const claudeMessages = [ { role: 'user', content: systemPrompt }, { role: 'assistant', content: 'Understood! I\'m ready to assist customers of Eco Computer Solutions PLT based on the provided documents.' }, ...history, { role: 'user', content: userText }, ]; try { const startTime = Date.now(); let reply = ''; // Use built-in claude helper if available (design tool preview), // otherwise call the PHP proxy (live Hostinger hosting) if (typeof window.claude !== 'undefined' && window.claude && typeof window.claude.complete === 'function') { reply = await window.claude.complete({ messages: claudeMessages }); } else { const res = await fetch('chat.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: claudeMessages }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Server error ' + res.status); } const data = await res.json(); if (data.error) throw new Error(data.error); reply = (data.content && data.content[0] && data.content[0].text) || 'No response received.'; } addBotMessage(reply); // Save to history historyRef.current.push({ role: 'user', content: userText }); historyRef.current.push({ role: 'assistant', content: reply }); if (!open) setUnread(true); // ── Log chat to Google Sheets (best-effort; silent on failure) ──── const elapsed = Date.now() - startTime; fetch('chat_logger.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question: userText, answer: reply, response_ms: elapsed, }), }).catch(() => {}); // never block chat on logger error } catch (e) { addBotMessage('Sorry, I encountered an error: ' + e.message + '. Please try again in a moment.'); } finally { setThinking(false); } } function handleKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } } // Simple markdown-ish renderer (bold, bullets, italic, links) function renderInline(text, keyBase) { // [text](url) → const parts = []; let cursor = 0; const linkRx = /\[([^\]]+)\]\(([^)]+)\)/g; let m; while ((m = linkRx.exec(text)) !== null) { if (m.index > cursor) parts.push(text.slice(cursor, m.index)); parts.push({ __link: { label: m[1], href: m[2] } }); cursor = m.index + m[0].length; } if (cursor < text.length) parts.push(text.slice(cursor)); // For each chunk, also handle **bold** and *italic* const out = []; parts.forEach((chunk, idx) => { if (typeof chunk === 'object' && chunk.__link) { out.push( {chunk.__link.label} ); return; } const segments = chunk.split(/(\*\*[^*]+\*\*|\*[^*]+\*)/g); segments.forEach((s, j) => { if (s.startsWith('**') && s.endsWith('**')) { out.push({s.slice(2, -2)}); } else if (s.startsWith('*') && s.endsWith('*') && s.length > 2) { out.push({s.slice(1, -1)}); } else if (s) { out.push({s}); } }); }); return out; } function renderText(text) { const lines = text.split('\n'); return lines.map((line, i) => { // Bullet if (line.startsWith('- ') || line.startsWith('• ')) { return (
{renderInline(line.slice(2), 'b' + i)}
); } return
{renderInline(line, 'l' + i)}
; }); } return ( <> {/* ── Floating Button ── */} {/* ── Chat Panel ── */}
{/* Resize handle — top-left corner (panel is anchored bottom-right) */}
{/* Header */}
Eco AI Assistant
{loadingDocs ? 'Loading documents…' : docNames.length ? 'Powered by Claude · ' + docNames.length + ' doc' + (docNames.length > 1 ? 's' : '') + ' loaded' : 'Powered by Claude'}
{loadingDocs &&
}
{docNames.length > 0 && (
{docNames.map((n, i) => (
{n}
))}
)}
{/* Messages */}
{messages.length === 0 && !loadingDocs && (
🌿
Hi there!
Ask me anything about our products, pricing, or services.
)} {messages.map(msg => (
{msg.role === 'assistant' && (
)}
{renderText(msg.content)}
))} {thinking && (
)} {/* Suggested questions — show only when no messages yet */} {messages.length === 1 && !thinking && (
SUGGESTED
{SUGGESTIONS.map(s => ( ))}
)}
{/* Quick action chips: persistent "Track" + "Amend" + "Cart" */} {!loadingDocs && !trackingStep && !amendStep && !cartStep && (
)} {trackingStep && (
📦 Tracking order — type cancel to exit
)} {amendStep && (
✏️ Amending order — type done to finish
)} {cartStep && (
🛒 Managing cart — type done to finish
)} {/* Input */}