init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
221
app/components/GroceryListDrawer.tsx
Normal file
221
app/components/GroceryListDrawer.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue