- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button, SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's color aliases onto BoardUI tokens and swap lucide for Remix icons - Retint BoardUI's neutral ramp to cool slate with a salmon accent; drop the dotted background; raise muted text contrast - Simplify the landing page and remove the overlap explorer - Planner: explicit "I have this" toggle on grocery lines, pantry panel above the menu with the staples list spelled out, sticky aisle header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded) - Recipes: replace the family cabbage soup with Cade's Cabbage Soup (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup - Remove unused BoardUI components, dependencies and exports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
5.6 KiB
TypeScript
145 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { RiArrowRightLine as ArrowRight, RiCalendarCheckLine as CalendarPlus, RiCheckLine as Check, RiCloseLine as X } from "@remixicon/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";
|
|
import { Button } from "@/components/base/buttons/button";
|
|
|
|
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
|
|
leadingIcon={CalendarPlus}
|
|
onClick={() => {
|
|
setPlan(readPlan());
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
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-3xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-3xl"
|
|
>
|
|
<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 “{recipeTitle}”
|
|
</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>
|
|
);
|
|
}
|