200 lines
8.8 KiB
TypeScript
200 lines
8.8 KiB
TypeScript
|
|
"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 & 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 “Use”
|
||
|
|
</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>
|
||
|
|
);
|
||
|
|
}
|