This commit is contained in:
Cayden Schwechten 2026-10-31 20:44:29 -05:00
commit c19b910c36
80 changed files with 16134 additions and 0 deletions

View file

@ -0,0 +1,146 @@
"use client";
import { ArrowRight, CalendarPlus, Check, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { DAY_NAMES, mealLabel, slotKey, visibleMeals } from "@/app/lib/plan";
import { RECIPES_BY_ID } from "@/app/lib/recipes";
import { DEFAULT_STATE, sanitizeState } from "@/app/lib/sanitize";
import type { MealPlanState } from "@/app/types";
const STORAGE_KEY = "budget-meal-planner:v2";
function readPlan(): MealPlanState {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? sanitizeState(JSON.parse(raw)) : DEFAULT_STATE;
} catch {
return DEFAULT_STATE;
}
}
export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string; recipeTitle: string }) {
const [open, setOpen] = useState(false);
const [plan, setPlan] = useState<MealPlanState>(DEFAULT_STATE);
const [addedLabel, setAddedLabel] = useState<string | null>(null);
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]);
function assignSlot(day: number, meal: number) {
const latest = readPlan();
const key = slotKey(day, meal);
const updated: MealPlanState = {
...latest,
slots: { ...latest.slots, [key]: recipeId },
};
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
setPlan(updated);
setAddedLabel(`${DAY_NAMES[day]} · ${mealLabel(meal)}`);
setOpen(false);
} catch {
// Blocked storage
}
}
const meals = visibleMeals(plan.mealsPerDay);
return (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => {
setPlan(readPlan());
setOpen(true);
}}
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
>
<CalendarPlus className="size-4" strokeWidth={2} aria-hidden />
Add to meal plan
</button>
{addedLabel && (
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-status-ok-line bg-status-ok-bg px-3 py-1.5 text-xs font-medium text-status-ok">
<Check className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden />
Added to {addedLabel}
<Link href="/plan" className="ml-1 inline-flex items-center gap-1 font-semibold underline hover:opacity-85">
Open planner
<ArrowRight className="size-3" aria-hidden />
</Link>
</span>
)}
<dialog
ref={dialogRef}
onClose={() => setOpen(false)}
onClick={(e) => e.target === dialogRef.current && setOpen(false)}
aria-labelledby="add-to-plan-title"
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-2xl"
>
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-4 border-b border-line pb-3">
<div>
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">Assign slot</p>
<h2 id="add-to-plan-title" className="font-serif text-xl font-semibold">
Add &ldquo;{recipeTitle}&rdquo;
</h2>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close"
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted hover:bg-raised hover:text-ink"
>
<X className="size-5" aria-hidden />
</button>
</div>
<div className="mt-4 space-y-3">
{Array.from({ length: plan.daysCount }, (_, day) => (
<div key={day} className="rounded-lg border border-line bg-canvas p-3">
<p className="font-serif text-sm font-semibold text-ink">{DAY_NAMES[day]}</p>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{meals.map((meal) => {
const key = slotKey(day, meal);
const existingId = plan.slots[key];
const existingTitle = existingId ? RECIPES_BY_ID.get(existingId)?.title : null;
const isSame = existingId === recipeId;
return (
<button
key={key}
type="button"
onClick={() => assignSlot(day, meal)}
className={`min-h-12 rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
isSame
? "border-status-ok-line bg-status-ok-bg text-status-ok"
: "border-line-strong bg-surface hover:border-brand-600 hover:bg-brand-950"
}`}
>
<span className="block font-mono text-[10px] tracking-wider text-ink-faint uppercase">
{mealLabel(meal)}
</span>
<span className="mt-0.5 block truncate font-medium text-ink">
{existingTitle ? existingTitle : "+ Empty slot"}
</span>
</button>
);
})}
</div>
</div>
))}
</div>
</div>
</dialog>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { formatUsd } from "@/app/lib/units";
/** Herb sage under 85%, saffron amber up to 100%, chili red over budget. */
function budgetTone(spent: number, budget: number) {
const ratio = budget > 0 ? spent / budget : 0;
if (ratio > 1) return { bar: "bg-status-danger", text: "text-status-danger", label: "Over budget" };
if (ratio > 0.85) return { bar: "bg-status-warn", text: "text-status-warn", label: "Close to budget" };
return { bar: "bg-status-ok", text: "text-status-ok", label: "Under budget" };
}
interface BudgetMeterProps {
spent: number;
budget: number;
compact?: boolean;
}
export function BudgetMeter({ spent, budget, compact = false }: BudgetMeterProps) {
const tone = budgetTone(spent, budget);
const percent = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
return (
<div className={compact ? "w-full" : "w-full min-w-48"}>
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className={`font-mono text-sm font-semibold tabular-nums ${tone.text}`}>{formatUsd(spent)}</span>
<span className="font-mono text-ink-muted tabular-nums">/ {formatUsd(budget)}</span>
</div>
<div
className="mt-1.5 h-2 w-full overflow-hidden rounded-full border border-line-strong/40 bg-raised"
role="meter"
aria-label="Budget used"
aria-valuemin={0}
aria-valuemax={budget}
aria-valuenow={Math.round(spent * 100) / 100}
aria-valuetext={`${formatUsd(spent)} of ${formatUsd(budget)}, ${tone.label.toLowerCase()}`}
>
<div className={`h-full rounded-full transition-[width] duration-300 ${tone.bar}`} style={{ width: `${percent}%` }} />
</div>
</div>
);
}

38
app/components/Chip.tsx Normal file
View file

@ -0,0 +1,38 @@
import { Check } from "lucide-react";
import type { ReactNode } from "react";
// A pill-shaped on/off toggle button for filters (cuisines, styles, appliances…).
// Includes a visible Check icon when pressed so state never relies on color alone.
interface ChipProps {
pressed: boolean;
onClick: () => void;
children: ReactNode;
/** "sm" for dense filter rows, "md" for settings panels. */
size?: "sm" | "md";
/** Dashed outline when off: tells a second filter row apart from the first. */
dashed?: boolean;
}
const SIZES = {
sm: "min-h-8 px-2.5 py-1 text-xs gap-1.5",
md: "min-h-9 px-3.5 py-1.5 text-sm gap-1.5",
};
export function Chip({ pressed, onClick, children, size = "md", dashed = false }: ChipProps) {
const look = pressed
? "border-selected-line bg-selected font-medium text-ink shadow-2xs"
: `border-line-strong bg-surface text-ink-muted hover:border-ink-muted hover:bg-raised hover:text-ink ${dashed ? "border-dashed" : ""}`;
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
className={`trim-cap inline-flex items-center rounded-full border transition-colors ${SIZES[size]} ${look}`}
>
{pressed && <Check className="size-3.5 shrink-0 text-brand-300" strokeWidth={2.5} aria-hidden />}
<span className="inline-flex items-center gap-1.5">{children}</span>
</button>
);
}

View file

@ -0,0 +1,272 @@
"use client";
import { ChevronDown, RotateCcw, Shuffle, SlidersHorizontal, Wand2 } from "lucide-react";
import { useState, type Dispatch } from "react";
import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan";
import type { PlanAction } from "@/app/lib/usePlanState";
import type { FillMode, MealPlanState } from "@/app/types";
import { Chip } from "./Chip";
import { ApplianceIcon, StyleIcon } from "./Icons";
import { RadioGroup } from "./RadioGroup";
interface ConfigPanelProps {
state: MealPlanState;
dispatch: Dispatch<PlanAction>;
onGenerate: (mode: FillMode) => void;
onClear: () => void;
isGenerating: boolean;
}
const MEALS_OPTIONS = [
{ value: 1, label: "1" },
{ value: 2, label: "2" },
{ value: 3, label: "3" },
] as const;
const PEOPLE_OPTIONS = [
{ value: 1, label: "1" },
{ value: 2, label: "2" },
{ value: 3, label: "3" },
{ value: 4, label: "4" },
{ value: 5, label: "5" },
{ value: 6, label: "6" },
] as const;
export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating }: ConfigPanelProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const filledCount = activeRecipeIds(state).length;
const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · ");
const activeFilterCount =
(APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) +
state.preferredCuisines.length +
state.preferredStyles.length;
const filterSummary = [
`${state.selectedAppliances.length}/${APPLIANCES.length} appliances`,
state.preferredCuisines.length > 0 ? `${state.preferredCuisines.length} cuisines` : "Any cuisine",
state.preferredStyles.length > 0 ? `${state.preferredStyles.length} styles` : null,
]
.filter(Boolean)
.join(" · ");
return (
<section aria-labelledby="config-heading" className="rounded-xl border border-line bg-surface p-4 sm:p-5">
<h2 id="config-heading" className="sr-only">
Plan parameters
</h2>
{/* --- Compact Top Strip: Core Week Parameters ---------------------- */}
<div className="grid gap-x-6 gap-y-4 sm:grid-cols-2">
{/* Budget */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<label htmlFor="budget" className="text-xs font-semibold tracking-wide text-ink-muted uppercase">
Weekly budget
</label>
<span className="text-xs text-ink-faint">At checkout</span>
</div>
<div className="mt-1.5 flex min-h-9 items-center gap-2.5">
<input
id="budget-range"
type="range"
min={20}
max={300}
step={5}
value={state.targetBudget}
onChange={(e) => dispatch({ type: "setBudget", value: Number(e.target.value) })}
className="min-w-0 flex-1"
aria-label="Weekly budget slider"
/>
<div className="relative shrink-0">
<span className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 font-mono text-xs text-ink-muted">$</span>
<input
id="budget"
type="number"
inputMode="numeric"
min={10}
max={1000}
value={state.targetBudget}
onChange={(e) => dispatch({ type: "setBudget", value: Math.max(0, Number(e.target.value) || 0) })}
className="min-h-9 w-22 rounded-lg border border-line-strong py-1 pr-2.5 pl-6 text-right font-mono text-sm font-semibold tabular-nums"
/>
</div>
</div>
</div>
{/* Days */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<label htmlFor="days" className="text-xs font-semibold tracking-wide text-ink-muted uppercase">
Days
</label>
<span className="font-mono text-xs font-semibold text-ink tabular-nums">
{state.daysCount} {state.daysCount === 1 ? "day" : "days"}
</span>
</div>
<div className="mt-1.5 flex min-h-9 items-center">
<input
id="days"
type="range"
min={1}
max={7}
value={state.daysCount}
onChange={(e) => dispatch({ type: "setDays", value: Number(e.target.value) })}
className="w-full"
/>
</div>
</div>
{/* Meals per day */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="shrink-0 text-xs font-semibold tracking-wide text-ink-muted uppercase">Meals / day</span>
<span className="truncate text-xs text-ink-faint">{mealNames}</span>
</div>
<div className="mt-1.5">
<RadioGroup
name="Meals per day"
options={MEALS_OPTIONS}
value={state.mealsPerDay}
onChange={(value) => dispatch({ type: "setMealsPerDay", value })}
fullWidth
/>
</div>
</div>
{/* Household size */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="shrink-0 text-xs font-semibold tracking-wide text-ink-muted uppercase">Household</span>
<span className="truncate text-xs text-ink-faint">Scales amounts</span>
</div>
<div className="mt-1.5">
<RadioGroup
name="People eating"
options={PEOPLE_OPTIONS}
value={state.householdSize}
onChange={(value) => dispatch({ type: "setHouseholdSize", value })}
fullWidth
/>
</div>
</div>
</div>
{/* --- Action Toolbar + Progressive Disclosure Toggle --------------- */}
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-line pt-3.5">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onGenerate("empty")}
disabled={isGenerating}
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 disabled:opacity-60"
>
<Wand2 className="size-4 shrink-0" strokeWidth={2} aria-hidden />
{isGenerating ? "Planning…" : "Auto-fill empty"}
</button>
<button
type="button"
onClick={() => onGenerate("all")}
disabled={isGenerating}
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3.5 py-2 text-sm font-medium text-ink transition-colors hover:bg-raised disabled:opacity-60"
>
<Shuffle className="size-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
Shuffle plan
</button>
<button
type="button"
onClick={onClear}
disabled={isGenerating || filledCount === 0}
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink disabled:opacity-40"
>
<RotateCcw className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
Clear
</button>
</div>
<button
type="button"
aria-expanded={filtersOpen}
aria-controls="kitchen-filters-drawer"
onClick={() => setFiltersOpen((o) => !o)}
className={`trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg border px-3.5 py-2 text-sm font-medium transition-colors ${
filtersOpen || activeFilterCount > 0
? "border-selected-line bg-selected text-ink"
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
}`}
>
<SlidersHorizontal className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
<span>Kitchen &amp; preferences</span>
<span className="hidden text-xs text-ink-faint sm:inline">({filterSummary})</span>
<ChevronDown
className={`size-4 shrink-0 text-ink-muted transition-transform ${filtersOpen ? "rotate-180" : ""}`}
aria-hidden
/>
</button>
</div>
{/* --- Collapsible Appliances, Cuisines & Styles -------------------- */}
{filtersOpen && (
<div id="kitchen-filters-drawer" className="mt-4 space-y-5 border-t border-line pt-4">
<fieldset>
<legend className="text-sm font-semibold text-ink">
Appliances you own <span className="font-normal text-ink-muted">· recipes needing anything else stay hidden</span>
</legend>
<div className="mt-2 flex flex-wrap gap-2">
{APPLIANCES.map((a) => (
<Chip
key={a.id}
size="sm"
pressed={state.selectedAppliances.includes(a.id)}
onClick={() => dispatch({ type: "toggleAppliance", value: a.id })}
>
<ApplianceIcon id={a.id} className="size-3.5" />
{a.label}
</Chip>
))}
</div>
</fieldset>
<fieldset>
<legend className="text-sm font-semibold text-ink">
Preferred cuisines <span className="font-normal text-ink-muted">· none selected = any</span>
</legend>
<div className="mt-2 flex flex-wrap gap-2">
{CUISINES.map((c) => (
<Chip
key={c.id}
size="sm"
pressed={state.preferredCuisines.includes(c.id)}
onClick={() => dispatch({ type: "toggleCuisine", value: c.id })}
>
{c.label}
</Chip>
))}
</div>
</fieldset>
<fieldset>
<legend className="text-sm font-semibold text-ink">
Cooking style <span className="font-normal text-ink-muted">· matches any selected</span>
</legend>
<div className="mt-2 flex flex-wrap gap-2">
{STYLES.map((s) => (
<Chip
key={s.id}
size="sm"
pressed={state.preferredStyles.includes(s.id)}
onClick={() => dispatch({ type: "toggleStyle", value: s.id })}
>
<StyleIcon id={s.id} className="size-3.5" />
{s.label}
</Chip>
))}
</div>
</fieldset>
</div>
)}
</section>
);
}

View 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 &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>
);
}

58
app/components/Icons.tsx Normal file
View file

@ -0,0 +1,58 @@
import type { LucideProps } from "lucide-react";
import {
BookHeart,
CookingPot,
CupSoda,
Fish,
Flame,
Heater,
Leaf,
PackageCheck,
Salad,
Soup,
Timer,
Utensils,
Wind,
Zap,
} from "lucide-react";
import type { Appliance, CookingStyle } from "@/app/types";
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & LucideProps) {
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
switch (id) {
case "stovetop":
return <Flame {...shared} />;
case "oven":
return <Heater {...shared} />;
case "air_fryer":
return <Wind {...shared} />;
case "slow_cooker":
return <Soup {...shared} />;
case "microwave":
return <Zap {...shared} />;
case "blender":
return <CupSoda {...shared} />;
case "rice_cooker":
return <CookingPot {...shared} />;
}
}
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & LucideProps) {
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
switch (id) {
case "caydens_corner":
return <BookHeart {...shared} />;
case "seafood":
return <Fish {...shared} />;
case "quick":
return <Timer {...shared} />;
case "vegetarian":
return <Leaf {...shared} />;
case "healthy":
return <Salad {...shared} />;
case "meal_prep":
return <PackageCheck {...shared} />;
case "comfort":
return <Utensils {...shared} />;
}
}

View file

@ -0,0 +1,199 @@
"use client";
import { ChevronDown, Search, X } from "lucide-react";
import { useMemo, useState, type Dispatch } from "react";
import { CATALOG, CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
import { RECIPES } from "@/app/lib/recipes";
import type { PlanAction } from "@/app/lib/usePlanState";
import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types";
import { RadioGroup, type RadioOption } from "./RadioGroup";
interface IngredientPanelProps {
state: MealPlanState;
dispatch: Dispatch<PlanAction>;
}
type Filter = "all" | "avoid" | "have";
const RECIPE_COUNT: ReadonlyMap<ItemId, number> = (() => {
const counts = new Map<ItemId, number>();
for (const recipe of RECIPES) {
for (const id of new Set(recipe.ingredients.map((i) => i.itemId))) counts.set(id, (counts.get(id) ?? 0) + 1);
}
return counts;
})();
const FILTER_OPTIONS: readonly RadioOption<Filter>[] = [
{ value: "all", label: "All" },
{ value: "avoid", label: "Avoided" },
{ value: "have", label: "On hand" },
];
const PREF_OPTIONS: readonly RadioOption<ItemPreference>[] = [
{ value: "avoid", label: "Avoid", activeClass: "border border-status-danger-line bg-status-danger-bg text-status-danger font-semibold" },
{ value: "use", label: "Use", activeClass: "border border-selected-line/60 bg-surface text-ink font-semibold shadow-2xs" },
{ value: "have", label: "Have it", activeClass: "border border-status-ok-line bg-status-ok-bg text-status-ok font-semibold" },
];
export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const preferenceOf = (id: ItemId): ItemPreference =>
state.avoidedItems.includes(id) ? "avoid" : state.onHandItems.includes(id) ? "have" : "use";
const setPreference = (itemIds: ItemId[], preference: ItemPreference) =>
dispatch({ type: "setItemPreference", itemIds, preference });
const needle = query.trim().toLowerCase();
const groups = useMemo(() => {
const visible = CATALOG.filter((item) => {
if (needle && !item.name.toLowerCase().includes(needle)) return false;
if (filter === "avoid") return state.avoidedItems.includes(item.id);
if (filter === "have") return state.onHandItems.includes(item.id);
return true;
});
const byAisle = new Map<Aisle, StoreItem[]>();
for (const item of [...visible].sort((a, b) => a.name.localeCompare(b.name))) {
byAisle.set(item.aisle, [...(byAisle.get(item.aisle) ?? []), item]);
}
return [...byAisle].sort(([a], [b]) => a.localeCompare(b));
}, [needle, filter, state.avoidedItems, state.onHandItems]);
const staplesAllOnHand = PANTRY_STAPLE_IDS.every((id) => state.onHandItems.includes(id));
return (
<section aria-labelledby="ingredients-heading" className="rounded-xl border border-line bg-surface">
<details className="group">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-4 sm:px-5 [&::-webkit-details-marker]:hidden">
<div>
<h2 id="ingredients-heading" className="font-serif text-lg font-semibold">
Pantry &amp; ingredient exclusions
</h2>
<p className="text-xs text-ink-muted sm:text-sm">
<span className="font-mono font-medium text-ink">{state.onHandItems.length}</span> on hand ($0) ·{" "}
<span className="font-mono font-medium text-ink">{state.avoidedItems.length}</span> avoided
</p>
</div>
<span className="grid size-9 shrink-0 place-items-center rounded-lg border border-line bg-raised text-ink-muted transition-transform group-open:rotate-180">
<ChevronDown className="size-4" aria-hidden />
</span>
</summary>
<div className="border-t border-line p-4 sm:px-5">
{state.avoidedItems.length > 0 && (
<div className="mb-4 flex flex-wrap gap-1.5" aria-label="Avoided ingredients">
{state.avoidedItems.map((id) => (
<button
key={id}
type="button"
onClick={() => setPreference([id], "use")}
className="trim-cap inline-flex min-h-8 items-center gap-1 rounded-full border border-status-danger-line bg-status-danger-bg px-3 py-1 text-xs font-medium text-status-danger transition-colors hover:opacity-85"
aria-label={`Stop avoiding ${CATALOG_BY_ID.get(id)?.name}`}
>
{CATALOG_BY_ID.get(id)?.name}
<X className="size-3" aria-hidden />
</button>
))}
</div>
)}
{/* Search + filter */}
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative w-full sm:w-auto sm:min-w-0 sm:flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
<input
type="search"
placeholder="Search store items…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="min-h-10 w-full rounded-lg border border-line-strong py-1.5 pr-3 pl-9 text-sm"
aria-label="Search store items"
/>
</div>
<RadioGroup name="Filter ingredients" options={FILTER_OPTIONS} value={filter} onChange={setFilter} size="xs" />
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs sm:text-sm">
<button
type="button"
onClick={() => setPreference(PANTRY_STAPLE_IDS, staplesAllOnHand ? "use" : "have")}
className="min-h-8 font-medium text-brand-300 hover:underline"
>
{staplesAllOnHand ? "Unmark pantry staples" : "Mark basic pantry staples as on hand"}
</button>
{(state.avoidedItems.length > 0 || state.onHandItems.length > 0) && (
<button
type="button"
onClick={() => setPreference([...state.avoidedItems, ...state.onHandItems], "use")}
className="min-h-8 text-ink-muted hover:text-ink hover:underline"
>
Reset all to &ldquo;Use&rdquo;
</button>
)}
</div>
<div className="mt-4 max-h-[28rem] space-y-5 overflow-y-auto pr-1">
{groups.length === 0 && <p className="py-8 text-center text-sm text-ink-muted">No store items match your search.</p>}
{groups.map(([aisle, items]) => (
<section key={aisle}>
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-line bg-surface py-1.5">
<h3 className="font-mono text-xs font-semibold tracking-wider text-ink-muted uppercase">{aisle}</h3>
<AisleBulkToggle itemIds={items.map((i) => i.id)} avoidedItems={state.avoidedItems} onSet={setPreference} />
</div>
<ul className="grid gap-x-6 sm:grid-cols-2">
{items.map((item) => {
const current = preferenceOf(item.id);
const count = RECIPE_COUNT.get(item.id) ?? 0;
return (
<li key={item.id} className="flex min-w-0 items-center justify-between gap-3 border-b border-line py-2.5">
<div className="min-w-0">
<p className={`truncate text-sm font-medium ${current === "avoid" ? "text-ink-faint line-through" : "text-ink"}`}>
{item.name}
</p>
<p className="text-xs text-ink-faint">
Used in {count} recipe{count === 1 ? "" : "s"}
</p>
</div>
<RadioGroup
name={item.name}
options={PREF_OPTIONS}
value={current}
onChange={(val) => setPreference([item.id], val)}
size="xs"
/>
</li>
);
})}
</ul>
</section>
))}
</div>
</div>
</details>
</section>
);
}
function AisleBulkToggle({
itemIds,
avoidedItems,
onSet,
}: {
itemIds: ItemId[];
avoidedItems: ItemId[];
onSet: (itemIds: ItemId[], preference: ItemPreference) => void;
}) {
const allAvoided = itemIds.every((id) => avoidedItems.includes(id));
return (
<button
type="button"
onClick={() => onSet(itemIds, allAvoided ? "use" : "avoid")}
className="min-h-8 px-2 text-xs font-medium text-ink-muted transition-colors hover:text-status-danger"
>
{allAvoided ? "Allow aisle" : "Avoid entire aisle"}
</button>
);
}

231
app/components/MealGrid.tsx Normal file
View file

@ -0,0 +1,231 @@
"use client";
import { AlertTriangle, ArrowLeftRight, Plus, X } from "lucide-react";
import { useState, type Dispatch } from "react";
import { APPLIANCES, DAY_NAMES, activeSlots, describeBlockers, isCookable, mealLabel, recipeBlockers, slotKey } from "@/app/lib/plan";
import { RECIPES_BY_ID } from "@/app/lib/recipes";
import { formatUsd } from "@/app/lib/units";
import type { PlanAction } from "@/app/lib/usePlanState";
import type { MealPlanState } from "@/app/types";
import { ApplianceIcon } from "./Icons";
interface MealGridProps {
state: MealPlanState;
dispatch: Dispatch<PlanAction>;
mealCosts: Map<string, number>;
onOpenSlot: (key: string) => void;
onViewSlot: (key: string) => void;
onRemoveSlot: (key: string, recipeTitle: string) => void;
}
const applianceLabel = (id: string) => APPLIANCES.find((a) => a.id === id)?.label ?? id;
export function MealGrid({ state, dispatch, mealCosts, onOpenSlot, onViewSlot, onRemoveSlot }: MealGridProps) {
const [dragFrom, setDragFrom] = useState<string | null>(null);
const [dragOver, setDragOver] = useState<string | null>(null);
/** Mobile & keyboard slot-swap picker: holds the origin slot key currently choosing a destination. */
const [moveSourceKey, setMoveSourceKey] = useState<string | null>(null);
const allSlots = activeSlots(state);
const days = Array.from({ length: state.daysCount }, (_, day) => allSlots.filter((s) => s.dayIndex === day));
return (
<section aria-labelledby="grid-heading">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h2 id="grid-heading" className="font-serif text-xl font-semibold tracking-tight">
Weekly menu
</h2>
<p className="text-xs text-ink-faint">
<span className="hidden md:inline">Drag meals or use the swap icon to rearrange · </span>
Tap any planned dish for its scaled recipe
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 2xl:grid-cols-3">
{days.map((slots, day) => {
const dayCost = slots.reduce((sum, s) => sum + (s.recipeId ? (mealCosts.get(s.recipeId) ?? 0) : 0), 0);
const hasAnyMeal = slots.some((s) => s.recipeId !== null);
return (
<article key={day} className="flex flex-col overflow-hidden rounded-xl border border-line bg-surface">
{/* Architectural day header */}
<div className="flex items-baseline justify-between border-b border-line bg-raised/65 px-4 py-2.5">
<h3 className="font-serif text-base font-semibold tracking-tight text-ink">{DAY_NAMES[day]}</h3>
{hasAnyMeal && (
<span className="font-mono text-xs text-ink-muted tabular-nums">{formatUsd(dayCost)} eaten</span>
)}
</div>
<ul className="flex-1 divide-y divide-line">
{slots.map((slot) => {
const key = slotKey(slot.dayIndex, slot.mealNumber);
const recipe = slot.recipeId ? RECIPES_BY_ID.get(slot.recipeId) : undefined;
const blocked = recipe ? !isCookable(recipe, state) : false;
const label = mealLabel(slot.mealNumber);
const isDropTarget = dragOver === key && dragFrom !== key;
const isMovingThis = moveSourceKey === key;
return (
<li
key={key}
onDragOver={(e) => {
if (!dragFrom) return;
e.preventDefault();
setDragOver(key);
}}
onDragLeave={() => setDragOver((current) => (current === key ? null : current))}
onDrop={(e) => {
e.preventDefault();
if (dragFrom && dragFrom !== key) dispatch({ type: "swapSlots", from: dragFrom, to: key });
setDragFrom(null);
setDragOver(null);
}}
className={`transition-colors ${isDropTarget ? "bg-brand-950 ring-2 ring-inset ring-brand-600" : ""}`}
>
{recipe ? (
<div
draggable
onDragStart={() => setDragFrom(key)}
onDragEnd={() => {
setDragFrom(null);
setDragOver(null);
}}
className={`p-3.5 transition-colors ${
blocked ? "bg-status-warn-bg/65" : "hover:bg-raised/45"
}`}
>
<div className="flex items-start justify-between gap-2">
<button
type="button"
onClick={() => onViewSlot(key)}
className="min-w-0 flex-1 text-left"
aria-label={`View recipe for ${DAY_NAMES[day]} ${label}: ${recipe.title}`}
>
<span className="block font-mono text-[11px] font-medium tracking-wider text-ink-faint uppercase">
{label}
</span>
<span className="mt-0.5 block leading-snug font-semibold text-ink hover:text-brand-300">
{recipe.title}
</span>
<span className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-muted">
<span>{recipe.prepTime} min</span>
<span aria-hidden>·</span>
<span className="font-mono tabular-nums">{formatUsd(mealCosts.get(recipe.id) ?? 0)}/meal</span>
{recipe.requiredAppliances.length > 0 && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1 text-ink-faint"
title={`Requires: ${recipe.requiredAppliances.map(applianceLabel).join(", ")}`}
>
{recipe.requiredAppliances.map((a) => (
<ApplianceIcon key={a} id={a} className="size-3.5" />
))}
<span className="sr-only">
Requires {recipe.requiredAppliances.map(applianceLabel).join(", ")}
</span>
</span>
</>
)}
</span>
{blocked && (
<span className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-status-warn">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
{describeBlockers(recipeBlockers(recipe, state))}
</span>
)}
</button>
{/* Non-overlapping 36x36px touch & keyboard action controls */}
<div className="flex shrink-0 items-center gap-0.5">
{allSlots.length > 1 && (
<button
type="button"
aria-expanded={isMovingThis}
onClick={() => setMoveSourceKey(isMovingThis ? null : key)}
aria-label={`Move or swap ${recipe.title}`}
title="Move or swap slot"
className={`grid size-9 place-items-center rounded-lg transition-colors ${
isMovingThis ? "bg-selected text-ink" : "text-ink-faint hover:bg-raised hover:text-ink"
}`}
>
<ArrowLeftRight className="size-4" strokeWidth={1.75} aria-hidden />
</button>
)}
<button
type="button"
onClick={() => {
if (isMovingThis) setMoveSourceKey(null);
onRemoveSlot(key, recipe.title);
}}
aria-label={`Remove ${recipe.title} from ${DAY_NAMES[day]} ${label}`}
title="Remove meal"
className="grid size-9 place-items-center rounded-lg text-ink-faint transition-colors hover:bg-status-danger-bg hover:text-status-danger"
>
<X className="size-4" strokeWidth={1.75} aria-hidden />
</button>
</div>
</div>
{/* Touch & keyboard accessible Swap/Move picker */}
{isMovingThis && (
<div className="mt-3 rounded-lg border border-line-strong bg-raised p-2.5">
<p className="text-xs font-medium text-ink-muted">Swap or move to another slot:</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{allSlots
.filter((target) => slotKey(target.dayIndex, target.mealNumber) !== key)
.map((target) => {
const targetKey = slotKey(target.dayIndex, target.mealNumber);
const occupant = target.recipeId ? RECIPES_BY_ID.get(target.recipeId)?.title : null;
return (
<button
key={targetKey}
type="button"
onClick={() => {
dispatch({ type: "swapSlots", from: key, to: targetKey });
setMoveSourceKey(null);
}}
className="trim-cap min-h-8 rounded-md border border-line-strong bg-surface px-2.5 py-1 text-xs font-medium text-ink transition-colors hover:border-brand-600 hover:bg-brand-950"
title={occupant ? `Swap with ${occupant}` : "Move to empty slot"}
>
{DAY_NAMES[target.dayIndex]} {mealLabel(target.mealNumber)}
{occupant ? " ⇄" : ""}
</button>
);
})}
</div>
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => onOpenSlot(key)}
className="group flex min-h-16 w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-raised/65"
>
<span>
<span className="block font-mono text-[11px] font-medium tracking-wider text-ink-faint uppercase">
{label}
</span>
<span className="mt-0.5 block text-sm font-medium text-ink-muted group-hover:text-ink">
Choose a meal
</span>
</span>
<span className="grid size-8 place-items-center rounded-lg border border-dashed border-line-strong text-ink-muted transition-colors group-hover:border-brand-600 group-hover:bg-brand-950 group-hover:text-brand-300">
<Plus className="size-4" strokeWidth={2} aria-hidden />
</span>
</button>
)}
</li>
);
})}
</ul>
</article>
);
})}
</div>
</section>
);
}

View file

@ -0,0 +1,297 @@
"use client";
import { ChevronDown, ChevronUp, Repeat2, Search, SlidersHorizontal, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Cart } from "@/app/lib/grocery";
import { CUISINES, DAY_NAMES, STYLES, activeRecipeIds, describeBlockers, mealKindFor, mealLabel, recipeBlockers, toggle } from "@/app/lib/plan";
import { RECIPES, RECIPES_BY_ID } from "@/app/lib/recipes";
import { formatUsd } from "@/app/lib/units";
import type { CookingStyle, Cuisine, MealPlanState, Recipe } from "@/app/types";
import { Chip } from "./Chip";
import { StyleIcon } from "./Icons";
import { RecipeDetail } from "./RecipeDetail";
interface MealSelectorModalProps {
slotKey: string | null;
state: MealPlanState;
onPick: (recipeId: string) => void;
onClose: () => void;
}
export function MealSelectorModal({ slotKey, state, onPick, onClose }: MealSelectorModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (slotKey && !dialog.open) dialog.showModal();
if (!slotKey && dialog.open) dialog.close();
return () => {
if (dialog.open) dialog.close();
};
}, [slotKey]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
onClick={(e) => e.target === dialogRef.current && onClose()}
aria-labelledby="selector-title"
className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-2xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-2xl"
>
{slotKey && <SelectorContent key={slotKey} slotKey={slotKey} state={state} onPick={onPick} onClose={onClose} />}
</dialog>
);
}
interface Candidate {
recipe: Recipe;
addedCost: number;
reusedCount: number;
blockedReason: string;
}
function SelectorContent({
slotKey,
state,
onPick,
onClose,
}: {
slotKey: string;
state: MealPlanState;
onPick: (id: string) => void;
onClose: () => void;
}) {
const [dayIndex, mealNumber] = slotKey.split("-").map(Number);
const slotKind = mealKindFor(mealNumber);
const currentId = state.slots[slotKey];
const [query, setQuery] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [cuisines, setCuisines] = useState<Cuisine[]>(state.preferredCuisines);
const [styles, setStyles] = useState<CookingStyle[]>(state.preferredStyles);
const [showAllKinds, setShowAllKinds] = useState(false);
const [showIncompatible, setShowIncompatible] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const candidates = useMemo<Candidate[]>(() => {
const others = activeRecipeIds(state);
if (currentId) others.splice(others.indexOf(currentId), 1);
const cart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems }, others);
const itemsInCart = new Set(others.flatMap((id) => RECIPES_BY_ID.get(id)?.ingredients.map((i) => i.itemId) ?? []));
return RECIPES.map((recipe) => ({
recipe,
addedCost: cart.marginal(recipe.id).checkout,
reusedCount: recipe.ingredients.filter((i) => itemsInCart.has(i.itemId)).length,
blockedReason: describeBlockers(recipeBlockers(recipe, state)),
})).sort((a, b) => a.addedCost - b.addedCost);
}, [state, currentId]);
const needle = query.trim().toLowerCase();
const visible = candidates.filter(
({ recipe, blockedReason }) =>
(showAllKinds || recipe.kind === slotKind) &&
(showIncompatible || blockedReason === "") &&
(cuisines.length === 0 || cuisines.includes(recipe.cuisine)) &&
(styles.length === 0 || recipe.styles.some((s) => styles.includes(s))) &&
(needle === "" || recipe.title.toLowerCase().includes(needle)),
);
const activeFilterCount = cuisines.length + styles.length + (showAllKinds ? 1 : 0) + (showIncompatible ? 1 : 0);
return (
<div className="flex max-h-[90dvh] flex-col">
{/* Compact sticky header so 80%+ of mobile viewport shows recipes */}
<div className="border-b border-line p-4 sm:px-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">
{DAY_NAMES[dayIndex]} · {mealLabel(mealNumber)}
</p>
<h2 id="selector-title" className="font-serif text-xl font-semibold">
Choose a dish
</h2>
<p className="text-xs text-ink-muted">Sorted by marginal cost added to your current grocery cart</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
<X className="size-5" aria-hidden />
</button>
</div>
<div className="mt-3 flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
<input
type="search"
autoFocus
placeholder="Search recipes…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="min-h-10 w-full rounded-lg border border-line-strong py-2 pr-3 pl-9 text-sm"
/>
</div>
<button
type="button"
aria-expanded={filtersOpen}
onClick={() => setFiltersOpen((o) => !o)}
className={`trim-cap inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
filtersOpen || activeFilterCount > 0
? "border-selected-line bg-selected text-ink"
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
}`}
>
<SlidersHorizontal className="size-4" strokeWidth={1.75} aria-hidden />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="rounded-full bg-brand-600 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-on-accent">
{activeFilterCount}
</span>
)}
</button>
</div>
{filtersOpen && (
<div className="mt-3 max-h-52 space-y-3 overflow-y-auto border-t border-line pt-3">
<div className="flex flex-wrap gap-1.5">
{CUISINES.map((c) => (
<Chip key={c.id} size="sm" pressed={cuisines.includes(c.id)} onClick={() => setCuisines((list) => toggle(list, c.id))}>
{c.label}
</Chip>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{STYLES.map((st) => (
<Chip key={st.id} size="sm" dashed pressed={styles.includes(st.id)} onClick={() => setStyles((list) => toggle(list, st.id))}>
<StyleIcon id={st.id} className="size-3.5" />
{st.label}
</Chip>
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-ink-muted sm:text-sm">
<label className="flex min-h-8 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={showAllKinds}
onChange={(e) => setShowAllKinds(e.target.checked)}
className="size-4 accent-brand-600"
/>
Include breakfasts, sides &amp; desserts
</label>
<label className="flex min-h-8 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={showIncompatible}
onChange={(e) => setShowIncompatible(e.target.checked)}
className="size-4 accent-brand-600"
/>
Show blocked recipes (appliances / avoided items)
</label>
</div>
</div>
)}
</div>
{/* Results */}
<ul className="flex-1 space-y-2 overflow-y-auto p-4 sm:px-5">
{visible.length === 0 && (
<li className="py-10 text-center text-sm text-ink-muted">No recipes match these filters.</li>
)}
{visible.map(({ recipe, addedCost, reusedCount, blockedReason }) => {
const disabled = blockedReason !== "";
const expanded = expandedId === recipe.id;
return (
<li
key={recipe.id}
className={`rounded-xl border transition-colors ${
recipe.id === currentId ? "border-selected-line bg-raised" : "border-line bg-surface hover:border-line-strong"
}`}
>
<div className="flex items-start gap-3 p-3.5">
<button
type="button"
disabled={disabled}
onClick={() => onPick(recipe.id)}
className="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-55"
aria-label={`Choose ${recipe.title}`}
>
<p className="font-semibold text-ink">
{recipe.title}
{recipe.id === currentId && (
<span className="ml-2 rounded border border-selected-line bg-selected px-1.5 py-0.5 font-mono text-[11px] font-medium text-ink">
current
</span>
)}
</p>
<p className="mt-0.5 text-sm text-ink-muted">{recipe.description}</p>
<p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
<span>{CUISINES.find((c) => c.id === recipe.cuisine)?.label}</span>
<span aria-hidden>·</span>
<span>{recipe.prepTime} min</span>
{recipe.requiredAppliances.length === 0 && (
<>
<span aria-hidden>·</span>
<span>No cooking</span>
</>
)}
{reusedCount > 0 && (
<span className="inline-flex items-center gap-1 font-medium text-status-ok">
<Repeat2 className="size-3" aria-hidden />
Reuses {reusedCount} item{reusedCount > 1 ? "s" : ""} in cart
</span>
)}
</p>
{disabled && <p className="mt-1.5 text-xs font-medium text-status-warn">{blockedReason}</p>}
</button>
<div className="flex shrink-0 flex-col items-end gap-1.5 text-right">
<div>
<p className={`font-mono text-sm font-semibold tabular-nums ${addedCost < 0.01 ? "text-status-ok" : "text-ink"}`}>
+{formatUsd(Math.max(0, addedCost))}
</p>
<p className="text-[11px] text-ink-faint">{addedCost < 0.01 ? "in cart" : "to cart"}</p>
</div>
<button
type="button"
onClick={() => setExpandedId(expanded ? null : recipe.id)}
aria-expanded={expanded}
aria-controls={`recipe-${recipe.id}`}
className="trim-cap inline-flex min-h-8 items-center gap-1 rounded-lg border border-line-strong bg-surface px-2.5 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
{expanded ? "Hide" : "Recipe"}
{expanded ? <ChevronUp className="size-3.5" aria-hidden /> : <ChevronDown className="size-3.5" aria-hidden />}
</button>
</div>
</div>
{expanded && (
<div id={`recipe-${recipe.id}`} className="border-t border-line bg-canvas/50 px-3.5 py-4">
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} compact />
{!disabled && (
<button
type="button"
onClick={() => onPick(recipe.id)}
className="trim-cap mt-4 min-h-10 w-full rounded-lg bg-brand-600 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
>
Add to {DAY_NAMES[dayIndex]} {mealLabel(mealNumber).toLowerCase()}
</button>
)}
</div>
)}
</li>
);
})}
</ul>
</div>
);
}

36
app/components/Navbar.tsx Normal file
View file

@ -0,0 +1,36 @@
import type { SyncStatus } from "@/app/lib/usePlanState";
import { BudgetMeter } from "./BudgetMeter";
import { SiteHeader } from "./SiteHeader";
interface NavbarProps {
checkoutTotal: number;
budget: number;
syncStatus: SyncStatus;
isGenerating: boolean;
}
const SYNC_LABEL: Record<SyncStatus, { text: string; dot: string }> = {
saved: { text: "Saved on device", dot: "bg-status-ok" },
error: { text: "Storage blocked", dot: "bg-status-danger" },
};
export function Navbar({ checkoutTotal, budget, syncStatus, isGenerating }: NavbarProps) {
const sync = isGenerating ? { text: "Building plan…", dot: "bg-status-warn animate-pulse" } : SYNC_LABEL[syncStatus];
return (
<SiteHeader
active="plan"
subtitle={
<span className="inline-flex items-center gap-1.5 text-xs text-ink-muted" aria-live="polite" title={sync.text}>
<span className={`size-1.5 rounded-full ${sync.dot}`} />
<span className="sr-only sm:not-sr-only">{sync.text}</span>
</span>
}
rightSlot={
<div className="hidden w-60 border-l border-line pl-4 sm:block lg:w-68">
<BudgetMeter spent={checkoutTotal} budget={budget} />
</div>
}
/>
);
}

View file

@ -0,0 +1,149 @@
"use client";
import { Check, Plus, Repeat2 } from "lucide-react";
import { useState } from "react";
export interface OverlapDish {
id: string;
title: string;
cuisine: string;
standaloneCheckout: number;
}
export interface OverlapComboSnapshot {
checkoutTotal: number;
usedTotal: number;
carryoverValue: number;
sharedNames: string[];
packageCount: number;
}
interface OverlapExplorerProps {
dishes: OverlapDish[];
/** Keyed by sorted comma-joined dish IDs; precomputed on the server so zero catalog data ships to the landing bundle. */
combos: Record<string, OverlapComboSnapshot>;
}
const fmt = (n: number) => `$${n.toFixed(2)}`;
export function OverlapExplorer({ dishes, combos }: OverlapExplorerProps) {
const [selected, setSelected] = useState<string[]>(() => dishes.slice(0, 2).map((d) => d.id));
function toggleDish(id: string) {
setSelected((prev) => {
if (prev.includes(id)) {
return prev.length > 1 ? prev.filter((x) => x !== id) : prev; // keep at least 1 selected
}
return [...prev, id];
});
}
const comboKey = [...selected].sort().join(",");
const current = combos[comboKey];
const naiveSum = dishes.filter((d) => selected.includes(d.id)).reduce((acc, d) => acc + d.standaloneCheckout, 0);
const saved = Math.max(0, naiveSum - (current?.checkoutTotal ?? 0));
return (
<div className="grid gap-6 rounded-xl border border-line-strong bg-surface p-5 sm:p-7 lg:grid-cols-[1.15fr_1fr]">
<div>
<p className="font-mono text-xs tracking-wider text-brand-300 uppercase">Interactive checkout math · 2 people</p>
<h3 className="mt-1 font-serif text-2xl font-semibold tracking-tight text-balance">
Toggle dishes to watch shared packages drop the bill
</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
Grocery stores sell whole bags of onions, rice, and spices—not single tablespoons. Selecting meals that reuse open
packages slashes what each extra dinner adds at the register.
</p>
<ul className="mt-5 space-y-2" aria-label="Sample dishes">
{dishes.map((dish) => {
const active = selected.includes(dish.id);
return (
<li key={dish.id}>
<button
type="button"
aria-pressed={active}
onClick={() => toggleDish(dish.id)}
className={`flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-colors ${
active
? "border-selected-line bg-selected text-ink"
: "border-line bg-canvas text-ink-muted hover:border-line-strong hover:text-ink"
}`}
>
<span className="flex items-center gap-3 min-w-0">
<span
className={`grid size-5 shrink-0 place-items-center rounded border ${
active ? "border-brand-600 bg-brand-600 text-on-accent" : "border-line-strong bg-surface"
}`}
>
{active ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : <Plus className="size-3 text-ink-faint" aria-hidden />}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-ink">{dish.title}</span>
<span className="block text-xs text-ink-faint">{dish.cuisine}</span>
</span>
</span>
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
{fmt(dish.standaloneCheckout)} alone
</span>
</button>
</li>
);
})}
</ul>
</div>
{/* Simulated paper receipt */}
<div className="paper-grain flex flex-col justify-between rounded-lg border border-line bg-canvas p-5 font-mono text-sm">
<div>
<div className="flex items-center justify-between border-b border-line pb-3 text-xs text-ink-faint uppercase">
<span>Checkout Receipt</span>
<span>{selected.length} {selected.length === 1 ? "meal" : "meals"}</span>
</div>
<dl className="mt-4 space-y-2.5 text-xs sm:text-sm">
<div className="flex justify-between text-ink-muted">
<dt>Buying each meal separately</dt>
<dd className="line-through tabular-nums">{fmt(naiveSum)}</dd>
</div>
{saved > 0.01 && (
<div className="flex justify-between font-semibold text-status-ok">
<dt>Shared-package savings</dt>
<dd className="tabular-nums">−{fmt(saved)}</dd>
</div>
)}
<div className="receipt-rule flex items-baseline justify-between pt-3 text-base font-bold text-ink">
<dt className="font-sans text-sm font-semibold">Combined register total</dt>
<dd className="text-lg tabular-nums">{fmt(current?.checkoutTotal ?? 0)}</dd>
</div>
</dl>
<dl className="mt-4 grid grid-cols-2 gap-3 rounded-md border border-line bg-surface p-3 font-sans text-xs">
<div>
<dt className="text-ink-faint">Food eaten</dt>
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.usedTotal ?? 0)}</dd>
</div>
<div>
<dt className="text-ink-faint">Kept in pantry</dt>
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.carryoverValue ?? 0)}</dd>
</div>
</dl>
</div>
<div className="mt-4 border-t border-line pt-3 font-sans text-xs text-ink-muted">
{current && current.sharedNames.length > 0 ? (
<p className="flex items-start gap-1.5">
<Repeat2 className="mt-0.5 size-3.5 shrink-0 text-status-ok" strokeWidth={2} aria-hidden />
<span>
<strong className="font-semibold text-ink">Reused across meals:</strong> {current.sharedNames.slice(0, 5).join(", ")}
{current.sharedNames.length > 5 ? ` +${current.sharedNames.length - 5} more` : ""}
</span>
</p>
) : (
<p>Select a second dish to see which store packages overlap in your cart.</p>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,246 @@
"use client";
import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { generateMealPlanAction } from "@/app/actions";
import { Cart, buildGroceryList } from "@/app/lib/grocery";
import { activeRecipeIds } from "@/app/lib/plan";
import { averageGroceryProvider } from "@/app/lib/pricing";
import { RECIPES } from "@/app/lib/recipes";
import { usePlanState } from "@/app/lib/usePlanState";
import type { FillMode } from "@/app/types";
import { BudgetMeter } from "./BudgetMeter";
import { ConfigPanel } from "./ConfigPanel";
import { GroceryListDrawer, GrocerySummaryPanel } from "./GroceryListDrawer";
import { IngredientPanel } from "./IngredientPanel";
import { MealGrid } from "./MealGrid";
import { MealSelectorModal } from "./MealSelectorModal";
import { Navbar } from "./Navbar";
import { RecipeDialog } from "./RecipeDialog";
interface UndoSnapshot {
message: string;
previousSlots: Record<string, string>;
}
export default function PlannerDashboard() {
const { state, dispatch, syncStatus } = usePlanState();
const [isGenerating, startGenerating] = useTransition();
const [notes, setNotes] = useState<string[]>([]);
const [openSlot, setOpenSlot] = useState<string | null>(null);
const [viewSlot, setViewSlot] = useState<string | null>(null);
const [groceriesOpen, setGroceriesOpen] = useState(false);
const [undo, setUndo] = useState<UndoSnapshot | null>(null);
// Auto-dismiss undo toast after 6 seconds
useEffect(() => {
if (!undo) return;
const timer = window.setTimeout(() => setUndo(null), 6000);
return () => window.clearTimeout(timer);
}, [undo]);
const summary = useMemo(
() => buildGroceryList(activeRecipeIds(state), { householdSize: state.householdSize, onHandItems: state.onHandItems }),
[state],
);
const mealCosts = useMemo(() => {
const emptyCart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems });
return new Map(RECIPES.map((r) => [r.id, emptyCart.marginal(r.id).eaten]));
}, [state.householdSize, state.onHandItems]);
function handleGenerate(fillMode: FillMode) {
startGenerating(async () => {
try {
const result = await generateMealPlanAction({ state, fillMode, seed: Date.now() });
dispatch({ type: "replaceSlots", slots: result.slots });
setNotes(result.notes);
setUndo(null);
} catch {
setNotes(["Couldn't reach the planner. Check your connection and try again."]);
}
});
}
const handleRemoveSlot = useCallback(
(key: string, recipeTitle: string) => {
setUndo({ message: `Removed "${recipeTitle}"`, previousSlots: { ...state.slots } });
dispatch({ type: "setSlot", key, recipeId: null });
},
[dispatch, state.slots],
);
const handleClearWeek = useCallback(() => {
const count = activeRecipeIds(state).length;
if (count === 0) return;
setUndo({ message: `Cleared ${count} planned meal${count === 1 ? "" : "s"}`, previousSlots: { ...state.slots } });
dispatch({ type: "clearSlots" });
}, [dispatch, state]);
const handleUndo = useCallback(() => {
if (!undo) return;
dispatch({ type: "replaceSlots", slots: undo.previousSlots });
setUndo(null);
}, [dispatch, undo]);
const closeSelector = useCallback(() => setOpenSlot(null), []);
const closeRecipe = useCallback(() => setViewSlot(null), []);
const closeGroceries = useCallback(() => setGroceriesOpen(false), []);
const toggleOnHand = useCallback(
(itemId: string, onHand: boolean) => dispatch({ type: "setItemPreference", itemIds: [itemId], preference: onHand ? "have" : "use" }),
[dispatch],
);
const mealCount = activeRecipeIds(state).length;
return (
<>
<Navbar checkoutTotal={summary.checkoutTotal} budget={state.targetBudget} syncStatus={syncStatus} isGenerating={isGenerating} />
<main className="mx-auto max-w-7xl px-4 pt-6 pb-28 sm:px-6 lg:pb-12">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left: compact control bar -> immediate MealGrid -> Pantry below */}
<div className="space-y-6 lg:col-span-2">
<ConfigPanel
state={state}
dispatch={dispatch}
onGenerate={handleGenerate}
onClear={handleClearWeek}
isGenerating={isGenerating}
/>
{notes.length > 0 && (
<div
role="status"
className="flex items-start justify-between gap-3 rounded-xl border border-status-warn-line bg-status-warn-bg p-3.5 text-sm text-status-warn"
>
<div className="flex items-start gap-2.5">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
<ul className="space-y-1">
{notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
</div>
<button
type="button"
onClick={() => setNotes([])}
aria-label="Dismiss notice"
className="grid size-8 shrink-0 place-items-center rounded-lg hover:bg-black/5"
>
<X className="size-4" aria-hidden />
</button>
</div>
)}
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
<MealGrid
state={state}
dispatch={dispatch}
mealCosts={mealCosts}
onOpenSlot={setOpenSlot}
onViewSlot={setViewSlot}
onRemoveSlot={handleRemoveSlot}
/>
</div>
<IngredientPanel state={state} dispatch={dispatch} />
</div>
{/* Right: persistent Market Receipt summary (desktop) */}
<aside className="hidden lg:block">
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-line bg-surface p-5">
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
<GrocerySummaryPanel
summary={summary}
budget={state.targetBudget}
priceSourceLabel={averageGroceryProvider.label}
onToggleOnHand={toggleOnHand}
/>
</div>
</aside>
</div>
</main>
{/* Undo Toast for accidental meal removal or Clear */}
{undo && (
<div
role="status"
aria-live="polite"
className="fixed bottom-20 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-line-strong bg-surface px-4 py-2.5 text-sm shadow-lg lg:bottom-6"
>
<span className="text-ink">{undo.message}</span>
<button
type="button"
onClick={handleUndo}
className="trim-cap inline-flex min-h-8 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-on-accent hover:bg-brand-500"
>
<RotateCcw className="size-3.5" aria-hidden />
Undo
</button>
<button
type="button"
onClick={() => setUndo(null)}
aria-label="Dismiss undo"
className="grid size-7 place-items-center rounded text-ink-faint hover:text-ink"
>
<X className="size-3.5" aria-hidden />
</button>
</div>
)}
{/* Mobile: sticky bottom bar */}
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-surface/95 px-4 py-3 backdrop-blur-md lg:hidden">
<div className="mx-auto flex max-w-7xl items-center gap-4">
<div className="flex-1">
<BudgetMeter spent={summary.checkoutTotal} budget={state.targetBudget} compact />
</div>
<button
type="button"
onClick={() => setGroceriesOpen(true)}
className="trim-cap inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs hover:bg-brand-500"
>
<ShoppingBag className="size-4" strokeWidth={2} aria-hidden />
Receipt ({summary.lines.length})
</button>
</div>
<p className="sr-only" aria-live="polite">
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars
</p>
</div>
<GroceryListDrawer
open={groceriesOpen}
onClose={closeGroceries}
summary={summary}
budget={state.targetBudget}
priceSourceLabel={averageGroceryProvider.label}
onToggleOnHand={toggleOnHand}
/>
<RecipeDialog
slotKey={viewSlot}
state={state}
onClose={closeRecipe}
onSwap={(key) => {
setViewSlot(null);
setOpenSlot(key);
}}
onRemove={(key, title) => {
handleRemoveSlot(key, title);
setViewSlot(null);
}}
/>
<MealSelectorModal
slotKey={openSlot}
state={state}
onClose={closeSelector}
onPick={(recipeId) => {
if (openSlot) dispatch({ type: "setSlot", key: openSlot, recipeId });
setOpenSlot(null);
}}
/>
</>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import dynamic from "next/dynamic";
const PlannerDashboard = dynamic(() => import("./PlannerDashboard"), {
ssr: false,
loading: () => <DashboardSkeleton />,
});
export function PlannerLoader() {
return <PlannerDashboard />;
}
function DashboardSkeleton() {
return (
<div className="animate-pulse" aria-busy="true" aria-label="Loading planner">
<div className="h-15 border-b border-line bg-surface" />
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-4 pt-6 sm:px-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<div className="h-36 rounded-xl bg-raised" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="h-44 rounded-xl bg-raised" />
))}
</div>
</div>
<div className="hidden h-[32rem] rounded-xl bg-raised lg:block" />
</div>
</div>
);
}

View file

@ -0,0 +1,95 @@
"use client";
import { useRef, type KeyboardEvent, type ReactNode } from "react";
export interface RadioOption<T extends string | number> {
value: T;
label: ReactNode;
activeClass?: string;
}
interface RadioGroupProps<T extends string | number> {
name: string;
options: readonly RadioOption<T>[];
value: T;
onChange: (value: T) => void;
size?: "xs" | "sm";
fullWidth?: boolean;
}
/**
* Accessible segmented control implementing WAI-ARIA `radiogroup` roving tabIndex
* and arrow/Home/End keyboard navigation (1 Tab stop per group instead of N).
*/
export function RadioGroup<T extends string | number>({
name,
options,
value,
onChange,
size = "sm",
fullWidth = false,
}: RadioGroupProps<T>) {
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const currentIndex = Math.max(0, options.findIndex((o) => o.value === value));
let nextIndex = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
nextIndex = (currentIndex + 1) % options.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
nextIndex = (currentIndex - 1 + options.length) % options.length;
} else if (e.key === "Home") {
nextIndex = 0;
} else if (e.key === "End") {
nextIndex = options.length - 1;
}
if (nextIndex !== -1) {
e.preventDefault();
onChange(options[nextIndex].value);
buttonsRef.current[nextIndex]?.focus();
}
}
const pad =
size === "xs"
? fullWidth
? "min-h-8 flex-1 min-w-0 px-1.5 py-1 text-xs"
: "min-h-8 px-2.5 py-1 text-xs"
: fullWidth
? "min-h-9 flex-1 min-w-0 px-2 py-1.5 text-sm"
: "min-h-9 min-w-9 px-3 py-1.5 text-sm";
return (
<div
role="radiogroup"
aria-label={name}
onKeyDown={handleKeyDown}
className={`${fullWidth ? "flex w-full" : "inline-flex shrink-0"} max-w-full rounded-lg border border-line-strong bg-raised p-0.5`}
>
{options.map((option, i) => {
const checked = option.value === value;
const activeStyle = option.activeClass ?? "bg-surface text-ink border border-selected-line/60 shadow-2xs";
return (
<button
key={String(option.value)}
ref={(el) => {
buttonsRef.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
onClick={() => onChange(option.value)}
className={`trim-cap inline-flex items-center justify-center rounded-md font-medium tabular-nums transition-colors ${pad} ${
checked ? activeStyle : "border border-transparent text-ink-muted hover:text-ink"
}`}
>
{option.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,231 @@
"use client";
import { BookHeart, Search, SlidersHorizontal } from "lucide-react";
import Link from "next/link";
import { ViewTransition, useMemo, useState, type ReactNode } from "react";
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES, toggle } from "@/app/lib/plan";
import type { Appliance, CookingStyle, Cuisine, MealKind } from "@/app/types";
import { Chip } from "./Chip";
import { ApplianceIcon, StyleIcon } from "./Icons";
export interface RecipeSummary {
id: string;
title: string;
description: string;
cuisine: Cuisine;
kind: MealKind;
styles: CookingStyle[];
appliances: Appliance[];
prepTime: number;
ingredientText: string;
}
type KindFilter = "all" | MealKind;
type SortOrder = "az" | "quickest";
export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState<KindFilter>("all");
const [cuisines, setCuisines] = useState<Cuisine[]>([]);
const [styles, setStyles] = useState<CookingStyle[]>([]);
const [kitchen, setKitchen] = useState<Appliance[]>([]);
const [sort, setSort] = useState<SortOrder>("az");
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
return recipes
.filter((r) => kind === "all" || r.kind === kind)
.filter((r) => cuisines.length === 0 || cuisines.includes(r.cuisine))
.filter((r) => styles.length === 0 || r.styles.some((s) => styles.includes(s)))
.filter((r) => kitchen.length === 0 || r.appliances.every((a) => kitchen.includes(a)))
.filter((r) => !needle || r.title.toLowerCase().includes(needle) || r.ingredientText.includes(needle))
.sort((a, b) => (sort === "quickest" ? a.prepTime - b.prepTime : 0) || a.title.localeCompare(b.title));
}, [recipes, query, kind, cuisines, styles, kitchen, sort]);
const activeChipCount = (kind !== "all" ? 1 : 0) + cuisines.length + styles.length + kitchen.length;
const filtersActive = Boolean(query) || activeChipCount > 0;
const clearAll = () => {
setQuery("");
setKind("all");
setCuisines([]);
setStyles([]);
setKitchen([]);
};
return (
<div className="grid gap-8 lg:grid-cols-[18.5rem_1fr]">
{/* --- Search + Collapsible Mobile Filters / Sticky Desktop Sidebar --- */}
<aside className="space-y-4 lg:sticky lg:top-20 lg:self-start">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
<input
type="search"
placeholder="Search dishes or ingredients…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="min-h-10 w-full rounded-lg border border-line-strong py-2 pr-3 pl-9 text-sm"
aria-label="Search recipes or ingredients"
/>
</div>
<button
type="button"
aria-expanded={mobileFiltersOpen}
onClick={() => setMobileFiltersOpen((o) => !o)}
className={`trim-cap inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium lg:hidden ${
mobileFiltersOpen || activeChipCount > 0
? "border-selected-line bg-selected text-ink"
: "border-line-strong bg-surface text-ink-muted"
}`}
>
<SlidersHorizontal className="size-4" aria-hidden />
Filters
{activeChipCount > 0 && (
<span className="rounded-full bg-brand-600 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-on-accent">
{activeChipCount}
</span>
)}
</button>
</div>
<div className={`space-y-5 ${mobileFiltersOpen ? "block rounded-xl border border-line bg-surface p-4" : "hidden"} lg:block lg:border-0 lg:bg-transparent lg:p-0`}>
<FilterGroup label="Course">
{(
[
["all", "All"],
["breakfast", "Breakfast"],
["main", "Lunch & dinner"],
["side", "Sides"],
["dessert", "Desserts"],
] as const
).map(([value, label]) => (
<Chip size="sm" key={value} pressed={kind === value} onClick={() => setKind(value)}>
{label}
</Chip>
))}
</FilterGroup>
<FilterGroup label="Cuisine">
{CUISINES.map((c) => (
<Chip size="sm" key={c.id} pressed={cuisines.includes(c.id)} onClick={() => setCuisines((l) => toggle(l, c.id))}>
{c.label}
</Chip>
))}
</FilterGroup>
<FilterGroup label="Style">
{STYLES.map((s) => (
<Chip size="sm" key={s.id} pressed={styles.includes(s.id)} onClick={() => setStyles((l) => toggle(l, s.id))}>
<StyleIcon id={s.id} className="size-3.5" />
{s.label}
</Chip>
))}
</FilterGroup>
<FilterGroup label="Available appliances" hint="Hides recipes needing anything else">
{APPLIANCES.map((a) => (
<Chip size="sm" key={a.id} pressed={kitchen.includes(a.id)} onClick={() => setKitchen((l) => toggle(l, a.id))}>
<ApplianceIcon id={a.id} className="size-3.5" />
{a.label}
</Chip>
))}
</FilterGroup>
</div>
</aside>
{/* --- Results -------------------------------------------------------- */}
<section aria-label="Recipes">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-line pb-3">
<p className="text-sm text-ink-muted" aria-live="polite">
Showing <span className="font-mono font-semibold text-ink">{visible.length}</span> of{" "}
<span className="font-mono">{recipes.length}</span> recipes
{filtersActive && (
<button type="button" onClick={clearAll} className="ml-3 font-medium text-brand-300 hover:underline">
Reset filters
</button>
)}
</p>
<label className="flex items-center gap-2 text-sm text-ink-muted">
Sort
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOrder)}
className="min-h-9 rounded-lg border border-line-strong bg-surface px-2.5 py-1 text-sm font-medium text-ink"
>
<option value="az">A–Z</option>
<option value="quickest">Quickest first</option>
</select>
</label>
</div>
{visible.length === 0 ? (
<p className="rounded-xl border border-dashed border-line-strong p-10 text-center text-ink-muted">
No recipes match these filters. Try relaxing an appliance or cuisine filter.
</p>
) : (
<ul className="grid gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((r) => {
const applianceNames = r.appliances.map((a) => APPLIANCES.find((x) => x.id === a)?.label).join(", ") || "No cooking";
return (
<li key={r.id}>
<Link
href={`/recipes/${r.id}`}
className="group flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
>
<span className="flex items-center gap-1.5 font-mono text-[11px] tracking-wide text-ink-faint uppercase">
{r.styles.includes("caydens_corner") && (
<BookHeart className="size-3.5 shrink-0 text-brand-300" strokeWidth={2} aria-hidden />
)}
<span>
{CUISINES.find((c) => c.id === r.cuisine)?.label} · {MEAL_KIND_LABELS[r.kind]}
</span>
</span>
<ViewTransition name={`recipe-title-${r.id}`}>
<span className="mt-1 font-serif text-lg leading-snug font-semibold text-ink group-hover:text-brand-300">
{r.title}
</span>
</ViewTransition>
<span className="mt-1.5 line-clamp-2 text-sm leading-relaxed text-ink-muted">{r.description}</span>
<span className="mt-auto flex items-center justify-between border-t border-line pt-3 mt-4 text-xs text-ink-faint">
<span className="font-mono tabular-nums">{r.prepTime} min</span>
<span className="inline-flex items-center gap-1.5" title={applianceNames}>
{r.appliances.length > 0 ? (
<>
{r.appliances.map((a) => (
<ApplianceIcon key={a} id={a} className="size-3.5 text-ink-muted" />
))}
<span className="sr-only">Requires {applianceNames}</span>
</>
) : (
<span>No cooking</span>
)}
</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
</div>
);
}
function FilterGroup({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<fieldset>
<legend className="text-xs font-semibold tracking-wider text-ink-muted uppercase">
{label} {hint && <span className="font-sans font-normal tracking-normal text-ink-faint normal-case">· {hint}</span>}
</legend>
<div className="mt-2 flex flex-wrap gap-1.5">{children}</div>
</fieldset>
);
}

View file

@ -0,0 +1,229 @@
"use client";
import { Check, Lightbulb } from "lucide-react";
import { useState } from "react";
import { ingredientLines } from "@/app/lib/display";
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan";
import type { Recipe } from "@/app/types";
import { ApplianceIcon, StyleIcon } from "./Icons";
interface RecipeDetailProps {
recipe: Recipe;
/** People to scale ingredient amounts for. */
servings: number;
/** Hide the cuisine/time/appliance chips when surrounding UI already shows them. */
showMeta?: boolean;
/** Smaller type for use inside lists. */
compact?: boolean;
/** Allow tapping ingredients & steps to check them off while cooking. */
interactive?: boolean;
}
export function RecipeDetail({
recipe,
servings,
showMeta = true,
compact = false,
interactive = false,
}: RecipeDetailProps) {
const lines = ingredientLines(recipe, servings);
const text = compact ? "text-sm" : "text-base";
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(() => new Set());
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(() => new Set());
function toggleSet(setter: React.Dispatch<React.SetStateAction<Set<number>>>, index: number) {
setter((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
}
return (
<div className={compact ? "space-y-4" : "space-y-8"}>
{showMeta && <RecipeMeta recipe={recipe} />}
<div className={compact ? "grid gap-5 md:grid-cols-[2fr_3fr]" : "grid gap-8 md:grid-cols-[2fr_3fr]"}>
{/* --- Ingredients ------------------------------------------------- */}
<section>
<div className="flex items-baseline justify-between gap-2">
<h3 className={`font-serif font-semibold ${compact ? "text-base" : "text-xl"}`}>
Ingredients{" "}
<span className="font-sans text-sm font-normal text-ink-muted">
· {servings} serving{servings === 1 ? "" : "s"}
</span>
</h3>
{interactive && checkedIngredients.size > 0 && (
<button
type="button"
onClick={() => setCheckedIngredients(new Set())}
className="text-xs font-medium text-brand-300 hover:underline"
>
Reset
</button>
)}
</div>
<ul className={`mt-3 divide-y divide-line ${text}`}>
{lines.map((line, i) => {
const done = interactive && checkedIngredients.has(i);
const content = (
<>
<span className="w-20 shrink-0 text-right font-mono text-sm font-semibold text-ink tabular-nums">
{line.quantity} {line.unit}
</span>
<span className={done ? "text-ink-faint line-through" : "text-ink"}>
{line.name}
{line.detail && <span className="text-ink-muted"> ({line.detail})</span>}
</span>
</>
);
return (
<li key={i}>
{interactive ? (
<button
type="button"
aria-pressed={done}
onClick={() => toggleSet(setCheckedIngredients, i)}
className="flex min-h-10 w-full items-baseline gap-3 py-2 text-left transition-colors hover:bg-raised/50"
>
{content}
</button>
) : (
<div className="flex items-baseline gap-3 py-1.5">{content}</div>
)}
</li>
);
})}
</ul>
</section>
{/* --- Instructions ------------------------------------------------ */}
<section>
<div className="flex items-baseline justify-between gap-2">
<h3 className={`font-serif font-semibold ${compact ? "text-base" : "text-xl"}`}>Instructions</h3>
{interactive && checkedSteps.size > 0 && (
<button
type="button"
onClick={() => setCheckedSteps(new Set())}
className="text-xs font-medium text-brand-300 hover:underline"
>
Reset steps
</button>
)}
</div>
{servings !== recipe.servings && (
<p className="mt-2.5 rounded-lg border border-line-strong/50 bg-raised px-3 py-2 text-xs text-ink-muted">
Ingredient quantities on the left are scaled to <strong className="font-semibold text-ink">{servings}</strong>.
Step prose is written for the original {recipe.servings}-serving batch—scale pan sizes or liquid mentions the same way.
</p>
)}
<ol className={`mt-3 space-y-3 ${text}`}>
{recipe.steps.map((step, i) => {
const done = interactive && checkedSteps.has(i);
const badge = (
<span
className={`grid size-6 shrink-0 place-items-center rounded-full font-mono text-xs font-semibold transition-colors ${
done
? "bg-status-ok text-canvas"
: "border border-selected-line/50 bg-selected text-ink"
}`}
>
{done ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : i + 1}
</span>
);
return (
<li key={i}>
{interactive ? (
<button
type="button"
aria-pressed={done}
onClick={() => toggleSet(setCheckedSteps, i)}
className="flex w-full items-start gap-3 rounded-lg p-1.5 -m-1.5 text-left transition-colors hover:bg-raised/50"
>
{badge}
<span className={`pt-0.5 leading-relaxed ${done ? "text-ink-faint line-through" : "text-ink"}`}>{step}</span>
</button>
) : (
<div className="flex items-start gap-3">
{badge}
<span className="pt-0.5 leading-relaxed">{step}</span>
</div>
)}
</li>
);
})}
</ol>
{recipe.tip && (
<div className="mt-5 flex items-start gap-2.5 rounded-lg border border-brand-border bg-brand-950 px-3.5 py-3 text-sm text-ink">
<Lightbulb className="mt-0.5 size-4 shrink-0 text-brand-300" strokeWidth={2} aria-hidden />
<p>
<span className="font-semibold text-brand-300">Cook&apos;s note:</span> {recipe.tip}
</p>
</div>
)}
</section>
</div>
</div>
);
}
export function RecipeMeta({ recipe }: { recipe: Recipe }) {
const cuisineLabel = CUISINES.find((c) => c.id === recipe.cuisine)?.label;
return (
<ul className="flex flex-wrap gap-1.5">
{cuisineLabel && (
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs font-medium text-ink">
{cuisineLabel}
</li>
)}
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">
{MEAL_KIND_LABELS[recipe.kind]}
</li>
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 font-mono text-xs text-ink-muted">
{recipe.prepTime} min
</li>
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">
Serves {recipe.servings}
</li>
{recipe.requiredAppliances.length === 0 ? (
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">No cooking</li>
) : (
recipe.requiredAppliances.map((a) => {
const label = APPLIANCES.find((x) => x.id === a)?.label ?? a;
return (
<li
key={a}
className="inline-flex items-center gap-1.5 rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted"
>
<ApplianceIcon id={a} className="size-3.5 text-ink" />
{label}
</li>
);
})
)}
{recipe.styles.map((s) => {
const label = STYLES.find((x) => x.id === s)?.label;
if (!label) return null;
return (
<li
key={s}
className="inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink-muted"
>
<StyleIcon id={s} className="size-3.5 text-brand-300" />
{label}
</li>
);
})}
</ul>
);
}

View file

@ -0,0 +1,111 @@
"use client";
import { AlertTriangle, ArrowRight, ArrowRightLeft, Trash2, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { DAY_NAMES, describeBlockers, isCookable, mealLabel, recipeBlockers } from "@/app/lib/plan";
import { RECIPES_BY_ID } from "@/app/lib/recipes";
import type { MealPlanState } from "@/app/types";
import { RecipeDetail, RecipeMeta } from "./RecipeDetail";
interface RecipeDialogProps {
slotKey: string | null;
state: MealPlanState;
onClose: () => void;
onSwap: (slotKey: string) => void;
onRemove: (slotKey: string, recipeTitle: string) => void;
}
export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: RecipeDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const recipe = slotKey ? RECIPES_BY_ID.get(state.slots[slotKey]) : undefined;
const open = Boolean(slotKey && recipe);
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]);
const [dayIndex, mealNumber] = (slotKey ?? "0-1").split("-").map(Number);
return (
<dialog
ref={dialogRef}
onClose={onClose}
onClick={(e) => e.target === dialogRef.current && onClose()}
aria-labelledby="recipe-dialog-title"
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-2xl"
>
{open && recipe && slotKey && (
<div className="p-5 sm:p-7">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">
{DAY_NAMES[dayIndex]} · {mealLabel(mealNumber)}
</p>
<h2 id="recipe-dialog-title" className="mt-0.5 font-serif text-2xl font-semibold tracking-tight sm:text-3xl">
{recipe.title}
</h2>
<p className="mt-1.5 text-ink-muted">{recipe.description}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
<X className="size-5" aria-hidden />
</button>
</div>
<div className="mt-4">
<RecipeMeta recipe={recipe} />
</div>
{!isCookable(recipe, state) && (
<p className="mt-4 flex items-center gap-2 rounded-lg border border-status-warn-line bg-status-warn-bg px-3.5 py-2.5 text-sm text-status-warn">
<AlertTriangle className="size-4 shrink-0" aria-hidden />
<span>{describeBlockers(recipeBlockers(recipe, state))}. Consider swapping this meal.</span>
</p>
)}
<div className="mt-6">
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} interactive />
</div>
<div className="mt-8 flex flex-wrap items-center gap-2.5 border-t border-line pt-4">
<button
type="button"
onClick={() => onSwap(slotKey)}
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
>
<ArrowRightLeft className="size-4" aria-hidden />
Replace dish
</button>
<button
type="button"
onClick={() => onRemove(slotKey, recipe.title)}
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium text-ink-muted transition-colors hover:bg-status-danger-bg hover:text-status-danger"
>
<Trash2 className="size-4" aria-hidden />
Remove from plan
</button>
<Link
href={`/recipes/${recipe.id}`}
onClick={onClose}
className="ml-auto inline-flex min-h-10 items-center gap-1.5 text-sm font-medium text-brand-300 hover:underline"
>
Full recipe &amp; cook mode
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</div>
)}
</dialog>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import { Flame, Minus, Plus } from "lucide-react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { Recipe } from "@/app/types";
import { RecipeDetail } from "./RecipeDetail";
const noopSubscribe = () => () => {};
/** RecipeDetail with an accessible servings stepper + Screen Wake Lock ("Cook mode") toggle. */
export function RecipeServings({ recipe }: { recipe: Recipe }) {
const [servings, setServings] = useState(recipe.servings);
const [cookMode, setCookMode] = useState(false);
const wakeSupported = useSyncExternalStore(noopSubscribe, () => "wakeLock" in navigator, () => false);
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
if (!cookMode || !wakeSupported) {
wakeLockRef.current?.release().catch(() => {});
wakeLockRef.current = null;
return;
}
let cancelled = false;
async function acquire() {
try {
const sentinel = await navigator.wakeLock.request("screen");
if (cancelled) {
await sentinel.release();
return;
}
wakeLockRef.current = sentinel;
} catch {
// Battery saver or background tab can refuse wakeLock; fail gracefully.
}
}
acquire();
const onVisibility = () => {
if (document.visibilityState === "visible" && cookMode) acquire();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", onVisibility);
wakeLockRef.current?.release().catch(() => {});
wakeLockRef.current = null;
};
}, [cookMode, wakeSupported]);
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 border-b border-line pb-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-ink-muted">Scale servings</span>
<div className="inline-flex items-center rounded-lg border border-line-strong bg-raised p-0.5">
<button
type="button"
onClick={() => setServings((s) => Math.max(1, s - 1))}
className="grid size-9 place-items-center rounded-md text-ink-muted transition-colors hover:bg-surface hover:text-ink"
aria-label="Fewer servings"
>
<Minus className="size-4" aria-hidden />
</button>
<span className="min-w-9 text-center font-mono text-sm font-semibold tabular-nums" aria-live="polite">
{servings}
</span>
<button
type="button"
onClick={() => setServings((s) => Math.min(12, s + 1))}
className="grid size-9 place-items-center rounded-md text-ink-muted transition-colors hover:bg-surface hover:text-ink"
aria-label="More servings"
>
<Plus className="size-4" aria-hidden />
</button>
</div>
<span className="hidden text-xs text-ink-faint sm:inline">Tap any ingredient or step below to check it off</span>
</div>
{wakeSupported && (
<button
type="button"
aria-pressed={cookMode}
onClick={() => setCookMode((c) => !c)}
className={`trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-semibold transition-colors ${
cookMode
? "border-status-ok-line bg-status-ok-bg text-status-ok"
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
}`}
>
<Flame className="size-3.5" aria-hidden />
{cookMode ? "Screen kept awake" : "Keep screen awake"}
</button>
)}
</div>
<RecipeDetail recipe={recipe} servings={servings} showMeta={false} interactive />
</div>
);
}

View file

@ -0,0 +1,123 @@
"use client";
import { Pause, Play, Repeat2, ShoppingBag } from "lucide-react";
import { useEffect, useState } from "react";
export interface PreviewSet {
theme: string;
blurb: string;
meals: { slot: string; title: string; cost: string }[];
total: string;
sharedItems: number;
itemCount: number;
}
const INTERVAL_MS = 5000;
export function RotatingPreview({ sets }: { sets: PreviewSet[] }) {
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [hovering, setHovering] = useState(false);
const running = !paused && !hovering;
useEffect(() => {
if (!running || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = window.setInterval(() => setIndex((i) => (i + 1) % sets.length), INTERVAL_MS);
return () => window.clearInterval(timer);
}, [running, index, sets.length]);
const set = sets[index];
return (
<section
aria-label="Example meal plans"
className="relative"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onFocus={() => setHovering(true)}
onBlur={() => setHovering(false)}
>
{/* Architectural editorial frame — crisp 1px border & tactile offset shadow instead of blurred glow blobs */}
<div className="rounded-xl border border-line-strong bg-surface shadow-[6px_6px_0_0_var(--color-line)]">
{/* Top progress rule */}
<div className="h-1 overflow-hidden rounded-t-xl bg-raised">
<div
key={index}
className="h-full origin-left animate-preview-timer bg-brand-600"
style={{ animationDuration: `${INTERVAL_MS}ms`, animationPlayState: running ? "running" : "paused" }}
/>
</div>
<div className="p-5 sm:p-6">
<div key={set.theme} className="animate-fade-in">
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
<div>
<p className="font-mono text-[11px] tracking-wider text-ink-faint uppercase">Sample menu · 2 people</p>
<h2 className="mt-0.5 font-serif text-xl font-semibold tracking-tight text-ink">{set.theme}</h2>
</div>
<span className="rounded-md border border-line bg-raised px-2.5 py-1 text-xs font-medium text-ink-muted">
{set.blurb}
</span>
</div>
<ul className="divide-y divide-line">
{set.meals.map((m) => (
<li key={m.slot} className="flex items-baseline justify-between gap-4 py-3">
<div className="min-w-0">
<p className="font-mono text-[11px] tracking-wide text-ink-faint uppercase">{m.slot}</p>
<p className="truncate font-medium text-ink">{m.title}</p>
</div>
<p className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">{m.cost}/meal</p>
</li>
))}
</ul>
{/* Receipt tear-off footer */}
<div className="receipt-rule mt-2 pt-3.5">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-raised px-3.5 py-2.5 text-xs">
<span className="inline-flex items-center gap-1.5 font-medium text-status-ok">
<Repeat2 className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
{set.sharedItems} shared store items
</span>
<span className="inline-flex items-center gap-1.5 font-mono font-semibold text-ink tabular-nums">
<ShoppingBag className="size-3.5 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
{set.itemCount} pkgs · {set.total}
</span>
</div>
</div>
</div>
{/* Accessible 40x40px pagination & playback controls */}
<div className="mt-3 flex items-center justify-between border-t border-line pt-2">
<div className="flex items-center">
{sets.map((s, i) => (
<button
key={s.theme}
type="button"
onClick={() => setIndex(i)}
aria-label={`Show example: ${s.theme}`}
aria-current={i === index}
className="grid size-9 place-items-center rounded-md hover:bg-raised"
>
<span
className={`block h-2 rounded-full transition-all ${
i === index ? "w-5 bg-brand-600" : "w-2 bg-line-strong"
}`}
/>
</button>
))}
</div>
<button
type="button"
onClick={() => setPaused((p) => !p)}
className="trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
{paused ? <Play className="size-3.5" aria-hidden /> : <Pause className="size-3.5" aria-hidden />}
{paused ? "Play" : "Pause"}
</button>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,109 @@
import { BookHeart, BookOpen, CalendarRange, UtensilsCrossed } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { ThemeToggle } from "./ThemeToggle";
export type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
export function Logo({ small = false, subtitle }: { small?: boolean; subtitle?: ReactNode }) {
return (
<div className="flex shrink-0 items-center gap-2.5">
<Link
href="/"
aria-label="Plan my Meals home"
className={`grid shrink-0 place-items-center rounded-lg border border-brand-border bg-brand-950 text-brand-300 transition-colors hover:bg-selected ${
small ? "size-7" : "size-9"
}`}
>
<UtensilsCrossed className={small ? "size-3.5" : "size-4"} strokeWidth={2} aria-hidden />
</Link>
<div className="min-w-0">
<Link
href="/"
className={`block font-serif leading-tight font-semibold tracking-tight whitespace-nowrap hover:text-brand-300 ${
small ? "text-sm text-ink-muted" : "text-lg text-ink"
}`}
>
Plan my Meals
</Link>
<div className="flex items-center gap-1.5 text-xs leading-tight text-ink-faint whitespace-nowrap">
<span>
by{" "}
<a
href="https://caydens.dev"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-ink-muted transition-colors hover:text-brand-300 hover:underline"
>
Cayden Codes
</a>
</span>
{subtitle && (
<>
<span aria-hidden>·</span>
{subtitle}
</>
)}
</div>
</div>
</div>
);
}
interface SiteHeaderProps {
active?: ActiveNav;
subtitle?: ReactNode;
rightSlot?: ReactNode;
}
export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderProps) {
const linkClass = (isCurrent: boolean) =>
`trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors sm:px-3 ${
isCurrent ? "bg-selected text-ink" : "text-ink-muted hover:bg-raised hover:text-ink"
}`;
return (
<header className="sticky top-0 z-30 border-b border-line bg-surface/90 backdrop-blur-md">
<div className="mx-auto flex max-w-7xl items-center gap-2 px-4 py-2.5 sm:gap-4 sm:px-6">
<Logo subtitle={subtitle} />
<nav aria-label="Primary" className="ml-auto flex items-center gap-1 whitespace-nowrap">
<Link
href="/recipes"
aria-current={active === "recipes" ? "page" : undefined}
className={linkClass(active === "recipes")}
title="Recipe library"
>
<BookOpen className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
<span className="sr-only md:not-sr-only">Recipes</span>
</Link>
<Link
href="/caydens-corner"
aria-current={active === "caydens-corner" ? "page" : undefined}
className={linkClass(active === "caydens-corner")}
title="Cayden's Corner"
>
<BookHeart className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
<span className="sr-only md:not-sr-only">Cayden&apos;s Corner</span>
</Link>
{active !== "plan" && (
<Link
href="/plan"
className="trim-cap ml-1 inline-flex min-h-10 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 sm:px-4"
>
<CalendarRange className="size-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="sm:hidden">Planner</span>
<span className="hidden sm:inline">Open planner</span>
</Link>
)}
</nav>
{rightSlot}
<ThemeToggle />
</div>
</header>
);
}

View file

@ -0,0 +1,53 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useSyncExternalStore } from "react";
const STORAGE_KEY = "plan-my-meals:theme";
const EVENT_NAME = "plan-my-meals:theme-change";
type ResolvedTheme = "light" | "dark";
function getSnapshot(): ResolvedTheme {
const explicit = document.documentElement.dataset.theme;
if (explicit === "light" || explicit === "dark") return explicit;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function subscribe(callback: () => void): () => void {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", callback);
window.addEventListener(EVENT_NAME, callback);
return () => {
mq.removeEventListener("change", callback);
window.removeEventListener(EVENT_NAME, callback);
};
}
export function ThemeToggle() {
// Server snapshot returns null so hydration never mismatches; button renders a stable shell first.
const theme = useSyncExternalStore<ResolvedTheme | null>(subscribe, getSnapshot, () => null);
function toggleTheme() {
const next: ResolvedTheme = (theme ?? getSnapshot()) === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Ignore blocked storage; dataset.theme still updates the active session.
}
window.dispatchEvent(new Event(EVENT_NAME));
}
return (
<button
type="button"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Switch to light paper theme" : "Switch to dark slate theme"}
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className="grid size-10 shrink-0 place-items-center rounded-lg border border-line bg-surface text-ink-muted transition-colors hover:border-line-strong hover:bg-raised hover:text-ink"
>
{theme === "dark" ? <Sun className="size-4" strokeWidth={1.75} aria-hidden /> : <Moon className="size-4" strokeWidth={1.75} aria-hidden />}
</button>
);
}