planmymeal/app/components/OverlapExplorer.tsx
Cayden Schwechten c19b910c36 init
2026-10-31 20:44:29 -05:00

149 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}