planmymeal/app/components/GroceryListDrawer.tsx

221 lines
8.9 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
"use client";
import { RiCheckLine as Check, RiFileCopyLine as Copy, RiRepeat2Line as Repeat2, RiCloseLine as X } from "@remixicon/react";
2026-10-31 20:44:29 -05:00
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";
2026-10-31 20:44:29 -05:00
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">
2026-10-31 20:44:29 -05:00
<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 &ldquo;I have this&rdquo; on anything in your kitchen and it drops off the bill.</p>
2026-10-31 20:44:29 -05:00
</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;
2026-10-31 20:44:29 -05:00
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)}
2026-10-31 20:44:29 -05:00
</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>
2026-10-31 20:44:29 -05:00
<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)}
2026-10-31 20:44:29 -05:00
</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>
2026-10-31 20:44:29 -05:00
</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"
2026-10-31 20:44:29 -05:00
>
<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>
);
}