planmymeal/app/components/MealSelectorModal.tsx

298 lines
13 KiB
TypeScript
Raw Normal View History

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