149 lines
6.5 KiB
TypeScript
149 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>
|
||
);
|
||
}
|