| import json |
| import os |
| import re |
| from collections import OrderedDict |
| from html import escape |
| from pathlib import Path |
|
|
| import gradio as gr |
| import uvicorn |
| from fastapi import FastAPI |
| from fastapi.responses import HTMLResponse |
| from fastapi.staticfiles import StaticFiles |
| from pydantic import BaseModel, Field |
|
|
| from meltmind_engine import MeltMindEngine |
| from space_runtime import backend_status, start_space_backend |
|
|
|
|
| ROOT = Path(__file__).parent |
| MENU = json.loads((ROOT / "data" / "menu.json").read_text()) |
| ADD_ONS = json.loads((ROOT / "data" / "meltmind" / "add_ons.json").read_text())["add_ons"] |
| INGREDIENTS = json.loads((ROOT / "data" / "meltmind" / "ingredients.json").read_text())["ingredients"] |
| BUSINESS = json.loads((ROOT / "data" / "meltmind" / "business_profile.json").read_text()) |
| POLICIES = json.loads((ROOT / "data" / "meltmind" / "operations_and_policies.json").read_text()) |
| CSS = (ROOT / "styles.css").read_text() |
| ENGINE = MeltMindEngine() |
| SPACE_BACKEND = start_space_backend() |
|
|
| LOGO_PATH = "assets/brand/Meltroom.jpeg" |
| HERO_PATH = "assets/hero/meltroom_special1.jpg" |
| WHATSAPP_NUMBER = "917780534935" |
|
|
|
|
| class ChatRequest(BaseModel): |
| message: str = Field(min_length=1, max_length=2000) |
| history: list = Field(default_factory=list) |
|
|
|
|
| class DesignerRequest(BaseModel): |
| budget_inr: int = Field(default=500, ge=99, le=50000) |
| group_size: int = Field(default=1, ge=1, le=50) |
| flavours: list[str] = Field(default_factory=list) |
| textures: list[str] = Field(default_factory=list) |
| premium: bool = False |
| variety: bool = False |
| less_sweet: bool = False |
| allergen_ids: list[str] = Field(default_factory=list) |
| severe_allergy: bool = False |
| request: str = "" |
|
|
|
|
| def asset_url(path: str) -> str: |
| return f"/{path}" |
|
|
|
|
| def numeric_price(value) -> int: |
| match = re.search(r"\d+", str(value)) |
| return int(match.group()) if match else 0 |
|
|
|
|
| def slug(value: str) -> str: |
| return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") |
|
|
|
|
| def add_on_matches_product(add_on: dict, product: dict) -> bool: |
| product_id = slug(product["name"]) |
| explicit_ids = {slug(value) for value in add_on.get("compatible_product_ids", [])} |
| if product_id in explicit_ids: |
| return True |
| if product["category"] in add_on.get("compatible_categories", []): |
| return True |
|
|
| aliases = { |
| "choco_chips": ("chocolate chips", "extra chocolate chips"), |
| "chocolate_sauce": ("chocolate sauce", "extra chocolate drizzle"), |
| "white_chocolate_sauce": ("white chocolate sauce", "extra white chocolate drizzle"), |
| "pista": ("extra pistachio filling", "extra pistachio cream", "pistachio"), |
| "kunafa": ("kunafa",), |
| "banana_slices": ("extra banana", "banana slices"), |
| "mango": ("extra mango", "fresh mango"), |
| "dark_chocolate_filling": ("extra dark chocolate", "dark chocolate filling"), |
| "brownie_crumbs": ("brownie chunks", "brownie crumbs"), |
| "whipped_cream": ("extra cream filling", "whipped cream"), |
| } |
| listed = {value.strip().lower() for value in product["ordering_information"].get("available_add_ons", [])} |
| return any(alias in listed for alias in aliases.get(add_on["add_on_id"], (add_on["name"].lower(),))) |
|
|
|
|
| def js_data() -> str: |
| products = [] |
| for item in MENU: |
| compatible_add_ons = [ |
| add_on["add_on_id"] |
| for add_on in ADD_ONS |
| if add_on["availability"] == "available" and add_on_matches_product(add_on, item) |
| ] |
| products.append( |
| { |
| "id": slug(item["name"]), |
| "name": item["name"], |
| "category": item["category"], |
| "price": numeric_price(item["price"]), |
| "image": asset_url(item["image"]) if item.get("image") else "", |
| "tagline": item.get("tagline", ""), |
| "description": item.get("detailed_description") or item.get("description", ""), |
| "short": item.get("one_line_description") or item.get("description", ""), |
| "serves": item.get("can_be_served_for", ""), |
| "sweetness": item["taste_profile"].get("sweetness", ""), |
| "chocolate": item["taste_profile"].get("chocolate_intensity", ""), |
| "textures": item["taste_profile"].get("textures", []), |
| "flavours": item["taste_profile"].get("flavours", []), |
| "bestFor": [value for value in item["taste_profile"].get("best_for", []) if value], |
| "tags": item.get("tags", []), |
| "availableAddOns": compatible_add_ons, |
| } |
| ) |
| add_ons = [ |
| { |
| "id": item["add_on_id"], |
| "name": item["name"], |
| "price": item["price_inr"], |
| "description": item["description"], |
| "flavours": item["flavour_contribution"], |
| "textures": item["texture_contribution"], |
| "allergens": item["allergen_ids"], |
| "premium": item["premium_add_on"], |
| "compatibleProductIds": [slug(value) for value in item.get("compatible_product_ids", [])], |
| "compatibleCategories": item.get("compatible_categories", []), |
| "message": item.get("upsell_message", ""), |
| } |
| for item in ADD_ONS |
| if item["availability"] == "available" |
| ] |
| return json.dumps({"products": products, "addOns": add_ons}, ensure_ascii=False).replace("</", "<\\/") |
|
|
|
|
| CATEGORY_DETAILS = { |
| "Brownie": ("01", "The originals", "Fudgy, focused, and made for the first perfect bite."), |
| "Melt Creation": ("02", "Playful by design", "Unexpected formats made for sharing, snacking, and showing off."), |
| "Dessert Sandwich": ("03", "Layered obsession", "Two brownie layers, rich fillings, and a gloriously substantial bite."), |
| "Brownie Bowl": ("04", "The signature bowls", "Loaded, spoonable MeltRoom experiences for one or two."), |
| } |
|
|
|
|
| def product_card(item: dict) -> str: |
| product_id = slug(item["name"]) |
| image = asset_url(item["image"]) if item.get("image") else "" |
| flavours = " Β· ".join(item["taste_profile"].get("flavours", [])[:2]) |
| badges = [ |
| f'<span>{escape(item.get("can_be_served_for", ""))}</span>', |
| "<span>Gluten-free</span>", |
| "<span>Eggless</span>", |
| ] |
| if "tree_nuts" in item["dietary_information"].get("allergens", []): |
| badges.append("<span>Contains nuts</span>") |
| return f""" |
| <article class="product-card reveal" data-category="{escape(item["category"])}" |
| data-search="{escape((item["name"] + " " + " ".join(item.get("tags", [])) + " " + flavours).lower())}"> |
| <button class="product-visual open-product" data-product-id="{product_id}" type="button"> |
| <img src="{image}" alt="{escape(item["name"])}" loading="lazy"> |
| <span class="visual-category">{escape(item["category"])}</span> |
| <span class="visual-arrow">β</span> |
| </button> |
| <div class="product-copy"> |
| <div class="product-meta"><span>{escape(flavours)}</span><strong>βΉ{numeric_price(item["price"])}</strong></div> |
| <button class="product-title open-product" data-product-id="{product_id}" type="button">{escape(item["name"])}</button> |
| <p class="product-tagline">{escape(item.get("tagline", ""))}</p> |
| <div class="product-badges">{"".join(badges)}</div> |
| <button class="add-product" data-item-type="product" data-item-id="{product_id}" type="button"> |
| <span class="add-copy">Add to MeltBasket</span> |
| <span class="card-quantity"><i data-action="minus">β</i><b>0</b><i data-action="plus">+</i></span> |
| <em>+</em> |
| </button> |
| </div> |
| </article> |
| """ |
|
|
|
|
| def menu_markup() -> str: |
| grouped = OrderedDict() |
| for item in MENU: |
| grouped.setdefault(item["category"], []).append(item) |
| navigation = "".join( |
| f'<a class="chapter-gateway reveal" href="#chapter-{slug(category)}">' |
| f'<span>{CATEGORY_DETAILS[category][0]}</span><div><strong>{escape(category)}</strong><small>{escape(CATEGORY_DETAILS[category][1])}</small></div><b>Explore β</b></a>' |
| for category in grouped |
| ) |
| sections = [] |
| for category, items in grouped.items(): |
| number, title, copy = CATEGORY_DETAILS[category] |
| sections.append( |
| f""" |
| <section id="chapter-{slug(category)}" class="menu-chapter" data-menu-section="{escape(category)}"> |
| <div class="chapter-heading reveal"> |
| <span>{number}</span> |
| <div><p>{escape(category)}</p><h3>{escape(title)}</h3></div> |
| <p>{escape(copy)}</p> |
| </div> |
| <div class="product-grid">{"".join(product_card(item) for item in items)}</div> |
| </section> |
| """ |
| ) |
| return f""" |
| <section id="menu" class="menu-intro"> |
| <div class="editorial-heading reveal"> |
| <div><p class="eyebrow">The MeltRoom collection</p><h2>Find your kind<br>of indulgence.</h2></div> |
| <p>Explore all four chapters, compare textures and flavours, then build a MeltBasket that feels entirely yours.</p> |
| </div> |
| <div class="chapter-gateways">{navigation}</div> |
| </section> |
| <div class="menu-collection">{"".join(sections)}</div> |
| """ |
|
|
|
|
| def add_on_markup() -> str: |
| cards = [] |
| for item in ADD_ONS: |
| if item["availability"] != "available": |
| continue |
| labels = item["texture_contribution"][:1] + item["flavour_contribution"][:1] |
| allergen = " Β· Contains " + ", ".join(item["allergen_ids"]) if item["allergen_ids"] else "" |
| cards.append( |
| f""" |
| <article class="addon-card reveal"> |
| <div class="addon-top"><span>{"Premium finish" if item["premium_add_on"] else "Finishing touch"}</span><strong>+βΉ{item["price_inr"]}</strong></div> |
| <h3>{escape(item["name"])}</h3> |
| <p>{escape(item["description"])}</p> |
| <div class="addon-tags">{"".join(f"<span>{escape(value)}</span>" for value in labels)}{f"<small>{escape(allergen)}</small>" if allergen else ""}</div> |
| <button class="add-addon" data-addon-id="{item["add_on_id"]}" type="button"><span>Choose a melt</span><b>+</b></button> |
| </article> |
| """ |
| ) |
| return f""" |
| <section id="addons" class="addon-section"> |
| <div class="editorial-heading editorial-heading-dark reveal"> |
| <div><p class="eyebrow">Finish it your way</p><h2>The final<br>little obsession.</h2></div> |
| <p>Add extra chocolate, cream, fruit, pistachio, or crunch. MeltBasket keeps every finishing touch clear and separate.</p> |
| </div> |
| <div class="addon-rail">{"".join(cards)}</div> |
| </section> |
| """ |
|
|
|
|
| def ingredient_markup() -> str: |
| wanted = ["brownie_base", "premium_dark_chocolate", "chocolate_chips", "pista_cream", "kunafa", "honey"] |
| by_id = {item["ingredient_id"]: item for item in INGREDIENTS} |
| cards = [] |
| for index, ingredient_id in enumerate(wanted, start=1): |
| item = by_id[ingredient_id] |
| flavours = item.get("flavour_contribution", [])[:2] |
| textures = item.get("texture_contribution", [])[:2] |
| labels = item.get("dietary_labels", [])[:3] |
| cards.append( |
| f""" |
| <article class="quality-card ingredient-story reveal"> |
| <div class="ingredient-card-top"><span>{index:02}</span><small>{escape(item["ingredient_type"].replace("_", " "))}</small></div> |
| <h3>{escape(item["name"])}</h3> |
| <p>{escape(item["plain_explanation"])}</p> |
| <div class="ingredient-contributions"> |
| <div><small>Flavour</small><strong>{escape(" Β· ".join(flavours) or "Complements the dessert")}</strong></div> |
| <div><small>Texture</small><strong>{escape(" Β· ".join(textures) or "Supports the final finish")}</strong></div> |
| </div> |
| <div class="ingredient-labels">{"".join(f"<span>{escape(value.replace('_', ' '))}</span>" for value in labels)}</div> |
| <div class="ingredient-purpose"><small>Why it belongs</small><strong>{escape(item["why_meltroom_uses_it"])}</strong></div> |
| </article> |
| """ |
| ) |
| return f""" |
| <section id="ingredients" class="quality-section"> |
| <div class="quality-lead reveal"> |
| <p class="eyebrow">Inside every MeltRoom creation</p> |
| <h2>Every layer<br><em>has a purpose.</em></h2> |
| <p>Explore the ingredients that shape MeltRoom's flavour, texture, and character. Our brownie base is gluten-free and eggless, made without maida or refined sugar, then finished with premium chocolate, fruits, creams, and thoughtful contrasts.</p> |
| <div class="quality-pills"><span>Gluten-free brownies</span><span>Eggless</span><span>No maida</span><span>No refined sugar</span></div> |
| <div class="ingredient-note"><b>Substitutions, thoughtfully handled</b><span>Oat or almond milk may replace milk where compatible. Almond milk contains tree nuts, and complete allergy requirements must always be confirmed with MeltRoom.</span></div> |
| </div> |
| <div class="ingredient-library"> |
| <div class="ingredient-library-head reveal"><span>Ingredient library</span><strong>Flavour. Texture. Purpose.</strong><small>Hover or scroll through the building blocks behind the MeltRoom experience.</small></div> |
| <div class="quality-grid">{"".join(cards)}</div> |
| </div> |
| </section> |
| """ |
|
|
|
|
| def meltmind_markup() -> str: |
| return """ |
| <section id="meltmind" class="mind-section"> |
| <div class="mind-gateway reveal"> |
| <p class="eyebrow">MeltMind AI Β· Choose your experience</p> |
| <h2>How would you like<br>to find your melt?</h2> |
| <div class="mind-choice-grid"> |
| <article class="mind-choice mind-choice-chat"> |
| <button data-mind-mode="chat" type="button"><span>01</span><strong>Chat with MeltMind</strong><b>β</b></button> |
| <div><h3>Your informed dessert concierge.</h3><p>Ask anything about MeltRoom, compare products, understand flavours and textures, explore ingredients, or get thoughtful guidance in natural conversation.</p><small>Best when you want to explore, ask questions, or compare before deciding.</small></div> |
| </article> |
| <article class="mind-choice mind-choice-designer"> |
| <button data-mind-mode="designer" type="button"><span>02</span><strong>Design Your Perfect Melt</strong><b>β</b></button> |
| <div><h3>A complete order shaped around you.</h3><p>Choose your flavour, texture, people, budget, and moment. MeltMind creates multiple valid plans with clear prices and serving guidance.</p><small>Best when you want a complete recommendation or group order.</small></div> |
| </article> |
| </div> |
| </div> |
| |
| <div class="mind-workspace" aria-hidden="true"> |
| <div class="mind-workspace-bar"> |
| <a class="mind-workspace-brand" href="#meltmind"><span>M</span><div><strong>MeltMind AI</strong><small>Powered by verified MeltRoom knowledge</small></div></a> |
| <div class="mind-workspace-switch"><button data-mind-mode="chat" type="button"><b>01</b><span>Chat</span></button><button data-mind-mode="designer" type="button"><b>02</b><span>Perfect Melt</span></button></div> |
| <button class="mind-workspace-basket" type="button"><span>M</span><strong>MeltBasket</strong><b class="basket-count">0</b></button> |
| <button class="mind-workspace-close" type="button"><span>Close experience</span><b>Γ</b></button> |
| </div> |
| <div class="mind-workspace-body"> |
| <div class="mind-panel" data-mind-panel="chat"> |
| <aside class="chat-discovery"> |
| <p>Conversation starters</p><h3>Begin wherever your craving begins.</h3> |
| <div class="mind-suggestions"><button class="mind-suggestion" type="button">I want something chocolaty but balanced.</button><button class="mind-suggestion" type="button">Compare the signature bowls for me.</button><button class="mind-suggestion" type="button">What should two friends order under βΉ500?</button><button class="mind-suggestion" type="button">Explain MeltRoom's gluten-free brownies.</button></div> |
| <div class="chat-capabilities"><span>Menu expert</span><span>Honest comparisons</span><span>Budget guidance</span><span>Ingredient answers</span></div> |
| </aside> |
| <div class="chat-stage"> |
| <div class="chat-stage-head"><div class="mind-monogram">M</div><div><strong>Chat with MeltMind</strong><span>Ask naturally. Get grounded MeltRoom answers.</span></div></div> |
| <div class="mind-conversation"><div class="mind-message assistant welcome-answer"><span>M</span><div><em>MeltMind concierge</em><p>Welcome. Tell me what you are craving, who you are ordering for, or what you want to understand.</p><small>I can compare products, explain textures, help with budgets, and move complex plans into Designer.</small></div></div><div class="mind-handoff"><span><small>Got enough information?</small><strong>Design your Perfect Melt using the preferences and constraints we have gathered.</strong></span><button class="chat-to-designer" type="button">Design Perfect Melt <b>β</b></button></div></div> |
| <div class="mind-composer"><textarea id="mind-chat-input" rows="1" placeholder="Ask MeltMind..."></textarea><button class="mind-send" type="button"><span>Ask MeltMind</span><b>β</b></button></div> |
| </div> |
| </div> |
| |
| <div class="mind-panel" data-mind-panel="designer"> |
| <aside class="designer-rail"> |
| <p>Your live melt profile</p><h3>A direction that becomes entirely yours.</h3> |
| <div class="designer-progress"><span class="active"><b>01</b> Flavour</span><span><b>02</b> Texture</span><span><b>03</b> People & budget</span><span><b>04</b> Complete plans</span></div> |
| <div class="designer-profile"><small>What MeltMind understands</small><strong>Waiting for your first choice</strong><p>Your preferences remain connected with Chat and MeltBasket.</p></div> |
| </aside> |
| <div class="designer-stage"> |
| <button class="designer-chat-bridge" data-mind-mode="chat" type="button"><span><small>Have a question before designing?</small><strong>Ask MeltMind about items, ingredients, flavours, textures, or anything else about MeltRoom.</strong></span><b>Ask MeltMind β</b></button> |
| <div class="designer-head"><div><p class="eyebrow">Design Your Perfect Melt</p><h3>Build the feeling.<br>We will build the order.</h3></div></div> |
| <div class="designer-group"><small><b>01</b> Choose a flavour direction</small><div class="designer-options"><button data-designer-value="Deep chocolate" type="button"><i>Dark & rich</i>Deep chocolate</button><button data-designer-value="Pistachio & nutty" type="button"><i>Roasted & premium</i>Pistachio & nutty</button><button data-designer-value="Fruity contrast" type="button"><i>Bright & fresh</i>Fruity contrast</button><button data-designer-value="Creamy & gentle" type="button"><i>Soft & balanced</i>Creamy & gentle</button></div></div> |
| <div class="designer-group"><small><b>02</b> Choose the textures you want</small><div class="designer-options"><button data-designer-value="Fudgy" type="button"><i>Dense</i>Fudgy</button><button data-designer-value="Gooey" type="button"><i>Molten</i>Gooey</button><button data-designer-value="Crunchy" type="button"><i>Textured</i>Crunchy</button><button data-designer-value="Creamy" type="button"><i>Smooth</i>Creamy</button></div></div> |
| <div class="designer-split"><div class="designer-group"><small><b>03</b> Who is this for?</small><div class="designer-options compact"><button data-designer-value="Just me" type="button"><i>One craving</i>Just me</button><button data-designer-value="Two people" type="button"><i>Share together</i>Two people</button><button data-designer-value="A group" type="button"><i>Build variety</i>A group</button></div></div><div class="designer-budget"><small><b>04</b> Maximum budget</small><div><span>βΉ</span><input id="designer-budget" type="number" value="500" min="99" max="5000" step="50"></div><p>MeltMind will never exceed this amount.</p></div></div> |
| <div class="designer-note"><label for="designer-note">Tell us about the moment</label><textarea id="designer-note" rows="2" placeholder="A celebration, movie night, comfort craving, less-sweet preference, or anything else that matters..."></textarea></div> |
| <div class="designer-action"><div><span>Ready to create your directions</span><small>You will receive closest-match, variety, and premium plans.</small></div><button class="designer-build" type="button">Design my perfect melt <b>β</b></button></div> |
| <div class="designer-results"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </section> |
| """ |
|
|
|
|
| HEAD = f""" |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap" rel="stylesheet"> |
| <script> |
| window.MELTROOM_DATA = {js_data()}; |
| (() => {{ |
| if (window.__meltroomStorefront) return; |
| window.__meltroomStorefront = true; |
| const catalog = window.MELTROOM_DATA; |
| const products = new Map(catalog.products.map(item => [item.id, item])); |
| const addOns = new Map(catalog.addOns.map(item => [item.id, item])); |
| const basket = new Map(); |
| const money = value => `βΉ${{value}}`; |
| const query = selector => document.querySelector(selector); |
| const queryAll = selector => [...document.querySelectorAll(selector)]; |
| const mindState = {{ preferences: new Set(), budget: 500, note: "", chatContextProductId: "", lastChatText: "", history: [] }}; |
| const safeText = value => String(value ?? "").replace(/[&<>"']/g, character => ({{"&":"&","<":"<",">":">",'"':""","'":"'"}})[character]); |
| const formatMindAnswer = value => {{ |
| const normalized = String(value ?? "") |
| .replace(/\\r/g, "") |
| .replace(/[^\\S\\n]+/g, " ") |
| .trim(); |
| if (!normalized) return ""; |
| const blocks = []; |
| let ordered = []; |
| let bullets = []; |
| const flushLists = () => {{ |
| if (ordered.length) {{ blocks.push(`<ol>${{ordered.join("")}}</ol>`); ordered = []; }} |
| if (bullets.length) {{ blocks.push(`<ul>${{bullets.join("")}}</ul>`); bullets = []; }} |
| }}; |
| normalized.split(/\\n+/).map(line => line.trim()).filter(Boolean).forEach(line => {{ |
| const numbered = line.match(/^\\d+\\.\\s+(.+)$/); |
| const bullet = line.match(/^[-β’]\\s+(.+)$/); |
| if (numbered) {{ if (bullets.length) flushLists(); ordered.push(`<li>${{safeText(numbered[1])}}</li>`); return; }} |
| if (bullet) {{ if (ordered.length) flushLists(); bullets.push(`<li>${{safeText(bullet[1])}}</li>`); return; }} |
| flushLists(); |
| blocks.push(`<p>${{safeText(line)}}</p>`); |
| }}); |
| flushLists(); |
| return blocks.join(""); |
| }}; |
| let toastTimer; |
| |
| const openBasket = () => {{ |
| query(".melt-basket")?.classList.add("open"); |
| query(".basket-backdrop")?.classList.add("open"); |
| document.body.classList.add("basket-open"); |
| }}; |
| const showToast = message => {{ |
| const toast = query(".melt-toast"); |
| if (!toast) return; |
| toast.textContent = message; |
| toast.classList.add("visible"); |
| clearTimeout(toastTimer); |
| toastTimer = setTimeout(() => toast.classList.remove("visible"), 3200); |
| }}; |
| const animateWorkspaceBasket = () => {{ |
| const button = query(".mind-workspace-basket"); |
| if (!button) return; |
| button.classList.remove("basket-bump"); |
| void button.offsetWidth; |
| button.classList.add("basket-bump"); |
| setTimeout(() => button.classList.remove("basket-bump"), 700); |
| }}; |
| const closeOverlays = () => {{ |
| query(".melt-basket")?.classList.remove("open"); |
| query(".basket-backdrop")?.classList.remove("open"); |
| query(".product-modal")?.classList.remove("open"); |
| query(".addon-picker")?.classList.remove("open"); |
| document.body.classList.remove("basket-open"); |
| }}; |
| const closeTopOverlay = () => {{ |
| const picker = query(".addon-picker"); |
| const modal = query(".product-modal"); |
| if (picker?.classList.contains("open")) {{ |
| picker.classList.remove("open"); |
| if (!query(".melt-basket")?.classList.contains("open")) {{ |
| query(".basket-backdrop")?.classList.remove("open"); |
| document.body.classList.remove("basket-open"); |
| }} |
| return; |
| }} |
| if (modal?.classList.contains("open")) {{ |
| modal.classList.remove("open"); |
| if (!query(".melt-basket")?.classList.contains("open")) {{ |
| query(".basket-backdrop")?.classList.remove("open"); |
| document.body.classList.remove("basket-open"); |
| }} |
| return; |
| }} |
| closeOverlays(); |
| }}; |
| const updateJumpButton = () => {{ |
| const button = query(".context-jump"); |
| const menu = query("#menu"); |
| const collection = query(".menu-collection"); |
| const addOnsSection = query("#addons"); |
| if (!button || !menu || !collection || !addOnsSection) return; |
| const pageTop = element => element.getBoundingClientRect().top + window.scrollY; |
| const menuTop = pageTop(menu); |
| const menuExperienceEnd = pageTop(addOnsSection) + addOnsSection.offsetHeight; |
| const firstChapter = query(".menu-chapter"); |
| const visible = window.scrollY > (firstChapter ? pageTop(firstChapter) + Math.min(260, firstChapter.offsetHeight * .35) : menuTop + 260); |
| const belowMenu = window.scrollY > menuExperienceEnd - window.innerHeight * .35; |
| button.classList.toggle("visible", visible); |
| button.dataset.target = belowMenu ? "home" : "menu"; |
| button.querySelector("strong").textContent = belowMenu ? "Back to home" : "Back to menu"; |
| button.querySelector("small").textContent = belowMenu ? "Return to the beginning" : "Choose another chapter"; |
| }}; |
| |
| const changeProduct = (id, delta, suggest = false) => {{ |
| const source = products.get(id); |
| if (!source) return; |
| const current = basket.get(id) || {{ ...source, quantity: 0, addOns: {{}} }}; |
| const wasEmpty = current.quantity === 0; |
| current.quantity += delta; |
| if (current.quantity <= 0) basket.delete(id); else basket.set(id, current); |
| renderBasket(); |
| if (suggest && wasEmpty && current.quantity > 0) setTimeout(() => openAddonPicker(id), 180); |
| }}; |
| |
| const changeAddon = (productId, addOnId, delta) => {{ |
| const item = basket.get(productId); |
| const addOn = addOns.get(addOnId); |
| if (!item || !addOn || !item.availableAddOns.includes(addOnId)) return; |
| const quantity = Math.max(0, (item.addOns[addOnId] || 0) + delta); |
| if (quantity) item.addOns[addOnId] = quantity; else delete item.addOns[addOnId]; |
| renderBasket(); |
| }}; |
| |
| const renderBasket = () => {{ |
| const entries = [...basket.values()]; |
| const count = entries.reduce((sum, item) => sum + item.quantity, 0); |
| const subtotal = entries.reduce((sum, item) => sum + item.price * item.quantity + |
| Object.entries(item.addOns).reduce((extra, [id, quantity]) => extra + (addOns.get(id)?.price || 0) * quantity, 0), 0); |
| queryAll(".basket-count, .floating-basket-count").forEach(el => el.textContent = count); |
| queryAll(".basket-total, .basket-subtotal strong").forEach(el => el.textContent = money(subtotal)); |
| query(".floating-basket")?.classList.toggle("visible", count > 0); |
| query(".basket-empty")?.classList.toggle("hidden", count > 0); |
| query(".place-order")?.toggleAttribute("disabled", count === 0); |
| queryAll(".add-product[data-item-id]").forEach(button => {{ |
| const quantity = basket.get(button.dataset.itemId)?.quantity || 0; |
| button.classList.toggle("selected", quantity > 0); |
| const countLabel = button.querySelector(".card-quantity b"); |
| if (countLabel) countLabel.textContent = quantity; |
| }}); |
| queryAll(".chat-card-add[data-chat-add]").forEach(button => {{ |
| const quantity = basket.get(button.dataset.chatAdd)?.quantity || 0; |
| button.classList.toggle("selected", quantity > 0); |
| button.closest(".chat-product-card")?.classList.toggle("added", quantity > 0); |
| const quantityLabel = button.querySelector(".chat-add-state strong"); |
| if (quantityLabel) quantityLabel.textContent = quantity; |
| }}); |
| queryAll("[data-result-add]").forEach(button => {{ |
| const quantity = basket.get(button.dataset.resultAdd)?.quantity || 0; |
| button.classList.toggle("selected", quantity > 0); |
| button.textContent = quantity ? `Added Β· ${{quantity}} in MeltBasket +` : "Add to MeltBasket +"; |
| }}); |
| const items = query(".basket-items"); |
| if (items) items.innerHTML = entries.map(item => ` |
| <article class="basket-line"> |
| <div class="basket-line-visual">${{item.image ? `<img src="${{item.image}}" alt="">` : `<span>+</span>`}}</div> |
| <div class="basket-line-copy"><span>${{item.category}}</span><strong>${{item.name}}</strong><small>${{money(item.price)}} each</small> |
| <div class="basket-line-controls"><button data-product-action="minus" data-id="${{item.id}}">β</button><b>${{item.quantity}}</b><button data-product-action="plus" data-id="${{item.id}}">+</button><button data-product-action="remove" data-id="${{item.id}}">Remove</button></div> |
| </div> |
| <strong>${{money(item.price * item.quantity)}}</strong> |
| <div class="basket-customize"> |
| <div class="basket-addon-list">${{Object.entries(item.addOns).map(([id, quantity]) => {{ |
| const addOn = addOns.get(id); return `<span>${{addOn.name}} <b>Γ${{quantity}}</b><button data-basket-addon-remove data-product-id="${{item.id}}" data-addon-id="${{id}}">Γ</button></span>`; |
| }}).join("")}}</div> |
| <button class="customize-line" data-customize-product="${{item.id}}" type="button"><i>+</i>${{Object.keys(item.addOns).length ? "Edit finishing touches" : "Add finishing touches"}}</button> |
| </div> |
| </article>`).join(""); |
| }}; |
| |
| const openAddonPicker = (productId, focusAddonId = "") => {{ |
| const product = basket.get(productId); |
| if (!product) return showToast("Add this dessert to your MeltBasket before customizing it."); |
| const compatible = product.availableAddOns.map(id => addOns.get(id)).filter(Boolean); |
| const picker = query(".addon-picker"); |
| picker.innerHTML = `<button class="picker-close" type="button">Γ</button> |
| <div class="picker-head"><p>Made for this melt</p><h2>Finish your ${{product.name}}</h2><span>Only compatible additions are shown. Each choice stays attached to this dessert.</span></div> |
| <div class="picker-grid">${{compatible.length ? compatible.map(addOn => ` |
| <article class="${{focusAddonId === addOn.id ? "focused" : ""}}"> |
| <div><span>${{addOn.premium ? "Premium finish" : "Finishing touch"}}</span><strong>+${{money(addOn.price)}}</strong></div> |
| <h3>${{addOn.name}}</h3><p>${{addOn.message || addOn.description}}</p> |
| <button data-picker-addon="${{addOn.id}}" data-product-id="${{product.id}}" type="button"> |
| <span>${{product.addOns[addOn.id] ? `Add another Β· ${{product.addOns[addOn.id]}} selected` : "Add to this melt"}}</span><b>+</b> |
| </button> |
| </article>`).join("") : `<div class="picker-empty">This creation is complete as designed. No verified add-ons are currently listed for it.</div>`}}</div> |
| <button class="picker-done" type="button">Done customizing</button>`; |
| picker.classList.add("open"); |
| query(".basket-backdrop")?.classList.add("open"); |
| document.body.classList.add("basket-open"); |
| }}; |
| |
| const chooseAddonTarget = addOnId => {{ |
| const addOn = addOns.get(addOnId); |
| const eligible = [...basket.values()].filter(product => product.availableAddOns.includes(addOnId)); |
| if (!basket.size) return showToast("Add a MeltRoom creation first, then choose which dessert gets this finishing touch."); |
| if (!eligible.length) return showToast(`${{addOn.name}} is not compatible with the desserts currently in your MeltBasket.`); |
| const picker = query(".addon-picker"); |
| picker.innerHTML = `<button class="picker-close" type="button">Γ</button> |
| <div class="picker-head"><p>Choose its perfect match</p><h2>Add ${{addOn.name}} to which melt?</h2><span>We only show desserts where this addition works beautifully.</span></div> |
| <div class="target-grid">${{eligible.map(product => `<button data-addon-target="${{product.id}}" data-addon-id="${{addOnId}}" type="button"><img src="${{product.image}}" alt=""><span><small>${{product.category}}</small><strong>${{product.name}}</strong></span><b>Choose β</b></button>`).join("")}}</div>`; |
| picker.classList.add("open"); |
| query(".basket-backdrop")?.classList.add("open"); |
| document.body.classList.add("basket-open"); |
| }}; |
| |
| const openProduct = id => {{ |
| const item = products.get(id); |
| if (!item) return; |
| const modal = query(".product-modal"); |
| modal.innerHTML = `<button class="modal-close" type="button">Γ</button> |
| <div class="modal-image"><img src="${{item.image}}" alt="${{item.name}}"><span>${{item.category}}</span></div> |
| <div class="modal-copy"><p class="eyebrow">${{item.flavours.slice(0, 3).join(" Β· ")}}</p><h2>${{item.name}}</h2><p class="modal-tagline">${{item.tagline}}</p><p>${{item.description}}</p> |
| <div class="modal-facts"><span><small>Serves</small>${{item.serves}}</span><span><small>Sweetness</small>${{item.sweetness}}</span><span><small>Chocolate</small>${{item.chocolate}}</span></div> |
| <div class="modal-lists"><div><small>Texture</small><p>${{item.textures.join(", ")}}</p></div><div><small>Best for</small><p>${{item.bestFor.slice(0, 5).join(", ")}}</p></div></div> |
| <button class="modal-add add-product" data-item-type="product" data-item-id="${{item.id}}" type="button"><span class="add-copy">Add to MeltBasket Β· ${{money(item.price)}}</span><em>+</em><span class="card-quantity"><i data-action="minus">β</i><b>${{basket.get(item.id)?.quantity || 0}}</b><i data-action="plus">+</i></span></button></div>`; |
| modal.classList.add("open"); query(".basket-backdrop")?.classList.add("open"); document.body.classList.add("basket-open"); |
| renderBasket(); |
| }}; |
| |
| const placeOrder = () => {{ |
| if (!basket.size) return; |
| const entries = [...basket.values()]; |
| const subtotal = entries.reduce((sum, item) => sum + item.price * item.quantity + |
| Object.entries(item.addOns).reduce((extra, [id, quantity]) => extra + addOns.get(id).price * quantity, 0), 0); |
| const fulfilment = query('input[name="fulfilment"]:checked')?.value || "Delivery through Rapido"; |
| const lines = ["Hello MeltRoom! I would like to place this order:", ""]; |
| entries.forEach(item => {{ |
| lines.push(`β’ ${{item.quantity}} Γ ${{item.name}} β ${{money(item.price * item.quantity)}}`); |
| Object.entries(item.addOns).forEach(([id, quantity]) => lines.push(` + ${{quantity}} Γ ${{addOns.get(id).name}} β ${{money(addOns.get(id).price * quantity)}}`)); |
| }}); |
| lines.push("", `Menu subtotal: ${{money(subtotal)}}`, `Fulfilment: ${{fulfilment}}`, "", "Please confirm availability, final total, delivery/pickup details, and share the payment QR."); |
| window.open(`https://wa.me/{WHATSAPP_NUMBER}?text=${{encodeURIComponent(lines.join("\\n"))}}`, "_blank", "noopener"); |
| }}; |
| |
| const updateDesignerProfile = () => {{ |
| const selected = queryAll("[data-designer-value].selected").map(button => button.dataset.designerValue); |
| mindState.preferences = new Set(selected); |
| mindState.budget = Number(query("#designer-budget")?.value || 500); |
| mindState.note = query("#designer-note")?.value.trim() || ""; |
| const profile = query(".designer-profile"); |
| if (!profile) return; |
| profile.querySelector("strong").textContent = selected.length ? selected.slice(0, 3).join(" Β· ") : "Waiting for your first choice"; |
| profile.querySelector("p").textContent = selected.length |
| ? `${{selected.length}} preference${{selected.length === 1 ? "" : "s"}} selected. MeltMind will balance these with your budget and notes.` |
| : "Your selected preferences will appear here before recommendation generation."; |
| const progress = queryAll(".designer-progress span"); |
| const hasFlavour = selected.some(value => ["Deep chocolate", "Pistachio & nutty", "Fruity contrast", "Creamy & gentle"].includes(value)); |
| const hasTexture = selected.some(value => ["Fudgy", "Gooey", "Crunchy", "Creamy"].includes(value)); |
| const hasPeople = selected.some(value => ["Just me", "Two people", "A group"].includes(value)); |
| [hasFlavour, hasTexture, hasPeople, query(".designer-results")?.classList.contains("visible")].forEach((complete, index) => progress[index]?.classList.toggle("active", Boolean(complete))); |
| }}; |
| |
| const switchMindMode = mode => {{ |
| queryAll("[data-mind-mode]").forEach(button => button.classList.toggle("active", button.dataset.mindMode === mode)); |
| queryAll("[data-mind-panel]").forEach(panel => panel.classList.toggle("active", panel.dataset.mindPanel === mode)); |
| const workspace = query(".mind-workspace"); |
| workspace?.classList.add("open"); |
| workspace?.setAttribute("aria-hidden", "false"); |
| document.body.classList.add("mind-workspace-open"); |
| }}; |
| |
| const closeMindWorkspace = () => {{ |
| const workspace = query(".mind-workspace"); |
| workspace?.classList.remove("open"); |
| workspace?.setAttribute("aria-hidden", "true"); |
| document.body.classList.remove("mind-workspace-open"); |
| query("#meltmind")?.scrollIntoView({{ behavior: "smooth", block: "center" }}); |
| }}; |
| |
| const extractChatPreferences = text => {{ |
| const normalized = text.toLowerCase(); |
| const mappings = [ |
| ["Deep chocolate", ["chocolate", "chocolaty", "chocolately", "fudge", "rich"]], |
| ["Pistachio & nutty", ["pista", "pistachio", "nutty", "kunafa"]], |
| ["Fruity contrast", ["fruit", "mango", "banana"]], |
| ["Creamy & gentle", ["cream", "creamy", "gentle", "light"]], |
| ["Fudgy", ["fudgy", "fudge"]], |
| ["Gooey", ["gooey", "goey", "melty"]], |
| ["Crunchy", ["crunch", "crispy", "kunafa"]], |
| ["Creamy", ["creamy", "cream"]], |
| ["Two people", ["two people", "two friends", "couple", "for 2"]], |
| ["A group", ["group", "friends", "party", "people", "sharing"]], |
| ["Just me", ["just me", "myself", "one person"]] |
| ]; |
| mappings.forEach(([value, words]) => {{ if (words.some(word => normalized.includes(word))) mindState.preferences.add(value); }}); |
| if (mindState.preferences.has("Just me")) {{ mindState.preferences.delete("Two people"); mindState.preferences.delete("A group"); }} |
| else if (mindState.preferences.has("Two people")) mindState.preferences.delete("A group"); |
| const budgetMatch = normalized.match(/(?:βΉ|rs\\.?|inr|under|budget(?: of)?)\\s*(\\d{{2,5}})/i) || normalized.match(/(\\d{{2,5}})\\s*(?:rupees|rs|inr)/i); |
| if (budgetMatch) mindState.budget = Number(budgetMatch[1]); |
| queryAll("[data-designer-value]").forEach(button => button.classList.toggle("selected", mindState.preferences.has(button.dataset.designerValue))); |
| if (query("#designer-budget")) query("#designer-budget").value = mindState.budget; |
| if (query("#designer-note")) query("#designer-note").value = text; |
| updateDesignerProfile(); |
| }}; |
| |
| const recommendProducts = () => {{ |
| const selected = [...mindState.preferences].map(value => value.toLowerCase()); |
| const score = product => {{ |
| const searchable = [product.name, product.category, ...product.flavours, ...product.textures, ...product.bestFor, ...product.tags].join(" ").toLowerCase(); |
| let value = 0; |
| selected.forEach(preference => {{ |
| preference.split(/\\s+|&/).filter(word => word.length > 3).forEach(word => {{ if (searchable.includes(word)) value += 3; }}); |
| }}); |
| if (selected.includes("a group") && (product.serves.includes("2") || product.category === "Brownie Bowl")) value += 5; |
| if (selected.includes("just me") && product.serves.includes("1 person")) value += 4; |
| if (product.price <= mindState.budget) value += 3; else value -= 20; |
| return value; |
| }}; |
| return [...products.values()].filter(product => product.price <= mindState.budget).sort((a, b) => score(b) - score(a)).slice(0, 3); |
| }}; |
| |
| const renderDesignerResults = () => {{ |
| updateDesignerProfile(); |
| const results = query(".designer-results"); |
| const recommendations = recommendProducts(); |
| if (!results) return; |
| results.innerHTML = `<div class="designer-results-head"><div><small>Your current directions</small><h3>Three melts worth exploring</h3></div></div> |
| <div class="designer-result-grid">${{recommendations.map((product, index) => `<article> |
| <div class="result-image"><img src="${{product.image}}" alt="${{product.name}}"><span>0${{index + 1}}</span></div> |
| <div class="result-copy"><small>${{product.category}} Β· ${{money(product.price)}}</small><h4>${{product.name}}</h4><p>${{product.short}}</p> |
| <div><button data-result-chat="${{product.id}}" type="button">Ask MeltMind about it</button><button data-result-add="${{product.id}}" type="button">Add to MeltBasket +</button></div> |
| </div></article>`).join("")}}</div>`; |
| results.classList.add("visible"); |
| }}; |
| |
| const openChatWithProduct = productId => {{ |
| const product = products.get(productId); |
| if (!product) return; |
| mindState.chatContextProductId = productId; |
| switchMindMode("chat"); |
| const input = query("#mind-chat-input"); |
| if (input) {{ input.value = `Tell me more about ${{product.name}}. Explain its taste, texture, serving fit, and how it compares with similar MeltRoom items.`; input.focus(); }} |
| showToast(`${{product.name}} is now the active context in Chat with MeltMind.`); |
| }}; |
| |
| const appendMindAnswer = payload => {{ |
| const conversation = query(".mind-conversation"); |
| if (!conversation) return; |
| const productCards = (payload.products || []).map(product => {{ |
| const id = product.id.replaceAll("_", "-"); |
| const catalogProduct = products.get(id); |
| const image = catalogProduct?.image || ""; |
| const tagline = catalogProduct?.tagline || catalogProduct?.short || "Explore this MeltRoom direction."; |
| return `<article class="chat-product-card"> |
| <button class="chat-product-visual" data-mind-product="${{id}}" type="button"><img src="${{image}}" alt="${{product.name}}"><span class="chat-card-category" style="color:#32140f!important;-webkit-text-fill-color:#32140f!important">${{product.category}}</span><strong class="chat-card-price">${{money(product.price)}}</strong></button> |
| <div class="chat-product-copy"><small>Suggested for your craving</small><button class="chat-card-details" data-open-product="${{id}}" type="button"><strong>${{product.name}}</strong><span>${{tagline}}</span><em>View taste, texture and serving details β</em></button><button class="chat-card-add" data-chat-add="${{id}}" type="button"><span class="chat-add-copy">Add to MeltBasket</span><span class="chat-add-state"><strong>0</strong><i>added to MeltBasket</i></span><b>+</b></button></div> |
| </article>`; |
| }}).join(""); |
| query(".mind-thinking")?.remove(); |
| const handoff = query(".mind-handoff"); |
| handoff?.insertAdjacentHTML("beforebegin", `<div class="mind-message assistant live-answer"><span>M</span><div><em>${{productCards ? "MeltMind recommendation" : "MeltMind answer"}}</em><div class="mind-answer-copy">${{formatMindAnswer(payload.answer)}}</div>${{productCards ? `<div class="answer-directions">${{productCards}}</div>` : ""}}<small>${{safeText(payload.source || "Verified MeltRoom knowledge")}}${{payload.handoff ? " Β· Human confirmation required" : ""}}</small></div></div>`); |
| renderBasket(); |
| const answer = handoff?.previousElementSibling; |
| if (answer) requestAnimationFrame(() => conversation.scrollTo({{ top: Math.max(0, answer.offsetTop - conversation.offsetTop - 8), behavior: "auto" }})); |
| }}; |
| |
| const askMeltMind = async () => {{ |
| const input = query("#mind-chat-input"); |
| const text = input?.value.trim() || ""; |
| if (!text) return showToast("Tell MeltMind what you are craving or what you want to understand."); |
| mindState.lastChatText = text; |
| extractChatPreferences(text); |
| const conversation = query(".mind-conversation"); |
| const handoff = query(".mind-handoff"); |
| handoff?.insertAdjacentHTML("beforebegin", `<div class="mind-message user live-answer"><div><p>${{text}}</p></div></div><div class="mind-message assistant mind-thinking"><span>M</span><div><em>MeltMind is considering your craving</em><div class="thinking-dots"><i></i><i></i><i></i></div></div></div>`); |
| if (conversation) conversation.scrollTop = conversation.scrollHeight; |
| if (input) input.value = ""; |
| try {{ |
| const response = await fetch("/api/meltmind/chat", {{ method: "POST", headers: {{ "Content-Type": "application/json" }}, body: JSON.stringify({{ message: text, history: mindState.history.slice(-8) }}) }}); |
| if (!response.ok) throw new Error("Chat request failed"); |
| const payload = await response.json(); |
| mindState.history.push({{ role: "user", content: text }}, {{ role: "assistant", content: payload.answer }}); |
| appendMindAnswer(payload); |
| if (payload.route_to_designer) {{ |
| showToast("Your craving and budget are ready in Perfect Melt."); |
| setTimeout(() => switchMindMode("designer"), 850); |
| }} |
| }} catch (error) {{ |
| appendMindAnswer({{ answer: "I could not reach the MeltMind service just now. Your preferences are still saved, and you can continue in Designer.", products: [], source: "Local fallback" }}); |
| }} |
| }}; |
| |
| const apiDesignerPreferences = () => {{ |
| const selected = [...mindState.preferences]; |
| const groupValue = selected.includes("A group") ? 4 : selected.includes("Two people") ? 2 : 1; |
| const flavourMap = {{ "Deep chocolate": "chocolate", "Pistachio & nutty": "pistachio", "Fruity contrast": "fruity", "Creamy & gentle": "cream" }}; |
| return {{ |
| budget_inr: mindState.budget, |
| group_size: groupValue, |
| flavours: selected.filter(value => flavourMap[value]).map(value => flavourMap[value]), |
| textures: selected.filter(value => ["Fudgy", "Gooey", "Crunchy", "Creamy"].includes(value)).map(value => value.toLowerCase()), |
| premium: selected.includes("Pistachio & nutty") || /premium/i.test(mindState.note), |
| variety: selected.includes("A group") || /variety|different/i.test(mindState.note), |
| less_sweet: /less sweet|not too sweet|balanced sweetness/i.test(mindState.note), |
| request: mindState.note |
| }}; |
| }}; |
| |
| const renderApiDesignerResults = payload => {{ |
| const results = query(".designer-results"); |
| if (!results) return; |
| results.classList.remove("loading"); |
| if (!payload.plans?.length) {{ |
| results.innerHTML = `<div class="designer-results-head"><div><small>We need one careful check</small><h3>No verified plan can be finalized yet</h3></div></div><article class="designer-plan designer-plan-notice"><p>${{safeText(payload.notice || "No current menu plan fits these constraints.")}}</p><div class="plan-foot"><span>Nothing unverified was suggested</span><button data-mind-mode="chat" type="button">Ask MeltMind β</button></div></article>`; |
| results.dataset.plans = "[]"; |
| results.classList.add("visible"); |
| results.scrollIntoView({{ behavior: "smooth", block: "start" }}); |
| return; |
| }} |
| results.innerHTML = `<div class="designer-results-head"><div><small>Your complete MeltRoom directions</small><h3>Choose the plan that feels right</h3></div></div> |
| <div class="designer-plan-grid">${{payload.plans.map((plan, index) => `<article class="designer-plan"> |
| <div class="plan-top"><span>0${{index + 1}}</span><div><small>${{plan.title}}</small><h4>${{money(plan.total)}} total</h4><strong class="plan-featured">${{plan.items.map(item => item.quantity > 1 ? `${{item.quantity}} Γ ${{item.name}}` : item.name).join(" Β· ")}}</strong><em>Approximately ${{plan.servings}} guided servings</em></div></div> |
| <p>${{safeText(plan.summary)}}</p> |
| <div class="plan-items">${{plan.items.map(item => `<div><img src="/${{item.image}}" alt=""><span><strong>${{item.name}}</strong><small>${{item.quantity}} Γ ${{money(item.price)}} Β· ${{item.serves_text}}</small></span><button data-open-product="${{item.id.replaceAll("_", "-")}}" type="button">Details</button></div>`).join("")}}</div> |
| <div class="plan-foot"><span>βΉ${{plan.remaining_budget}} remaining Β· ${{safeText(plan.serving_note)}}</span><button data-add-plan="${{index}}" type="button">Add plan to MeltBasket +</button></div> |
| </article>`).join("")}}</div>`; |
| results.dataset.plans = JSON.stringify(payload.plans); |
| results.classList.add("visible"); |
| updateDesignerProfile(); |
| results.scrollIntoView({{ behavior: "smooth", block: "start" }}); |
| }}; |
| |
| const showDesignerLoading = () => {{ |
| const results = query(".designer-results"); |
| const button = query(".designer-build"); |
| query(".designer-stage")?.classList.add("is-building"); |
| if (button) {{ |
| button.disabled = true; |
| button.classList.add("loading"); |
| button.innerHTML = `Designing your melt <b>Β·Β·Β·</b>`; |
| }} |
| if (!results) return; |
| results.innerHTML = `<div class="designer-loading"> |
| <div class="designer-loading-mark"><span></span><span></span><span></span><b>M</b></div> |
| <small>MeltMind is composing your directions</small> |
| <h3>Balancing the craving,<br>the moment, and your budget.</h3> |
| <div class="designer-loading-steps"><span>Reading your preferences</span><span>Building valid combinations</span><span>Writing three distinct directions</span></div> |
| </div>`; |
| results.classList.add("visible", "loading"); |
| results.scrollIntoView({{ behavior: "smooth", block: "start" }}); |
| }}; |
| |
| const finishDesignerLoading = () => {{ |
| const button = query(".designer-build"); |
| query(".designer-stage")?.classList.remove("is-building"); |
| query(".designer-results")?.classList.remove("loading"); |
| if (button) {{ |
| button.disabled = false; |
| button.classList.remove("loading"); |
| button.innerHTML = `Design my perfect melt <b>β</b>`; |
| }} |
| }}; |
| |
| const buildPerfectMelt = async () => {{ |
| updateDesignerProfile(); |
| showDesignerLoading(); |
| try {{ |
| const response = await fetch("/api/meltmind/design", {{ method: "POST", headers: {{ "Content-Type": "application/json" }}, body: JSON.stringify(apiDesignerPreferences()) }}); |
| if (!response.ok) throw new Error("Designer request failed"); |
| renderApiDesignerResults(await response.json()); |
| }} catch (error) {{ |
| renderDesignerResults(); |
| showToast("Using the local recommendation fallback while the MeltMind service reconnects."); |
| }} finally {{ |
| finishDesignerLoading(); |
| }} |
| }}; |
| |
| document.addEventListener("click", event => {{ |
| if (event.target.closest(".mind-workspace-close")) {{ closeMindWorkspace(); return; }} |
| const mindMode = event.target.closest("[data-mind-mode]"); |
| if (mindMode) {{ switchMindMode(mindMode.dataset.mindMode); return; }} |
| const suggestion = event.target.closest(".mind-suggestion"); |
| if (suggestion) {{ const input = query("#mind-chat-input"); if (input) {{ input.value = suggestion.textContent.trim(); input.focus(); }} return; }} |
| const designerOption = event.target.closest("[data-designer-value]"); |
| if (designerOption) {{ |
| if (["Just me", "Two people", "A group"].includes(designerOption.dataset.designerValue)) {{ |
| queryAll('[data-designer-value="Just me"], [data-designer-value="Two people"], [data-designer-value="A group"]').forEach(button => button.classList.remove("selected")); |
| designerOption.classList.add("selected"); |
| }} else designerOption.classList.toggle("selected"); |
| updateDesignerProfile(); return; |
| }} |
| const chatProduct = event.target.closest("[data-mind-product]"); if (chatProduct) return openChatWithProduct(chatProduct.dataset.mindProduct); |
| const openMindProduct = event.target.closest("[data-open-product]"); if (openMindProduct) return openProduct(openMindProduct.dataset.openProduct); |
| const chatAdd = event.target.closest("[data-chat-add]"); |
| if (chatAdd) {{ changeProduct(chatAdd.dataset.chatAdd, 1, false); animateWorkspaceBasket(); showToast(`${{products.get(chatAdd.dataset.chatAdd).name}} added to the shared MeltBasket.`); return; }} |
| const resultChat = event.target.closest("[data-result-chat]"); if (resultChat) return openChatWithProduct(resultChat.dataset.resultChat); |
| const resultAdd = event.target.closest("[data-result-add]"); |
| if (resultAdd) {{ changeProduct(resultAdd.dataset.resultAdd, 1, false); animateWorkspaceBasket(); showToast(`${{products.get(resultAdd.dataset.resultAdd).name}} added to the shared MeltBasket.`); return; }} |
| const chatToDesigner = event.target.closest(".chat-to-designer"); |
| if (chatToDesigner) {{ const text = query("#mind-chat-input")?.value.trim() || mindState.lastChatText; extractChatPreferences(text); switchMindMode("designer"); return; }} |
| const addPlan = event.target.closest("[data-add-plan]"); |
| if (addPlan) {{ |
| const plans = JSON.parse(query(".designer-results")?.dataset.plans || "[]"); |
| const plan = plans[Number(addPlan.dataset.addPlan)]; |
| plan?.items.forEach(item => {{ for (let index = 0; index < item.quantity; index += 1) changeProduct(item.id.replaceAll("_", "-"), 1, false); }}); |
| animateWorkspaceBasket(); |
| showToast(`${{plan.title}} added to the shared MeltBasket.`); |
| return; |
| }} |
| if (event.target.closest(".mind-send")) {{ askMeltMind(); return; }} |
| if (event.target.closest(".designer-build")) {{ buildPerfectMelt(); return; }} |
| const open = event.target.closest(".open-product"); if (open) return openProduct(open.dataset.productId); |
| const add = event.target.closest(".add-product"); |
| if (add) {{ const action = event.target.closest("[data-action]")?.dataset.action; changeProduct(add.dataset.itemId, action === "minus" ? -1 : 1, !action); return; }} |
| const productAction = event.target.closest("[data-product-action]"); |
| if (productAction) {{ const item = basket.get(productAction.dataset.id); const action = productAction.dataset.productAction; changeProduct(productAction.dataset.id, action === "plus" ? 1 : action === "minus" ? -1 : -(item?.quantity || 1)); return; }} |
| const addon = event.target.closest(".add-addon"); if (addon) return chooseAddonTarget(addon.dataset.addonId); |
| const customize = event.target.closest("[data-customize-product]"); if (customize) return openAddonPicker(customize.dataset.customizeProduct); |
| const pickerAddon = event.target.closest("[data-picker-addon]"); |
| if (pickerAddon) {{ changeAddon(pickerAddon.dataset.productId, pickerAddon.dataset.pickerAddon, 1); openAddonPicker(pickerAddon.dataset.productId, pickerAddon.dataset.pickerAddon); return; }} |
| const target = event.target.closest("[data-addon-target]"); |
| if (target) {{ |
| const product = basket.get(target.dataset.addonTarget); |
| const addOn = addOns.get(target.dataset.addonId); |
| changeAddon(target.dataset.addonTarget, target.dataset.addonId, 1); |
| closeOverlays(); |
| showToast(`${{addOn.name}} added to ${{product.name}}.`); |
| return; |
| }} |
| const removeAddon = event.target.closest("[data-basket-addon-remove]"); |
| if (removeAddon) {{ changeAddon(removeAddon.dataset.productId, removeAddon.dataset.addonId, -(basket.get(removeAddon.dataset.productId)?.addOns[removeAddon.dataset.addonId] || 1)); return; }} |
| if (event.target.closest(".basket-toggle, .floating-basket, .mind-workspace-basket")) return openBasket(); |
| const jump = event.target.closest(".context-jump"); |
| if (jump) {{ query(`#${{jump.dataset.target || "menu"}}`)?.scrollIntoView({{ behavior: "smooth" }}); return; }} |
| if (event.target.closest(".basket-close, .basket-backdrop")) return closeOverlays(); |
| if (event.target.closest(".modal-close, .picker-close, .picker-done")) return closeTopOverlay(); |
| if (event.target.closest(".place-order")) return placeOrder(); |
| const nav = event.target.closest(".mobile-nav-toggle"); |
| if (nav) {{ query(".melt-nav")?.classList.toggle("mobile-open"); return; }} |
| if (event.target.closest(".mobile-nav-panel a")) query(".melt-nav")?.classList.remove("mobile-open"); |
| }}); |
| document.addEventListener("input", event => {{ |
| if (event.target.matches("#designer-budget, #designer-note")) updateDesignerProfile(); |
| }}); |
| document.addEventListener("keydown", event => {{ |
| if (event.target.matches("#mind-chat-input") && event.key === "Enter" && !event.shiftKey) {{ |
| event.preventDefault(); |
| askMeltMind(); |
| }} |
| }}); |
| setTimeout(() => {{ |
| renderBasket(); |
| const observer = new IntersectionObserver(entries => entries.forEach(entry => {{ |
| if (entry.isIntersecting) {{ |
| entry.target.classList.add("in-view"); |
| observer.unobserve(entry.target); |
| }} |
| }}), {{ threshold: .12, rootMargin: "0px 0px -5% 0px" }}); |
| queryAll(".reveal, .menu-chapter").forEach(element => observer.observe(element)); |
| updateJumpButton(); |
| }}, 500); |
| window.addEventListener("scroll", updateJumpButton, {{ passive: true }}); |
| window.addEventListener("resize", updateJumpButton); |
| }})(); |
| </script> |
| """ |
|
|
|
|
| logo = f'<img src="{asset_url(LOGO_PATH)}" alt="MeltRoom logo">' if (ROOT / LOGO_PATH).exists() else "<span>M</span>" |
| hero = f'<img src="{asset_url(HERO_PATH)}" alt="MeltRoom signature dessert">' if (ROOT / HERO_PATH).exists() else "" |
|
|
|
|
| def app_markup() -> str: |
| return f""" |
| <nav class="melt-nav"> |
| <a class="brand" href="#home">{logo}<strong>MeltRoom</strong><small>Dessert studio</small></a> |
| <div class="nav-links"> |
| <a href="#menu"><small>Discover</small><strong>Menu & add-ons</strong></a> |
| <a href="#meltmind"><small>Personalize</small><strong>Ask MeltMind</strong></a> |
| <a href="#ingredients"><small>Our craft</small><strong>Ingredients</strong></a> |
| <a href="#ordering"><small>Your journey</small><strong>How to order</strong></a> |
| <a href="#contact"><small>Stay close</small><strong>Social & support</strong></a> |
| </div> |
| <div class="nav-actions"><button class="basket-toggle" type="button">MeltBasket <span class="basket-count">0</span></button><button class="mobile-nav-toggle" type="button"><i></i><i></i></button></div> |
| <div class="mobile-nav-panel"> |
| <a href="#menu"><small>Discover</small><strong>Menu & add-ons</strong></a> |
| <a href="#meltmind"><small>Personalize</small><strong>Ask MeltMind</strong></a> |
| <a href="#ingredients"><small>Our craft</small><strong>Ingredients</strong></a> |
| <a href="#ordering"><small>Your journey</small><strong>How to order</strong></a> |
| <a href="#contact"><small>Stay close</small><strong>Social & support</strong></a> |
| </div> |
| </nav> |
| <div class="basket-backdrop"></div> |
| <aside class="melt-basket"> |
| <div class="basket-head"><div><p>Your order</p><h2>MeltBasket</h2></div><button class="basket-close" type="button">Γ</button></div> |
| <div class="basket-empty"><span>Start with a craving.</span><p>Your selected melts and finishing touches will live here.</p></div> |
| <div class="basket-items"></div> |
| <div class="basket-checkout"> |
| <div class="fulfilment"><label><input type="radio" name="fulfilment" value="Delivery through Rapido" checked><span>Rapido delivery<small>Charge confirmed in chat</small></span></label><label><input type="radio" name="fulfilment" value="Pickup from KLN Reddy Colony"><span>Cloud-kitchen pickup<small>Avoid delivery charges</small></span></label></div> |
| <div class="basket-subtotal"><span>Menu subtotal<small>Delivery is calculated separately</small></span><strong class="basket-total">βΉ0</strong></div> |
| <button class="place-order" type="button" disabled>Place order on WhatsApp <b>β</b></button> |
| <p>Mobile-number authentication will be added at this final step. Until then, WhatsApp confirms customer identity.</p> |
| </div> |
| </aside> |
| <button class="context-jump" data-target="menu" type="button"><span><i>β</i></span><strong>Back to menu</strong><small>Choose another chapter</small></button> |
| <button class="floating-basket" type="button"><span>M</span><strong>MeltBasket<small class="basket-total">βΉ0</small></strong><b class="floating-basket-count">0</b></button> |
| <div class="product-modal"></div> |
| <aside class="addon-picker"></aside> |
| <div class="melt-toast"></div> |
| |
| <section id="home" class="hero"> |
| <div class="hero-copy"> |
| <p class="eyebrow">MeltRoom Β· Signature dessert studio</p> |
| <h1>Not just dessert.<br><em>Your kind of melt.</em></h1> |
| <p>Gluten-free, eggless brownie experiences built around rich chocolate, playful formats, and the mood you arrived with.</p> |
| <div class="hero-actions"><a href="#menu">Explore the collection <b>β</b></a><a href="#meltmind">Meet MeltMind <b>β</b></a></div> |
| <div class="hero-signals"><span><b>18</b> signature creations</span><span><b>4</b> dessert chapters</span><span><b>100%</b> gluten-free brownies</span></div> |
| </div> |
| <div class="hero-art"> |
| <div class="hero-image-wrap"><div class="hero-image">{hero}</div></div> |
| </div> |
| </section> |
| <section class="ticker"><div><span>Made for cravings worth remembering</span><b>β¦</b><span>Signature brownie bowls</span><b>β¦</b><span>Playful melts, serious chocolate</span><b>β¦</b><span>Premium ingredients, beautifully layered</span><b>β¦</b><span>Your moment, your perfect melt</span><b>β¦</b><span>Made for cravings worth remembering</span><b>β¦</b><span>Signature brownie bowls</span><b>β¦</b><span>Playful melts, serious chocolate</span><b>β¦</b><span>Premium ingredients, beautifully layered</span><b>β¦</b><span>Your moment, your perfect melt</span><b>β¦</b></div></section> |
| {menu_markup()} |
| {add_on_markup()} |
| {meltmind_markup()} |
| {ingredient_markup()} |
| <section id="ordering" class="ordering-section order-journey"> |
| <div class="ordering-head reveal"><p class="eyebrow">Your journey through this experience</p><h2>From first craving<br>to final melt.</h2><p>Everything happens in one clear flow: discover, understand, customize, confirm, and receive.</p></div> |
| <div class="ordering-grid journey-grid"> |
| <article class="reveal"><span>01</span><small>Discover</small><h3>Explore the four menu chapters</h3><p>Open product cards to understand flavour, texture, serving guidance, and price before choosing.</p><a href="#menu">Explore menu β</a></article> |
| <article class="reveal"><span>02</span><small>Get guidance</small><h3>Ask MeltMind or design your melt</h3><p>Ask questions naturally, compare options, or create a complete plan around your craving, people, and budget.</p><a href="#meltmind">Meet MeltMind β</a></article> |
| <article class="reveal"><span>03</span><small>Customize</small><h3>Build your MeltBasket</h3><p>Add products, adjust quantities, and attach compatible finishing touches to the exact dessert you want.</p><button class="basket-toggle" type="button">Open MeltBasket β</button></article> |
| <article class="reveal"><span>04</span><small>Confirm</small><h3>Send the prepared order on WhatsApp</h3><p>Place Order creates a clean WhatsApp message. MeltRoom reviews availability and shares the owner's payment QR.</p></article> |
| <article class="reveal"><span>05</span><small>Receive</small><h3>Payment confirms your order</h3><p>After payment, MeltRoom updates the order and delivery status. Choose Rapido delivery or pickup near KLN Reddy Colony.</p></article> |
| </div> |
| <div class="policy-strip reveal"><span><b>Regular orders</b>Generally targeted within 45 minutes after confirmation.</span><span><b>Delivery charges</b>Rapido charge is extra. Above βΉ100, MeltRoom contributes 40%.</span><span><b>Large orders</b>Discuss approximately 5β6 hours ahead.</span><span><b>Best taste</b>Enjoy within 60 minutes or refrigerate and gently reheat.</span></div> |
| </section> |
| |
| <section id="contact" class="contact-section social-section"> |
| <div class="social-lead reveal"><p class="eyebrow">Stay close to MeltRoom</p><h2>See the melts.<br>Share the love.</h2><p>Follow new creations and behind-the-scenes moments, leave a Google review after your order, or speak directly with MeltRoom for support and custom requests.</p><div class="social-proof"><span><b>Warangal</b>Handcrafted locally</span><span><b>Direct support</b>A real person confirms every order</span></div></div> |
| <div class="contact-links social-links"> |
| <a class="social-card instagram-card reveal" href="https://www.instagram.com/meltroom.warangal/" target="_blank"><span><i>IG</i> Follow the studio</span><strong>@meltroom.warangal</strong><p>Discover new drops, signature melts, customer moments, and custom creations.</p><b>Open Instagram β</b></a> |
| <a class="social-card review-card reveal" href="https://g.page/r/CW-GZTL2bFinEBM/review" target="_blank"><span><i>β
</i> Share your experience</span><strong>Review MeltRoom on Google</strong><p>Your honest review helps a local dessert studio grow and helps new customers discover us.</p><b>Write a review β</b></a> |
| <a class="social-card whatsapp-card reveal" href="https://wa.me/{WHATSAPP_NUMBER}" target="_blank"><span><i>WA</i> Orders & support</span><strong>WhatsApp MeltRoom</strong><p>Confirm orders, discuss custom cakes, allergies, payments, delivery, or any issue.</p><b>Start a conversation β</b></a> |
| <a class="social-card call-card reveal" href="tel:+917780534935"><span><i>β</i> Speak directly</span><strong>+91 77805 34935</strong><p>Reach MeltRoom directly when a conversation is easier than a message.</p><b>Call MeltRoom β</b></a> |
| </div> |
| </section> |
| <footer><strong>MeltRoom Γ MeltMind AI</strong><span>Gluten-free brownies. Guilt-free indulgence. Thoughtfully yours.</span></footer> |
| """ |
|
|
|
|
| def html_page() -> str: |
| return f"""<!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <title>MeltRoom Β· MeltMind AI</title> |
| {HEAD} |
| <style>{CSS}</style> |
| </head> |
| <body class="meltmind-app"> |
| {app_markup()} |
| </body> |
| </html>""" |
|
|
|
|
| with gr.Blocks(title="MeltRoom Β· MeltMind AI", css=CSS, head=HEAD, theme=gr.themes.Base(), elem_classes="meltmind-app") as demo: |
| gr.HTML(app_markup(), container=False) |
|
|
|
|
| web_app = FastAPI(title="MeltMind AI") |
| web_app.mount("/assets", StaticFiles(directory=ROOT / "assets"), name="assets") |
|
|
|
|
| @web_app.get("/", response_class=HTMLResponse) |
| def meltmind_home(): |
| return HTMLResponse(html_page()) |
|
|
|
|
| @web_app.post("/api/meltmind/chat") |
| def meltmind_chat(request: ChatRequest): |
| return ENGINE.chat(request.message, request.history) |
|
|
|
|
| @web_app.post("/api/meltmind/design") |
| def design_perfect_melt(request: DesignerRequest): |
| return ENGINE.design(request.model_dump()) |
|
|
|
|
| @web_app.get("/api/meltmind/model") |
| def meltmind_model_status(): |
| return {**ENGINE.runtime_status(check_connection=True), "backend": backend_status()} |
|
|
|
|
| web_app = gr.mount_gradio_app(web_app, demo, path="/gradio", allowed_paths=[str(ROOT / "assets")]) |
|
|
|
|
| if __name__ == "__main__": |
| uvicorn.run(web_app, host="0.0.0.0", port=int(os.getenv("GRADIO_SERVER_PORT", "7860"))) |
|
|