150 lines
6.5 KiB
TypeScript
150 lines
6.5 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { Check, Plus, Repeat2 } from "lucide-react";
|
|||
|
|
import { useState } from "react";
|
|||
|
|
|
|||
|
|
export interface OverlapDish {
|
|||
|
|
id: string;
|
|||
|
|
title: string;
|
|||
|
|
cuisine: string;
|
|||
|
|
standaloneCheckout: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface OverlapComboSnapshot {
|
|||
|
|
checkoutTotal: number;
|
|||
|
|
usedTotal: number;
|
|||
|
|
carryoverValue: number;
|
|||
|
|
sharedNames: string[];
|
|||
|
|
packageCount: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface OverlapExplorerProps {
|
|||
|
|
dishes: OverlapDish[];
|
|||
|
|
/** Keyed by sorted comma-joined dish IDs; precomputed on the server so zero catalog data ships to the landing bundle. */
|
|||
|
|
combos: Record<string, OverlapComboSnapshot>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fmt = (n: number) => `$${n.toFixed(2)}`;
|
|||
|
|
|
|||
|
|
export function OverlapExplorer({ dishes, combos }: OverlapExplorerProps) {
|
|||
|
|
const [selected, setSelected] = useState<string[]>(() => dishes.slice(0, 2).map((d) => d.id));
|
|||
|
|
|
|||
|
|
function toggleDish(id: string) {
|
|||
|
|
setSelected((prev) => {
|
|||
|
|
if (prev.includes(id)) {
|
|||
|
|
return prev.length > 1 ? prev.filter((x) => x !== id) : prev; // keep at least 1 selected
|
|||
|
|
}
|
|||
|
|
return [...prev, id];
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const comboKey = [...selected].sort().join(",");
|
|||
|
|
const current = combos[comboKey];
|
|||
|
|
const naiveSum = dishes.filter((d) => selected.includes(d.id)).reduce((acc, d) => acc + d.standaloneCheckout, 0);
|
|||
|
|
const saved = Math.max(0, naiveSum - (current?.checkoutTotal ?? 0));
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="grid gap-6 rounded-xl border border-line-strong bg-surface p-5 sm:p-7 lg:grid-cols-[1.15fr_1fr]">
|
|||
|
|
<div>
|
|||
|
|
<p className="font-mono text-xs tracking-wider text-brand-300 uppercase">Interactive checkout math · 2 people</p>
|
|||
|
|
<h3 className="mt-1 font-serif text-2xl font-semibold tracking-tight text-balance">
|
|||
|
|
Toggle dishes to watch shared packages drop the bill
|
|||
|
|
</h3>
|
|||
|
|
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
|
|||
|
|
Grocery stores sell whole bags of onions, rice, and spices—not single tablespoons. Selecting meals that reuse open
|
|||
|
|
packages slashes what each extra dinner adds at the register.
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<ul className="mt-5 space-y-2" aria-label="Sample dishes">
|
|||
|
|
{dishes.map((dish) => {
|
|||
|
|
const active = selected.includes(dish.id);
|
|||
|
|
return (
|
|||
|
|
<li key={dish.id}>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
aria-pressed={active}
|
|||
|
|
onClick={() => toggleDish(dish.id)}
|
|||
|
|
className={`flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-colors ${
|
|||
|
|
active
|
|||
|
|
? "border-selected-line bg-selected text-ink"
|
|||
|
|
: "border-line bg-canvas text-ink-muted hover:border-line-strong hover:text-ink"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<span className="flex items-center gap-3 min-w-0">
|
|||
|
|
<span
|
|||
|
|
className={`grid size-5 shrink-0 place-items-center rounded border ${
|
|||
|
|
active ? "border-brand-600 bg-brand-600 text-on-accent" : "border-line-strong bg-surface"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{active ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : <Plus className="size-3 text-ink-faint" aria-hidden />}
|
|||
|
|
</span>
|
|||
|
|
<span className="min-w-0">
|
|||
|
|
<span className="block truncate text-sm font-semibold text-ink">{dish.title}</span>
|
|||
|
|
<span className="block text-xs text-ink-faint">{dish.cuisine}</span>
|
|||
|
|
</span>
|
|||
|
|
</span>
|
|||
|
|
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
|
|||
|
|
{fmt(dish.standaloneCheckout)} alone
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
</li>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Simulated paper receipt */}
|
|||
|
|
<div className="paper-grain flex flex-col justify-between rounded-lg border border-line bg-canvas p-5 font-mono text-sm">
|
|||
|
|
<div>
|
|||
|
|
<div className="flex items-center justify-between border-b border-line pb-3 text-xs text-ink-faint uppercase">
|
|||
|
|
<span>Checkout Receipt</span>
|
|||
|
|
<span>{selected.length} {selected.length === 1 ? "meal" : "meals"}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<dl className="mt-4 space-y-2.5 text-xs sm:text-sm">
|
|||
|
|
<div className="flex justify-between text-ink-muted">
|
|||
|
|
<dt>Buying each meal separately</dt>
|
|||
|
|
<dd className="line-through tabular-nums">{fmt(naiveSum)}</dd>
|
|||
|
|
</div>
|
|||
|
|
{saved > 0.01 && (
|
|||
|
|
<div className="flex justify-between font-semibold text-status-ok">
|
|||
|
|
<dt>Shared-package savings</dt>
|
|||
|
|
<dd className="tabular-nums">−{fmt(saved)}</dd>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div className="receipt-rule flex items-baseline justify-between pt-3 text-base font-bold text-ink">
|
|||
|
|
<dt className="font-sans text-sm font-semibold">Combined register total</dt>
|
|||
|
|
<dd className="text-lg tabular-nums">{fmt(current?.checkoutTotal ?? 0)}</dd>
|
|||
|
|
</div>
|
|||
|
|
</dl>
|
|||
|
|
|
|||
|
|
<dl className="mt-4 grid grid-cols-2 gap-3 rounded-md border border-line bg-surface p-3 font-sans text-xs">
|
|||
|
|
<div>
|
|||
|
|
<dt className="text-ink-faint">Food eaten</dt>
|
|||
|
|
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.usedTotal ?? 0)}</dd>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<dt className="text-ink-faint">Kept in pantry</dt>
|
|||
|
|
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.carryoverValue ?? 0)}</dd>
|
|||
|
|
</div>
|
|||
|
|
</dl>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="mt-4 border-t border-line pt-3 font-sans text-xs text-ink-muted">
|
|||
|
|
{current && current.sharedNames.length > 0 ? (
|
|||
|
|
<p className="flex items-start gap-1.5">
|
|||
|
|
<Repeat2 className="mt-0.5 size-3.5 shrink-0 text-status-ok" strokeWidth={2} aria-hidden />
|
|||
|
|
<span>
|
|||
|
|
<strong className="font-semibold text-ink">Reused across meals:</strong> {current.sharedNames.slice(0, 5).join(", ")}
|
|||
|
|
{current.sharedNames.length > 5 ? ` +${current.sharedNames.length - 5} more` : ""}
|
|||
|
|
</span>
|
|||
|
|
</p>
|
|||
|
|
) : (
|
|||
|
|
<p>Select a second dish to see which store packages overlap in your cart.</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|