init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
297
app/components/MealSelectorModal.tsx
Normal file
297
app/components/MealSelectorModal.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue