planmymeal/app/components/IngredientPanel.tsx

200 lines
8.8 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
"use client";
import { ChevronDown, Search, X } from "lucide-react";
import { useMemo, useState, type Dispatch } from "react";
import { CATALOG, CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
import { RECIPES } from "@/app/lib/recipes";
import type { PlanAction } from "@/app/lib/usePlanState";
import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types";
import { RadioGroup, type RadioOption } from "./RadioGroup";
interface IngredientPanelProps {
state: MealPlanState;
dispatch: Dispatch<PlanAction>;
}
type Filter = "all" | "avoid" | "have";
const RECIPE_COUNT: ReadonlyMap<ItemId, number> = (() => {
const counts = new Map<ItemId, number>();
for (const recipe of RECIPES) {
for (const id of new Set(recipe.ingredients.map((i) => i.itemId))) counts.set(id, (counts.get(id) ?? 0) + 1);
}
return counts;
})();
const FILTER_OPTIONS: readonly RadioOption<Filter>[] = [
{ value: "all", label: "All" },
{ value: "avoid", label: "Avoided" },
{ value: "have", label: "On hand" },
];
const PREF_OPTIONS: readonly RadioOption<ItemPreference>[] = [
{ value: "avoid", label: "Avoid", activeClass: "border border-status-danger-line bg-status-danger-bg text-status-danger font-semibold" },
{ value: "use", label: "Use", activeClass: "border border-selected-line/60 bg-surface text-ink font-semibold shadow-2xs" },
{ value: "have", label: "Have it", activeClass: "border border-status-ok-line bg-status-ok-bg text-status-ok font-semibold" },
];
export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const preferenceOf = (id: ItemId): ItemPreference =>
state.avoidedItems.includes(id) ? "avoid" : state.onHandItems.includes(id) ? "have" : "use";
const setPreference = (itemIds: ItemId[], preference: ItemPreference) =>
dispatch({ type: "setItemPreference", itemIds, preference });
const needle = query.trim().toLowerCase();
const groups = useMemo(() => {
const visible = CATALOG.filter((item) => {
if (needle && !item.name.toLowerCase().includes(needle)) return false;
if (filter === "avoid") return state.avoidedItems.includes(item.id);
if (filter === "have") return state.onHandItems.includes(item.id);
return true;
});
const byAisle = new Map<Aisle, StoreItem[]>();
for (const item of [...visible].sort((a, b) => a.name.localeCompare(b.name))) {
byAisle.set(item.aisle, [...(byAisle.get(item.aisle) ?? []), item]);
}
return [...byAisle].sort(([a], [b]) => a.localeCompare(b));
}, [needle, filter, state.avoidedItems, state.onHandItems]);
const staplesAllOnHand = PANTRY_STAPLE_IDS.every((id) => state.onHandItems.includes(id));
return (
<section aria-labelledby="ingredients-heading" className="rounded-xl border border-line bg-surface">
<details className="group">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-4 sm:px-5 [&::-webkit-details-marker]:hidden">
<div>
<h2 id="ingredients-heading" className="font-serif text-lg font-semibold">
Pantry &amp; ingredient exclusions
</h2>
<p className="text-xs text-ink-muted sm:text-sm">
<span className="font-mono font-medium text-ink">{state.onHandItems.length}</span> on hand ($0) ·{" "}
<span className="font-mono font-medium text-ink">{state.avoidedItems.length}</span> avoided
</p>
</div>
<span className="grid size-9 shrink-0 place-items-center rounded-lg border border-line bg-raised text-ink-muted transition-transform group-open:rotate-180">
<ChevronDown className="size-4" aria-hidden />
</span>
</summary>
<div className="border-t border-line p-4 sm:px-5">
{state.avoidedItems.length > 0 && (
<div className="mb-4 flex flex-wrap gap-1.5" aria-label="Avoided ingredients">
{state.avoidedItems.map((id) => (
<button
key={id}
type="button"
onClick={() => setPreference([id], "use")}
className="trim-cap inline-flex min-h-8 items-center gap-1 rounded-full border border-status-danger-line bg-status-danger-bg px-3 py-1 text-xs font-medium text-status-danger transition-colors hover:opacity-85"
aria-label={`Stop avoiding ${CATALOG_BY_ID.get(id)?.name}`}
>
{CATALOG_BY_ID.get(id)?.name}
<X className="size-3" aria-hidden />
</button>
))}
</div>
)}
{/* Search + filter */}
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative w-full sm:w-auto sm:min-w-0 sm: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"
placeholder="Search store items…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="min-h-10 w-full rounded-lg border border-line-strong py-1.5 pr-3 pl-9 text-sm"
aria-label="Search store items"
/>
</div>
<RadioGroup name="Filter ingredients" options={FILTER_OPTIONS} value={filter} onChange={setFilter} size="xs" />
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs sm:text-sm">
<button
type="button"
onClick={() => setPreference(PANTRY_STAPLE_IDS, staplesAllOnHand ? "use" : "have")}
className="min-h-8 font-medium text-brand-300 hover:underline"
>
{staplesAllOnHand ? "Unmark pantry staples" : "Mark basic pantry staples as on hand"}
</button>
{(state.avoidedItems.length > 0 || state.onHandItems.length > 0) && (
<button
type="button"
onClick={() => setPreference([...state.avoidedItems, ...state.onHandItems], "use")}
className="min-h-8 text-ink-muted hover:text-ink hover:underline"
>
Reset all to &ldquo;Use&rdquo;
</button>
)}
</div>
<div className="mt-4 max-h-[28rem] space-y-5 overflow-y-auto pr-1">
{groups.length === 0 && <p className="py-8 text-center text-sm text-ink-muted">No store items match your search.</p>}
{groups.map(([aisle, items]) => (
<section key={aisle}>
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-line bg-surface py-1.5">
<h3 className="font-mono text-xs font-semibold tracking-wider text-ink-muted uppercase">{aisle}</h3>
<AisleBulkToggle itemIds={items.map((i) => i.id)} avoidedItems={state.avoidedItems} onSet={setPreference} />
</div>
<ul className="grid gap-x-6 sm:grid-cols-2">
{items.map((item) => {
const current = preferenceOf(item.id);
const count = RECIPE_COUNT.get(item.id) ?? 0;
return (
<li key={item.id} className="flex min-w-0 items-center justify-between gap-3 border-b border-line py-2.5">
<div className="min-w-0">
<p className={`truncate text-sm font-medium ${current === "avoid" ? "text-ink-faint line-through" : "text-ink"}`}>
{item.name}
</p>
<p className="text-xs text-ink-faint">
Used in {count} recipe{count === 1 ? "" : "s"}
</p>
</div>
<RadioGroup
name={item.name}
options={PREF_OPTIONS}
value={current}
onChange={(val) => setPreference([item.id], val)}
size="xs"
/>
</li>
);
})}
</ul>
</section>
))}
</div>
</div>
</details>
</section>
);
}
function AisleBulkToggle({
itemIds,
avoidedItems,
onSet,
}: {
itemIds: ItemId[];
avoidedItems: ItemId[];
onSet: (itemIds: ItemId[], preference: ItemPreference) => void;
}) {
const allAvoided = itemIds.every((id) => avoidedItems.includes(id));
return (
<button
type="button"
onClick={() => onSet(itemIds, allAvoided ? "use" : "avoid")}
className="min-h-8 px-2 text-xs font-medium text-ink-muted transition-colors hover:text-status-danger"
>
{allAvoided ? "Allow aisle" : "Avoid entire aisle"}
</button>
);
}