planmymeal/app/components/AddToPlanControl.tsx

147 lines
5.7 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
"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>
);
}