// src/store.jsx — Store menu (the hero screen). // Supports 4 category-nav patterns via tweak: 'tabs' | 'rail' | 'sheet' | 'aisle'. // 'aisle' drops the nav chrome entirely for a long-scroll walk-the-store feel. // Always: sticky storefront header, delivery/pickup toggle, category sections, density-aware product grid. // Store-specific mock content — distinct flavor per store so the editorial lede // and orders-near ticker don't feel generic. Keyed by store.id. const STORE_VOICE = { kalymnos: { lede: { pull: "The same 8 AM pastry shipment for thirty years. That's not tradition, it's a handshake.", body: "Maria's been running Kalymnos since her father shoved a knife into her hand in '92. If it's on the shelf, it sold last week.", author: "Staff of Kalymnos", }, orders: { count: 142, window: "in the last hour", recent: ["Modelo 12pk", "BEC on a roll", "Topo Chico"] }, }, liu: { lede: { pull: "The sign says 24hr. The sign has always said 24hr.", body: "Liu's is the block's power grid. If the lights are out somewhere else, they're on here.", author: "Liu's 24hr", }, orders: { count: 58, window: "in the last hour", recent: ["Liquid Death", "Red Bull 12oz", "Advil"] }, }, arbor: { lede: { pull: "Real hammers. Real level. No fruit.", body: "Arbor has been the hardware answer for three generations of Greenpoint renters. They know what drywall you have before you do.", author: "Arbor Hardware", }, orders: { count: 17, window: "this morning", recent: ["Picture hooks", "Drywall anchors", "Painter's tape"] }, }, fig: { lede: { pull: "A wall of under-$22 bottles worth fighting about.", body: "Cobble Hill's wine counter where the staff will argue with you about the Pinot you just pulled off the shelf, and be right.", author: "Fig & Thistle", }, orders: { count: 34, window: "in the last hour", recent: ["Meiomi Pinot", "Whispering Angel", "White Claw 12"] }, }, elmo: { lede: { pull: "We brined 200 thighs this morning. You are not going to eat them all.", body: "Elmo's Chicken & Fixins runs on a Tuesday dinner rush that has to be seen to be believed. The fryer doesn't stop until the thighs do.", author: "Elmo's", }, orders: { count: 96, window: "in the last hour", recent: ["Fried chicken plate", "Collards", "Sweet tea"] }, }, verde: { lede: { pull: "Orchard to your bag in 14 hours. We timed it.", body: "Verde pulls from three Hudson Valley farms. The produce you pick today was on a branch yesterday.", author: "Verde Corner Produce", }, orders: { count: 41, window: "in the last hour", recent: ["Honeycrisp apples", "Vine tomatoes", "Baby spinach"] }, }, }; // Editorial pull-quote — distinct shape from product cards: no image, big serif text // with a vertical accent rule and small attribution. function EditorialLede({ voice }) { if (!voice) return null; return (
“{voice.pull}”

{voice.body}

— {voice.author}
); } // Horizontal "who's ordering right now" ticker — dots + recent item names. // Distinct shape from the pill toggles and cards: a thin rail with pulsing dot. function OrdersNearby({ voice }) { if (!voice) return null; return (
{voice.count} orders {voice.window} ·
{voice.recent.map(item => ( {item} ))}
); } // Deals strip — small horizontal ticket-style cards, visually distinct from the // larger ProductCard grid that dominates the rest of the page. One deal per // scroll-column. Container respects its parent column width (no negative // bleed margins) so it can't push the page into horizontal scroll. function DealsStrip({ deals, onOpen, onAdd, getQty }) { if (!deals.length) return null; return (
Deals this week
{deals.length} on
{deals.map(p => (
{p.deal}
{p.name}
{money(p.price)}
))}
); } // Large prominent pickup-ready banner — visible when mode is "pickup". Different // shape from the PickupBanner on home: full-bleed green stripe with timer dot. function PickupReadyChip({ store }) { return (
{store.pickupEta || "Ready in 8 min"} We'll text you when your bag's packed. Show your name at the counter.
); } function StoreHeader({ store, mode, setMode, onNav }) { const isMobile = useMedia("(max-width: 720px)"); return (
{/* Hero */}
{(() => { const si = storeImage(store); if (!si) return {store.hero}; const src = si.url || `https://loremflickr.com/1600/600/${encodeURIComponent(si.keyword)}/all?lock=${encodeURIComponent(store.id)}`; return ( <> {store.hero} { e.currentTarget.style.display = 'none'; }} />
); })()}
{/* back button */}
{/* Store info card — overlapping */}

{store.name}

}>Open · til 11p
{store.tagline}
· {store.distance} · {store.minOrder}
{store.tags.map(t => {t})}
{/* Delivery/pickup toggle */}
{[ { id: "delivery", label: "Delivery", icon: "Truck", eta: store.eta }, { id: "pickup", label: "Pickup", icon: "Store", eta: store.pickupEta || "Ready in 8 min" }, ].map(m => { const Icon = Icons[m.icon]; const active = mode === m.id; return ( ); })}
{store.fee} · {store.hours}
); } // Category nav — three variants function CategoryTabs({ categories, active, onSelect, sticky = true }) { const isMobile = useMedia("(max-width: 720px)"); return (
{categories.map(c => { const Icon = Icons[c.icon]; const isActive = active === c.id; return ( ); })}
); } function CategoryRail({ categories, active, onSelect }) { const [collapsed, setCollapsed] = React.useState(false); return ( ); } // Product card — larger version with "customize" badge if relevant function ProductCard({ p, qty, onOpen, onAdd, onRemove, pattern = "morph", density = "regular" }) { const compact = density === "compact"; return (
{ e.currentTarget.style.boxShadow = "var(--shadow-md)"; }} onMouseLeave={(e) => { e.currentTarget.style.boxShadow = "none"; }}>
{p.name}
{/* Meta row — always reserves one line so customizable / stock / unit variations can't push the card taller than its neighbors. */}
{p.stock ? ( {p.stock} ) : ( {p.unit} )}
{money(p.price)} {p.customizable ? ( ) : ( )}
); } // ─── CatalogSlider ───────────────────────────────────────────────────────── // Netflix-style horizontal row of ProductCards for a single category. Each // category on the store page renders one of these. Desktop gets chevron // controls; mobile relies on swipe + snap. function CatalogSlider({ products, cardWidth, isMobile, getQty, onOpen, onAdd, onRemove, pattern, density }) { const ref = React.useRef(null); const [canLeft, setCanLeft] = React.useState(false); const [canRight, setCanRight] = React.useState(true); const update = React.useCallback(() => { const el = ref.current; if (!el) return; setCanLeft(el.scrollLeft > 4); setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4); }, []); React.useEffect(() => { const el = ref.current; if (!el) return; update(); el.addEventListener("scroll", update, { passive: true }); window.addEventListener("resize", update); return () => { el.removeEventListener("scroll", update); window.removeEventListener("resize", update); }; }, [update]); const step = (dir) => { const el = ref.current; if (!el) return; el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: "smooth" }); }; return (
{products.map(p => (
onOpen(p.id)} onAdd={() => onAdd(p)} onRemove={() => onRemove(p.id)} pattern={pattern} density={density} />
))}
{!isMobile && (canLeft || canRight) && ( <> {canLeft && ( )} {canRight && ( )} )}
); } function GoesWithStrip({ anchorId, addToCart, onOpen }) { const ids = GOES_WITH[anchorId]; if (!ids) return null; const items = ids.map(i => PRODUCT_INDEX[i]).filter(Boolean); const anchor = PRODUCT_INDEX[anchorId]; return (
Goes with {anchor.name}
{items.map(p => ( ))}
); } function Store({ storeId, onNav, cart, addToCart, tweaks, initialCategory }) { const store = STORES.find(s => s.id === storeId) || STORES[0]; const voice = STORE_VOICE[store.id]; const [mode, setMode] = React.useState("delivery"); const [active, setActive] = React.useState(initialCategory || "featured"); const isMobile = useMedia("(max-width: 720px)"); const sectionRefs = React.useRef({}); const aisleMode = tweaks.categoryNav === "aisle"; // Group products by category const byCategory = React.useMemo(() => { const g = {}; PRODUCTS.forEach(p => { (g[p.category] = g[p.category] || []).push(p); }); return g; }, []); const featured = PRODUCTS.filter(p => p.popular || p.trending).slice(0, 8); const deals = PRODUCTS.filter(p => p.deal).slice(0, 8); const cols = isMobile ? (tweaks.density === "compact" ? 3 : 2) : (tweaks.density === "compact" ? 6 : tweaks.density === "comfy" ? 4 : 5); // Card width for horizontal category sliders. Scales with density on desktop, // shrinks on mobile so ~2.2 cards are visible and the next peeks in. const cardWidth = isMobile ? (tweaks.density === "compact" ? 132 : tweaks.density === "comfy" ? 180 : 160) : (tweaks.density === "compact" ? 168 : tweaks.density === "comfy" ? 252 : 210); // Scroll-spy: when user scrolls, update the active category React.useEffect(() => { const onScroll = () => { const scrollY = window.scrollY + 200; let current = active; for (const c of CATEGORIES) { const el = sectionRefs.current[c.id]; if (el && el.offsetTop <= scrollY) current = c.id; } if (current !== active) setActive(current); }; window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [active]); const goCategory = (id) => { setActive(id); const el = sectionRefs.current[id]; if (el) window.scrollTo({ top: el.offsetTop - 160, behavior: "smooth" }); }; const getQty = (pid) => cart.items.find(i => i.productId === pid)?.qty || 0; const renderSection = (cat, idx) => { const list = cat.id === "featured" ? featured : (byCategory[cat.id] || []); if (list.length === 0) return null; const Icon = Icons[cat.icon]; // In "aisle" mode, render each category as a walk-the-store banner with a big // aisle number instead of a tight pill header. return (
sectionRefs.current[cat.id] = el} style={{ scrollMarginTop: 160, paddingTop: 12 }} data-screen-label={`Aisle ${cat.label}`}> {aisleMode ? (
Aisle {String(idx + 1).padStart(2, "0")}

{cat.label}

{list.length} items {cat.id !== "featured" && ( )}
) : (

{cat.label}

{list.length}
{cat.id !== "featured" && ( )}
)} onNav({ screen: "product", storeId: store.id, productId: pid })} onAdd={(p) => p.customizable ? onNav({ screen: "product", storeId: store.id, productId: p.id }) : addToCart(store.id, p.id, 1)} onRemove={(pid) => addToCart(store.id, pid, -1)} pattern={tweaks.addPattern} density={tweaks.density} /> {/* Contextual "goes with" strip after beer category — only for desktop and when something's in cart */} {cat.id === "beer" && cart.items.some(i => i.productId === "bw-modelo") && (
addToCart(store.id, pid, 1)} onOpen={(pid) => onNav({ screen: "product", storeId: store.id, productId: pid })} />
)}
); }; return (
{tweaks.categoryNav === "tabs" && ( )}
{tweaks.categoryNav === "rail" && !isMobile && ( )}
{/* Orders ticker — social proof that this store is alive right now */} {voice && } {/* Prominent pickup-ready chip when user has switched to pickup */} {mode === "pickup" && } {/* Lede + Deals — stacked on mobile, 40/60 side-by-side on desktop. Both cells get min-width:0 so the deals scroller can't push the lede column out past the container on wide screens. */}
{voice && }
onNav({ screen: "product", storeId: store.id, productId: pid })} onAdd={(pid) => addToCart(store.id, pid, 1)} />
{CATEGORIES.map((c, i) => renderSection(c, i))} {/* End-of-store warm close — distinct shape from HomeFarewell: this one's a warm postcard/note style with the store owner's voice, not the navy gradient hero the home page uses. */}
{/* Mobile bottom-sheet nav */} {tweaks.categoryNav === "sheet" && isMobile && ( )}
); } function BottomSheetNav({ categories, active, onSelect }) { const [open, setOpen] = React.useState(false); const activeCat = categories.find(c => c.id === active) || categories[0]; const ActiveIcon = Icons[activeCat.icon]; return ( <> {open && (
setOpen(false)}>
e.stopPropagation()} style={{ position: "absolute", bottom: 0, left: 0, right: 0, background: "var(--surface)", borderRadius: "20px 20px 0 0", padding: "14px 16px calc(20px + env(safe-area-inset-bottom))" }}>
Jump to aisle
{categories.map(c => { const Icon = Icons[c.icon]; const a = c.id === active; return ( ); })}
)} ); } // ─── StoreFarewell ───────────────────────────────────────────────────────── // End-of-store close. Warm postcard/hand-drawn feel with a tiny store map // pin glyph, the store's own voice, and two CTAs: Ask Opa and Try nearby. // Intentionally quieter than the HomeFarewell navy gradient — this is a // pause, not a pitch. function StoreFarewell({ store, voice, onNav, isMobile }) { const nearby = STORES.filter(s => s.id !== store.id).slice(0, 3); const firstName = (voice?.lede?.author || store.name).split(/[,\s]/)[0]; return (
{/* Left: tactile "aisle end" stamp band */} {/* Right: store voice + CTAs */}

Didn't see what you came for? {firstName} keeps more behind the counter than what's on the shelves. Tell Opa what you need — or hop to a nearby store that stocks it.

{nearby.length > 0 && (
Try nearby
{nearby.map(s => ( ))}
)}
); } window.Store = Store; window.ProductCard = ProductCard; window.CatalogSlider = CatalogSlider; window.StoreFarewell = StoreFarewell;