// src/home.jsx — Home screen. // Novel moment: "Ask Opa" — an AI command bar with suggested prompts that compose a mini-cart across stores. // Also: hero store tiles, quick-start picks, trending, and a bodega-aware "what's open right now" rail. // AskResultCard — cross-store results grouped by store. Each cluster renders its // store chip (primary gets a "Best match" badge) followed by product tiles that // each label which store they came from. function AskResultCard({ answer, onNav, addToCart, onAddAll, onClose }) { if (!answer) return null; // Normalize legacy {store, items} shape into {clusters: [...]}. const clusters = answer.clusters || [{ store: answer.store, items: answer.items, primary: true }]; const flat = clusters.flatMap(c => c.items.map(id => ({ storeId: c.store.id, productId: id }))); const totalCount = flat.length; const { reason } = answer; return (
Opa picked · {totalCount} items {clusters.length > 1 && ( · {clusters.length} stores )}

{reason}

{clusters.map((cluster, ci) => { const products = cluster.items.map(id => PRODUCT_INDEX[id]).filter(Boolean); return ( {/* Store chip — primary gets a "Best match" badge */} {/* Product tiles for this cluster */} {products.map(p => (
{cluster.store.name.split(" ")[0]}
{p.name}
{money(p.price)}
))}
); })}
); } function AskOpa({ onNav, onAddMany, addToCart, answer, setAnswer }) { const [q, setQ] = React.useState(""); const [state, setState] = React.useState("idle"); // idle | thinking | answer const inputRef = React.useRef(null); const suggestions = [ { label: "Hangover stuff under $20", q: "i need hangover stuff under $20 from a place nearby" }, { label: "Something for pasta night", q: "pasta night for 4, under $40, from one store" }, { label: "Game-day snacks + a 12-pack", q: "game-day snacks plus a 12-pack of modelo" }, { label: "Tools to hang a picture", q: "what do i need to hang 3 frames on drywall" }, ]; const respond = async (query) => { setQ(query); setState("thinking"); // Mocked response — in a real build this would route to claude.complete with a tool that returns SKU ids. // Simulate latency. await new Promise(r => setTimeout(r, 900)); const storeOf = (id) => STORES.find(s => s.id === id); let picks; if (/hangover|tired|headache/i.test(query)) { picks = { clusters: [ { store: storeOf("kalymnos"), primary: true, items: ["drk-gatorade", "deli-eggcheese", "drk-poland", "drk-ouj", "snk-peanuts", "cof-coldbrew"] }, { store: storeOf("liu"), items: ["hh-advil", "drk-celsius"] } ], reason: "Advil + Gatorade + a Bacon-Egg-Cheese for the reset, then water, OJ, a cold brew, and shells to break. Liu's is the only late-night nearby that stocks Celsius." }; } else if (/pasta/i.test(query)) { picks = { clusters: [ { store: storeOf("kalymnos"), primary: true, items: ["pan-pasta", "pan-olive", "pan-butter", "prd-tomato", "deli-italian", "snk-oreo"] }, { store: storeOf("fig"), items: ["bw-meiomi", "drk-topo"] } ], reason: "Rao's jar, good olive oil, Kerrygold, vine tomatoes, a backup hero, and Oreos for after. Fig & Thistle pulls the Pinot because their wall is better." }; } else if (/game|super bowl|modelo|beer/i.test(query)) { picks = { clusters: [ { store: storeOf("kalymnos"), primary: true, items: ["bw-modelo", "snk-doritos", "snk-flamin", "snk-peanuts", "snk-popchips", "drk-topo", "deli-custom"] }, { store: storeOf("fig"), items: ["bw-whiteclaw"] } ], reason: "Modelo 12-pack, four bags that matter, sparkling water, and a build-your-own hero for halftime. White Claw plan B from Fig & Thistle." }; } else if (/hang|picture|frame|drywall|tool|hammer/i.test(query)) { picks = { clusters: [ { store: storeOf("arbor"), primary: true, items: ["hw-hammer", "hw-hooks", "hw-anchors", "hw-level", "hw-pencil"] }, { store: storeOf("kalymnos"), items: ["hw-tape", "hh-lightbulb"] } ], reason: "Arbor carries the hammer, picture hooks, drywall anchors, level, and pencil. Kalymnos is the corner-shop grab for painter's tape and a fresh bulb." }; } else if (/dinner/i.test(query)) { picks = { clusters: [ { store: storeOf("kalymnos"), primary: true, items: ["pan-pasta", "pan-olive", "prd-tomato", "pan-butter", "bw-meiomi", "snk-oreo"] } ], reason: "Marinara, olive oil, vine tomatoes, butter, a decent Pinot, and cookies." }; } else { picks = { clusters: [ { store: storeOf("kalymnos"), primary: true, items: ["snk-popchips", "snk-oreo", "bw-meiomi", "drk-topo", "snk-peanuts", "cof-coldbrew", "drk-celsius"] } ], reason: "Movie-night default — Kalymnos carries all of it." }; } setAnswer(picks); setState("answer"); }; return (
{/* decorative — stronger warm accent in dark mode */}
{/* hairline warm sheen along the top edge */}
Ask Opa · new

Tell us what you need.
We'll read the aisles.

3,500 local stores. One sentence. We'll build the cart — you just check it.

setQ(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && q.trim()) respond(q); }} placeholder="a hangover kit · pasta night for 4 · supplies for the new puppy…" style={{ flex: 1, height: 48, background: "transparent", border: 0, outline: 0, color: "#fff", fontSize: 15, fontFamily: "inherit", }} />
{suggestions.map(s => ( ))}
{state === "thinking" && (
Reading aisles at nearby stores…
)}
); } function StoreCard({ store, onOpen, big }) { return ( ); } // Mocked order-history + saved lists, distinct visual shape from product grids. // Two tabs in one strip: "Buy again" is timestamped item tiles; "Your usuals" // shows multi-thumbnail list cards with a rebuild-cart CTA. const BUY_AGAIN = [ { id: "cof-coldbrew", storeId: "kalymnos", last: "Tue · 2 days ago" }, { id: "deli-italian", storeId: "kalymnos", last: "Mon · 3 days ago" }, { id: "drk-topo", storeId: "kalymnos", last: "Fri · last week" }, { id: "snk-oreo", storeId: "kalymnos", last: "Thu · last week" }, { id: "hh-advil", storeId: "liu", last: "Sun · 5 days ago" }, { id: "bw-modelo", storeId: "kalymnos", last: "Sat · last week" }, { id: "pan-pasta", storeId: "kalymnos", last: "Wed · last week" }, { id: "drk-celsius", storeId: "liu", last: "Mon · last week" }, ]; const USUALS = [ { id: "sunday-am", label: "Sunday breakfast", subtitle: "Opa keeps this list warm.", items: ["deli-eggcheese", "cof-latte", "drk-ouj", "snk-popchips"], storeId: "kalymnos" }, { id: "pantry-reup", label: "Pantry re-up", subtitle: "Every other Tuesday, like clockwork.", items: ["pan-pasta", "pan-olive", "pan-butter", "hh-tp", "hh-ptowel", "hh-tide"], storeId: "kalymnos" }, { id: "wed-lunch", label: "Wednesday lunch", subtitle: "Italian combo, chips, topo.", items: ["deli-italian", "snk-flamin", "drk-topo"], storeId: "kalymnos" }, ]; function PantryStrip({ onNav, addToCart, addMany, isMobile }) { const [tab, setTab] = React.useState("again"); return (
Your pantry

Pick up where you left off.

{[ { id: "again", label: `Buy again · ${BUY_AGAIN.length}` }, { id: "usuals", label: `Your usuals · ${USUALS.length}` }, ].map(t => ( ))}
{tab === "again" && (
{BUY_AGAIN.map(b => { const p = PRODUCT_INDEX[b.id]; if (!p) return null; const store = STORES.find(s => s.id === b.storeId); return (
{b.last}
{p.name}
{store?.name} · {money(p.price)}
); })}
)} {tab === "usuals" && (
{USUALS.map(u => { const items = u.items.map(id => PRODUCT_INDEX[id]).filter(Boolean); const total = items.reduce((s, p) => s + p.price, 0); return (
{/* Mini collage — 4 up */}
{items.slice(0, 4).map((p, i) => ( ))}
{u.label}
{u.subtitle}
{items.length} items · ~{money(total)}
); })}
)}
); } // Time-aware band — surfaces a contextual "what's open late / breakfast soon / etc." // Currently hard-mocked to late-night for the demo so it always renders the same // moment regardless of actual clock. function LateNightBand({ onNav }) { const late = STORES.filter(s => /Late-night|24hr/i.test((s.tags || []).join(" ") + " " + (s.tagline || s.hours || ""))); const list = late.length ? late : STORES.slice(0, 2); return (
It's 11:34 PM.{" "} Two stores near you are still open — {list.map(s => s.name).join(" and ")} stays on until close.
{list.slice(0, 2).map(s => ( ))}
); } function Home({ onNav, cart, addToCart, addMany }) { const isMobile = useMedia("(max-width: 720px)"); const isWide = useMedia("(min-width: 1200px)"); const nearby = STORES; const hero = STORES[0]; const [answer, setAnswer] = React.useState(null); return (
{answer && clusters.forEach(c => addMany(c.store.id, c.items))} onClose={() => setAnswer(null)} />} {/* Quick categories strip */}
{[ { id: "deli", label: "Deli", icon: "Baguette", tint: "oklch(93% 0.06 80)" }, { id: "coffee", label: "Coffee", icon: "Coffee", tint: "oklch(92% 0.04 60)" }, { id: "drinks", label: "Drinks", icon: "Snow", tint: "oklch(92% 0.05 210)" }, { id: "beer", label: "Beer & Wine", icon: "Beer", tint: "oklch(92% 0.07 100)" }, { id: "snacks", label: "Snacks", icon: "Cookie", tint: "oklch(92% 0.06 40)" }, { id: "produce", label: "Produce", icon: "Apple", tint: "oklch(93% 0.08 140)" }, { id: "pantry", label: "Pantry", icon: "Box", tint: "oklch(93% 0.04 90)" }, { id: "household", label: "Household", icon: "Pill", tint: "oklch(93% 0.03 280)" }, ].slice(0, isMobile ? 8 : 8).map(c => { const Icon = Icons[c.icon]; return ( ); })}
{/* Stores open right now — horizontal slider on both mobile and desktop */} onNav({ screen: "store", storeId: s.id })} /> {/* Curated bundles — bodega-style */}
Editor-curated, one tap to cart.} />
{BUNDLES.map((b, idx) => { const items = b.items.map(id => PRODUCT_INDEX[id]).filter(Boolean); const total = items.reduce((s,p)=>s+p.price,0); // Each bundle gets a different visual character const themes = [ { tint: "oklch(94% 0.06 80)", ink: "oklch(35% 0.10 60)", pat: "wave" }, { tint: "oklch(93% 0.05 220)", ink: "oklch(35% 0.12 240)", pat: "grid" }, { tint: "oklch(94% 0.07 130)", ink: "oklch(38% 0.10 145)", pat: "dots" }, ]; const th = themes[idx % themes.length]; return (
{/* Stylized header band */}
Bundle · {items.length} items
{b.label}
{/* Body */}
{b.desc}
{/* Item thumbnails */}
{items.map(p => ( ))}
{/* Item names list (dense, mono) */}
    {items.map(p => (
  • {p.name} {money(p.price)}
  • ))}
{money(total)} before delivery
} onClick={() => addMany("kalymnos", b.items)}>Add bundle
); })}
{/* Two CTA cards — eco / community */}
{/* Most shopped near you — horizontal slider, breathable */} p.trending).slice(0, 12)} isMobile={isMobile} cart={cart} onOpen={(p) => onNav({ screen: "product", storeId: "kalymnos", productId: p.id })} addToCart={addToCart} /> {/* Worth your attention — promoted long-form/video carousel */} {/* Warm end-of-page moment so the scroll doesn't cliff-dive into nothing. */}
); } function MiniProductCard({ p, onOpen, qty, onAdd, onRemove, pattern = "morph", storeName }) { return (
{storeName && (
{storeName}
)}
{p.name}
{p.unit}
{money(p.price)}
); } // ─── StoresOpenSlider ───────────────────────────────────────────────────── // Horizontal-scroll rail that works on both mobile and desktop. Desktop shows // chevron controls; mobile relies on swipe. Card width scales per viewport. function StoresOpenSlider({ stores, isMobile, onOpen }) { const scrollerRef = React.useRef(null); const [canLeft, setCanLeft] = React.useState(false); const [canRight, setCanRight] = React.useState(true); const cardW = isMobile ? 240 : 300; const update = React.useCallback(() => { const el = scrollerRef.current; if (!el) return; setCanLeft(el.scrollLeft > 4); setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4); }, []); React.useEffect(() => { const el = scrollerRef.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 = scrollerRef.current; if (!el) return; el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: "smooth" }); }; return (
{!isMobile && ( <> )} }>See all 412
} />
{stores.map(s => (
onOpen(s)} />
))}
); } // ─── HomeFarewell ───────────────────────────────────────────────────────── // Warm end-of-page moment so the home screen doesn't cliff-dive into nothing. // Personality more than CTA — but it does surface a one-tap Ask Opa shortcut. function HomeFarewell({ onNav, isMobile }) { const hour = new Date().getHours(); const when = hour < 5 ? "late tonight" : hour < 11 ? "this morning" : hour < 15 ? "this afternoon" : hour < 19 ? "tonight" : "tonight"; return (
That's everything {when}

Still not sure?{" "} Ask Opa — it's the whole block in one sentence.

3,500 local stores. One question. We'll read the aisles for you and build the cart.

); } Object.assign(window, { Home, MiniProductCard, StoresOpenSlider, HomeFarewell }); // ─── MostShoppedSlider ───────────────────────────────────────────────────── // Horizontal scroll carousel. Desktop shows ~4.5 cards visible → inviting scroll. // Mobile shows ~2.4 cards. Cards are larger + more breathable than the old grid. function MostShoppedSlider({ products, isMobile, cart, onOpen, addToCart }) { const scrollerRef = React.useRef(null); const [canScrollLeft, setCanScrollLeft] = React.useState(false); const [canScrollRight, setCanScrollRight] = React.useState(true); const updateArrows = React.useCallback(() => { const el = scrollerRef.current; if (!el) return; setCanScrollLeft(el.scrollLeft > 4); setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4); }, []); React.useEffect(() => { const el = scrollerRef.current; if (!el) return; updateArrows(); el.addEventListener("scroll", updateArrows, { passive: true }); window.addEventListener("resize", updateArrows); return () => { el.removeEventListener("scroll", updateArrows); window.removeEventListener("resize", updateArrows); }; }, [updateArrows]); const scrollBy = (dir) => { const el = scrollerRef.current; if (!el) return; el.scrollBy({ left: dir * (el.clientWidth * 0.8), behavior: "smooth" }); }; const cardWidth = isMobile ? 168 : 232; return (
{!isMobile && ( <> )} }>More
} />
{products.map(p => (
onOpen(p)} qty={cart.items.find(i => i.productId === p.id)?.qty || 0} onAdd={() => addToCart("kalymnos", p.id, 1)} onRemove={() => addToCart("kalymnos", p.id, -1)} />
))}
); } Object.assign(window, { MostShoppedSlider }); // ─── PickupBanner ────────────────────────────────────────────────────────── // Persistent strip on home when there's a pickup waiting. Real action. function PickupBanner({ onNav }) { const [dismissed, setDismissed] = React.useState(false); const isMobile = useMedia("(max-width: 720px)"); if (dismissed) return null; return (
{/* Subtle diagonal accent stripe */}
Pickup ready at Kalymnos #4821 · 3 items · held until 8:30 PM
Show your name at the counter — Maria knows you're on the way.
onNav({ screen: "store", storeId: "kalymnos" })} style={{ flexShrink: 0, position: "relative", zIndex: 1 }}> {isMobile ? "Go" : "Get directions"}
); } // ─── CtaCard ─────────────────────────────────────────────────────────────── // Full-bleed image-less promo card. Tone drives palette; stat anchors a number. function CtaCard({ tone = "green", eyebrow, title, body, stat, ctaLabel, icon = "Leaf" }) { const palettes = { green: { bg: "var(--ok-tint)", border: "oklch(from var(--ok) l c h / 0.22)", ink: "var(--ink)", eyebrow: "var(--ok)", iconBg: "var(--surface)", iconColor: "var(--ok)", btn: "primary", pattern: "var(--ok)", }, navy: { bg: "var(--navy-fill)", border: "transparent", ink: "#fff", eyebrow: "oklch(from #fff l c h / 0.7)", iconBg: "rgba(255,255,255,0.10)", iconColor: "#fff", btn: "inverse", pattern: "rgba(255,255,255,0.6)", }, }[tone]; const Icon = Icons[icon] || Icons.Leaf; return (
{/* decorative arc */}
{eyebrow}
{title}
{body}
{stat.k}
{stat.l}
}>{ctaLabel}
); } // ─── WorthYourAttention ──────────────────────────────────────────────────── // Promoted stories / video carousel. Every card has the "Promoted" tag. function WorthYourAttention({ isMobile, onNav }) { const items = [ { id: "verde", brand: "Verde Corner Produce", kind: "Video · 0:42", hed: "Watch how Hudson Valley apples get from orchard to your bag in 14 hours.", kicker: "This week's harvest", img: { url: "https://images.unsplash.com/photo-1568702846914-96b305d2aaeb?w=800&auto=format" }, color: "oklch(48% 0.12 140)" }, { id: "fig", brand: "Fig & Thistle Wine", kind: "Story · 3 min read", hed: "Three under-$22 reds the staff is fighting over right now.", kicker: "Buyer's notes · Nov", img: { url: "https://images.unsplash.com/photo-1547595628-c61a29f496f0?w=800&auto=format" }, color: "oklch(35% 0.09 20)" }, { id: "kalymnos", brand: "Kalymnos Deli", kind: "Recipe", hed: "Maria's hangover egg-cheese: 4 ingredients, 6 minutes, $9 cart.", kicker: "From the deli counter", img: { url: "https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=800&auto=format" }, color: "oklch(45% 0.10 60)" }, { id: "elmo", brand: "Elmo's Chicken & Fixins", kind: "Video · 1:08", hed: "We brined 200 thighs this morning. Here's why Tuesday is the night.", kicker: "Behind the counter", img: { url: "https://images.unsplash.com/photo-1532550907401-a500c9a57435?w=800&auto=format" }, color: "oklch(40% 0.13 30)" }, ]; return (
}>All stories} />
{items.map(it => ( ))}
); } Object.assign(window, { PickupBanner, CtaCard, WorthYourAttention }); // ─── BundleBackdrop ──────────────────────────────────────────────────────── // Decorative pattern fills for bundle headers — wave / grid / dots. function BundleBackdrop({ pattern, color }) { const stroke = `oklch(from ${color} l c h / 0.18)`; if (pattern === "wave") { return ( {[...Array(7)].map((_, i) => ( ))} ); } if (pattern === "grid") { return ( ); } // dots return ( {[...Array(7)].map((_, row) => [...Array(17)].map((__, col) => ( )) )} ); } window.BundleBackdrop = BundleBackdrop;