// src/product.jsx — Product detail with customization.
// Customize pattern variants: 'modal' | 'sheet' | 'inline' (controlled via tweaks).
// For the sandwich builder, uses DELI_BUILDER spec. For non-customizable products, shows a simpler detail.
function Price({ value, size = 22 }) {
return {money(value)};
}
function BuilderPanel({ builder, value, setValue, helpMode }) {
const isMobile = useMedia("(max-width: 720px)");
// value = { bread: 'hero', protein: 'turkey', ... , qty: 1, notes: '' }
const toggleMulti = (sec, id) => {
const arr = value[sec.id] || [];
if (arr.includes(id)) setValue({ ...value, [sec.id]: arr.filter(x => x !== id) });
else if (!sec.max || arr.length < sec.max) setValue({ ...value, [sec.id]: [...arr, id] });
};
const setSingle = (secId, id) => setValue({ ...value, [secId]: id });
// Accordion: single-open. Click to open replaces, click open header to close.
const firstRequired = builder.sections.find(s => s.required)?.id;
const [openId, setOpenId] = React.useState(firstRequired || null);
const toggleOpen = (id) => setOpenId(prev => prev === id ? null : id);
// Summary of the current selection in a section — shown when it's collapsed.
const summarize = (sec) => {
if (sec.multi) {
const ids = value[sec.id] || [];
if (ids.length === 0) return "None";
return ids.map(id => sec.options.find(o => o.id === id)?.label).filter(Boolean).join(", ");
}
const v = value[sec.id];
if (!v) return "—";
return sec.options.find(o => o.id === v)?.label || "—";
};
return (
{builder.sections.map(sec => {
const isOpen = openId === sec.id;
const recommended = helpMode && sec.options.find(o => o.popular);
return (
{isOpen && (
{sec.options.map(opt => {
const selected = sec.multi
? (value[sec.id] || []).includes(opt.id)
: value[sec.id] === opt.id;
const isRec = helpMode && recommended && opt.id === recommended.id && !selected;
return (
);
})}
)}
);
})}
{/* AI notes field — always visible, below the accordion */}
Anything else?
AI understands "make it spicy, no mayo"
);
}
function computePrice(p, builderValue) {
if (!p.customizable) return p.price;
const b = DELI_BUILDER;
let sum = b.basePrice;
for (const sec of b.sections) {
const v = builderValue[sec.id];
if (sec.multi) {
(v || []).forEach(id => { const o = sec.options.find(o => o.id === id); if (o) sum += o.price; });
} else {
const o = sec.options.find(o => o.id === v);
if (o) sum += o.price;
}
}
return Math.max(0, sum);
}
function defaultBuilderValue() {
return {
bread: "hero", protein: "turkey", cheese: "provolone",
toppings: ["lettuce", "tomato"], sauce: ["mayo"],
prep: "cold", qty: 1, notes: "",
};
}
function Product({ storeId, productId, onNav, cart, addToCart, tweaks, showToast }) {
const isMobile = useMedia("(max-width: 720px)");
const p = PRODUCT_INDEX[productId] || PRODUCT_INDEX["deli-custom"];
const store = STORES.find(s => s.id === storeId) || STORES[0];
const [bv, setBv] = React.useState(defaultBuilderValue);
const [qty, setQty] = React.useState(1);
const [showCustomize, setShowCustomize] = React.useState(false); // used for modal / sheet variants
const [helpMode, setHelpMode] = React.useState(false);
const isCustom = !!p.customizable;
// Toggling "Help me choose" populates the popular / first-option pick for every
// section at once, so it's a real "do it for me" action not just a subtle badge.
const applyOpaPicks = () => {
if (!isCustom) return;
const next = { ...bv };
for (const sec of DELI_BUILDER.sections) {
const pick = sec.options.find(o => o.popular) || sec.options[0];
if (!pick) continue;
if (sec.multi) {
const current = next[sec.id] || [];
const already = current.includes(pick.id);
if (!already) {
const max = sec.max || Infinity;
next[sec.id] = current.length >= max ? [pick.id, ...current.slice(0, max - 1)] : [...current, pick.id];
}
} else {
next[sec.id] = pick.id;
}
}
setBv(next);
};
const toggleHelp = () => {
setHelpMode(v => {
const nextOn = !v;
if (nextOn) applyOpaPicks();
return nextOn;
});
};
// Pattern was a tweak with inline/modal/sheet variants. For the demo we've
// committed to inline — the modal/sheet branches below are kept in source
// as a fallback if future product types need overlay behavior, but no UI
// exposes a way to switch them.
const pattern = "inline";
const unitPrice = computePrice(p, bv);
const total = unitPrice * qty;
const onAdd = () => {
addToCart(storeId, p.id, qty);
showToast(`Added ${qty} × ${p.name}`);
onNav({ screen: "store", storeId });
};
const builderInline = isCustom;
const builderModal = false;
const builderSheet = false;
return (
{/* Breadcrumb + back */}
{p.name}
{/* Left: media + facts */}
{!isMobile && (
From {store.name}
{store.eta}
{store.distance}
Fresh-made daily
)}
{/* Right: product content */}
{p.popular &&
} style={{ marginBottom: 10 }}>Popular at {store.name}}
{p.name}
·
{p.unit}
{isCustom && <>
·Fully customizable>}
{p.desc &&
{p.desc}
}
{builderInline && (
Build your sandwich
)}
{(builderModal || builderSheet) && (
}
onClick={() => setShowCustomize(true)}>
Customize this sandwich
)}
{/* Qty + add */}
{qty}
Add {qty} to cart · {money(total)}
{/* Goes with */}
{GOES_WITH[p.id] && (
addToCart(storeId, pid, 1)}
onOpen={(pid) => onNav({ screen: "product", storeId, productId: pid })} />
)}
{/* Modal variant */}
{builderModal && showCustomize && (
setShowCustomize(false)}>
e.stopPropagation()}
style={{ background: "var(--surface)", borderRadius: "var(--radius-lg)", padding: 24, maxWidth: 640, width: "100%", maxHeight: "85vh", overflow: "auto", boxShadow: "var(--shadow-lg)" }}>
Customize
setShowCustomize(false)}>
Save · {money(unitPrice)}
)}
{/* Side drawer variant (from the right) */}
{builderSheet && showCustomize && (
setShowCustomize(false)}>
e.stopPropagation()}
style={{ position: "absolute", top: 0, right: 0, bottom: 0, width: "min(480px, 92vw)",
background: "var(--surface)", boxShadow: "-12px 0 40px rgba(0,0,0,0.18)",
padding: "18px 20px 20px",
display: "flex", flexDirection: "column", overflow: "hidden" }}>
Customize
setShowCustomize(false)} style={{ marginTop: 16, flexShrink: 0 }}>
Save · {money(unitPrice)}
)}
);
}
window.Product = Product;