222 lines
8.6 KiB
TypeScript
222 lines
8.6 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { Check, Copy, Repeat2, Square, SquareCheckBig, X } from "lucide-react";
|
|||
|
|
import { useEffect, useRef, useState } from "react";
|
|||
|
|
import { formatAmount, formatUsd } from "@/app/lib/units";
|
|||
|
|
import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types";
|
|||
|
|
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="paper-grain rounded-xl border border-line 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 items you already have on hand</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 } = line;
|
|||
|
|
return (
|
|||
|
|
<li>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => onToggleOnHand(item.id, !line.onHand)}
|
|||
|
|
aria-pressed={line.onHand}
|
|||
|
|
aria-label={line.onHand ? `Mark ${item.name} as needed at the store` : `Mark ${item.name} as already on hand`}
|
|||
|
|
className={`flex min-h-12 w-full items-start justify-between gap-3 py-2.5 text-left text-sm transition-colors hover:bg-raised/50 ${
|
|||
|
|
line.onHand ? "text-ink-faint" : "text-ink"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<span className="flex min-w-0 items-start gap-2.5">
|
|||
|
|
<span className="mt-0.5 shrink-0 text-ink-muted">
|
|||
|
|
{line.onHand ? (
|
|||
|
|
<SquareCheckBig className="size-4 text-status-ok" strokeWidth={2} aria-hidden />
|
|||
|
|
) : (
|
|||
|
|
<Square className="size-4 text-line-strong" strokeWidth={1.75} aria-hidden />
|
|||
|
|
)}
|
|||
|
|
</span>
|
|||
|
|
<span className="min-w-0">
|
|||
|
|
<span className={`block leading-snug ${line.onHand ? "line-through" : "font-medium"}`}>
|
|||
|
|
{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)}
|
|||
|
|
{line.onHand && " · on hand"}
|
|||
|
|
</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>
|
|||
|
|
|
|||
|
|
<span className="shrink-0 text-right font-mono text-sm font-medium tabular-nums">
|
|||
|
|
{line.onHand ? "$0.00" : formatUsd(line.checkoutCost)}
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
</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-2xl 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>
|
|||
|
|
);
|
|||
|
|
}
|