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

221 lines
8.6 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, 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 &amp; 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>
);
}