298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
|
|
"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 & 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>
|
||
|
|
);
|
||
|
|
}
|