meltmind-ai / app.py
Haricharan Vallem
Codex: fix Space blank screen and add hackathon tags
202cfb1
Raw
History Blame Contribute Delete
68.1 kB
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 => ({{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}})[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")))