- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button, SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's color aliases onto BoardUI tokens and swap lucide for Remix icons - Retint BoardUI's neutral ramp to cool slate with a salmon accent; drop the dotted background; raise muted text contrast - Simplify the landing page and remove the overlap explorer - Planner: explicit "I have this" toggle on grocery lines, pantry panel above the menu with the staples list spelled out, sticky aisle header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded) - Recipes: replace the family cabbage soup with Cade's Cabbage Soup (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup - Remove unused BoardUI components, dependencies and exports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
8.9 KiB
TypeScript
220 lines
8.9 KiB
TypeScript
"use client";
|
||
|
||
import { RiCheckLine as Check, RiFileCopyLine as Copy, RiRepeat2Line as Repeat2, RiCloseLine as X } from "@remixicon/react";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { formatAmount, formatUsd } from "@/app/lib/units";
|
||
import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types";
|
||
import { cx } from "@/utils/cx";
|
||
import { BudgetMeter } from "./BudgetMeter";
|
||
|
||
interface PanelProps {
|
||
summary: GrocerySummary;
|
||
budget: number;
|
||
priceSourceLabel: string;
|
||
onToggleOnHand: (itemId: ItemId, onHand: boolean) => void;
|
||
}
|
||
|
||
export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggleOnHand }: PanelProps) {
|
||
const [copied, setCopied] = useState(false);
|
||
const byAisle = groupByAisle(summary.lines);
|
||
const remaining = budget - summary.checkoutTotal;
|
||
|
||
async function copyList() {
|
||
const text = summary.lines
|
||
.filter((l) => !l.onHand)
|
||
.map((l) => `☐ ${l.packages} × ${l.item.name} (${l.item.packageLabel})`)
|
||
.join("\n");
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
} catch {
|
||
// Clipboard can be blocked; list remains visible on screen.
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* --- Tactile Market Receipt Header -------------------------------- */}
|
||
<div className="rounded-3xl border border-border-button-default bg-canvas p-4">
|
||
<BudgetMeter spent={summary.checkoutTotal} budget={budget} />
|
||
|
||
<dl className="receipt-rule mt-4 grid grid-cols-2 gap-3 pt-3.5 text-xs">
|
||
<Stat label="At checkout" value={formatUsd(summary.checkoutTotal)} />
|
||
<Stat
|
||
label={remaining >= 0 ? "Left in budget" : "Over budget"}
|
||
value={formatUsd(Math.abs(remaining))}
|
||
tone={remaining >= 0 ? "text-status-ok" : "text-status-danger"}
|
||
/>
|
||
<Stat label="Eaten this week" value={formatUsd(summary.usedTotal)} />
|
||
<Stat label="Pantry carryover" value={formatUsd(summary.carryoverValue)} hint="Leftover package value kept" />
|
||
</dl>
|
||
|
||
{summary.sharedItemCount > 0 && (
|
||
<p className="mt-3 flex items-center gap-1.5 border-t border-line pt-2.5 text-xs font-medium text-status-ok">
|
||
<Repeat2 className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
|
||
{summary.sharedItemCount} store item{summary.sharedItemCount > 1 ? "s" : ""} shared across meals
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* --- Shopping List Header ----------------------------------------- */}
|
||
<div className="mt-5 flex items-center justify-between">
|
||
<div>
|
||
<h3 className="font-serif text-base font-semibold">Shopping list</h3>
|
||
<p className="text-xs text-ink-faint">Tap “I have this” on anything in your kitchen and it drops off the bill.</p>
|
||
</div>
|
||
{summary.lines.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={copyList}
|
||
className="trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3 py-1.5 text-xs font-semibold text-ink transition-colors hover:bg-raised"
|
||
>
|
||
{copied ? <Check className="size-3.5 text-status-ok" aria-hidden /> : <Copy className="size-3.5 text-ink-muted" aria-hidden />}
|
||
{copied ? "Copied" : "Copy list"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{summary.lines.length === 0 ? (
|
||
<p className="mt-6 rounded-lg border border-dashed border-line-strong p-6 text-center text-sm text-ink-muted">
|
||
Add meals to build your aisle-sorted grocery receipt.
|
||
</p>
|
||
) : (
|
||
<div className="mt-3 space-y-5">
|
||
{byAisle.map(([aisle, lines]) => (
|
||
<section key={aisle}>
|
||
<h4 className="border-b border-line pb-1 font-mono text-[11px] font-semibold tracking-wider text-ink-muted uppercase">
|
||
{aisle}
|
||
</h4>
|
||
<ul className="divide-y divide-line">
|
||
{lines.map((line) => (
|
||
<GroceryRow key={line.item.id} line={line} onToggleOnHand={onToggleOnHand} />
|
||
))}
|
||
</ul>
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<p className="mt-6 text-xs text-ink-faint">{priceSourceLabel}. Actual store prices vary by location.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GroceryRow({ line, onToggleOnHand }: { line: GroceryLine; onToggleOnHand: PanelProps["onToggleOnHand"] }) {
|
||
const { item, onHand } = line;
|
||
return (
|
||
<li className="flex min-h-12 items-start justify-between gap-3 py-2.5 text-sm">
|
||
<span className="min-w-0">
|
||
<span className={cx("block leading-snug", onHand ? "text-ink-faint line-through" : "font-medium text-ink")}>
|
||
{line.packages > 1 && <span className="font-mono tabular-nums">{line.packages} × </span>}
|
||
{item.name}
|
||
</span>
|
||
<span className="mt-0.5 block text-xs text-ink-muted">
|
||
{item.packageLabel} · need {formatAmount(line.neededAmount, item.baseUnit)}
|
||
</span>
|
||
{line.usedBy.length > 1 && (
|
||
<span className="mt-0.5 inline-flex items-center gap-1 text-xs text-ink-faint" title={line.usedBy.join(", ")}>
|
||
<Repeat2 className="size-3 shrink-0" aria-hidden />
|
||
Used in {line.usedBy.length} meals
|
||
</span>
|
||
)}
|
||
</span>
|
||
|
||
<span className="flex shrink-0 flex-col items-end gap-1.5">
|
||
<span className={cx("font-mono text-sm font-medium tabular-nums", onHand ? "text-ink-faint" : "text-ink")}>
|
||
{onHand ? "$0.00" : formatUsd(line.checkoutCost)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onToggleOnHand(item.id, !onHand)}
|
||
aria-pressed={onHand}
|
||
aria-label={`I have ${item.name}`}
|
||
title={onHand ? "Counted as $0. Tap to add it back to the bill." : "Already have it? Tap to take it off the bill."}
|
||
className={cx(
|
||
"inline-flex min-h-7 cursor-pointer items-center gap-1 rounded-full border px-2.5 text-caption-1-medium whitespace-nowrap transition-colors duration-150",
|
||
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||
onHand
|
||
? "border-status-ok-line bg-status-ok-bg text-status-ok"
|
||
: "border-border-button-default bg-background-primary-default text-ink-muted hover:border-border-button-hover hover:text-ink",
|
||
)}
|
||
>
|
||
{onHand && <Check className="size-3.5 shrink-0" aria-hidden />}I have this
|
||
</button>
|
||
</span>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function Stat({ label, value, hint, tone = "text-ink" }: { label: string; value: string; hint?: string; tone?: string }) {
|
||
return (
|
||
<div>
|
||
<dt className="text-xs text-ink-faint" title={hint}>
|
||
{label}
|
||
</dt>
|
||
<dd className={`mt-0.5 font-mono text-sm font-semibold tabular-nums ${tone}`}>{value}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function groupByAisle(lines: GroceryLine[]): [Aisle, GroceryLine[]][] {
|
||
const groups = new Map<Aisle, GroceryLine[]>();
|
||
for (const line of lines) {
|
||
const group = groups.get(line.item.aisle) ?? [];
|
||
group.push(line);
|
||
groups.set(line.item.aisle, group);
|
||
}
|
||
return [...groups];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GroceryListDrawer: Native <dialog> mobile bottom sheet with focus trapping,
|
||
// scroll locking, and @starting-style spring transition.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface DrawerProps extends PanelProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function GroceryListDrawer({ open, onClose, ...panelProps }: DrawerProps) {
|
||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||
|
||
useEffect(() => {
|
||
const dialog = dialogRef.current;
|
||
if (!dialog) return;
|
||
if (open && !dialog.open) dialog.showModal();
|
||
if (!open && dialog.open) dialog.close();
|
||
return () => {
|
||
if (dialog.open) dialog.close();
|
||
};
|
||
}, [open]);
|
||
|
||
return (
|
||
<dialog
|
||
ref={dialogRef}
|
||
onClose={onClose}
|
||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||
aria-labelledby="grocery-drawer-title"
|
||
className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden"
|
||
>
|
||
<div className="p-4 pb-8 sm:px-6">
|
||
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||
<h2 id="grocery-drawer-title" className="font-serif text-xl font-semibold">
|
||
Market receipt & list
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="Close grocery list"
|
||
className="grid size-10 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
|
||
>
|
||
<X className="size-5" aria-hidden />
|
||
</button>
|
||
</div>
|
||
<GrocerySummaryPanel {...panelProps} />
|
||
</div>
|
||
</dialog>
|
||
);
|
||
}
|