273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { ChevronDown, RotateCcw, Shuffle, SlidersHorizontal, Wand2 } from "lucide-react";
|
||
|
|
import { useState, type Dispatch } from "react";
|
||
|
|
import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan";
|
||
|
|
import type { PlanAction } from "@/app/lib/usePlanState";
|
||
|
|
import type { FillMode, MealPlanState } from "@/app/types";
|
||
|
|
import { Chip } from "./Chip";
|
||
|
|
import { ApplianceIcon, StyleIcon } from "./Icons";
|
||
|
|
import { RadioGroup } from "./RadioGroup";
|
||
|
|
|
||
|
|
interface ConfigPanelProps {
|
||
|
|
state: MealPlanState;
|
||
|
|
dispatch: Dispatch<PlanAction>;
|
||
|
|
onGenerate: (mode: FillMode) => void;
|
||
|
|
onClear: () => void;
|
||
|
|
isGenerating: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
const MEALS_OPTIONS = [
|
||
|
|
{ value: 1, label: "1" },
|
||
|
|
{ value: 2, label: "2" },
|
||
|
|
{ value: 3, label: "3" },
|
||
|
|
] as const;
|
||
|
|
|
||
|
|
const PEOPLE_OPTIONS = [
|
||
|
|
{ value: 1, label: "1" },
|
||
|
|
{ value: 2, label: "2" },
|
||
|
|
{ value: 3, label: "3" },
|
||
|
|
{ value: 4, label: "4" },
|
||
|
|
{ value: 5, label: "5" },
|
||
|
|
{ value: 6, label: "6" },
|
||
|
|
] as const;
|
||
|
|
|
||
|
|
export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating }: ConfigPanelProps) {
|
||
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||
|
|
|
||
|
|
const filledCount = activeRecipeIds(state).length;
|
||
|
|
const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · ");
|
||
|
|
const activeFilterCount =
|
||
|
|
(APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) +
|
||
|
|
state.preferredCuisines.length +
|
||
|
|
state.preferredStyles.length;
|
||
|
|
|
||
|
|
const filterSummary = [
|
||
|
|
`${state.selectedAppliances.length}/${APPLIANCES.length} appliances`,
|
||
|
|
state.preferredCuisines.length > 0 ? `${state.preferredCuisines.length} cuisines` : "Any cuisine",
|
||
|
|
state.preferredStyles.length > 0 ? `${state.preferredStyles.length} styles` : null,
|
||
|
|
]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join(" · ");
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section aria-labelledby="config-heading" className="rounded-xl border border-line bg-surface p-4 sm:p-5">
|
||
|
|
<h2 id="config-heading" className="sr-only">
|
||
|
|
Plan parameters
|
||
|
|
</h2>
|
||
|
|
|
||
|
|
{/* --- Compact Top Strip: Core Week Parameters ---------------------- */}
|
||
|
|
<div className="grid gap-x-6 gap-y-4 sm:grid-cols-2">
|
||
|
|
{/* Budget */}
|
||
|
|
<div className="min-w-0">
|
||
|
|
<div className="flex items-baseline justify-between gap-2">
|
||
|
|
<label htmlFor="budget" className="text-xs font-semibold tracking-wide text-ink-muted uppercase">
|
||
|
|
Weekly budget
|
||
|
|
</label>
|
||
|
|
<span className="text-xs text-ink-faint">At checkout</span>
|
||
|
|
</div>
|
||
|
|
<div className="mt-1.5 flex min-h-9 items-center gap-2.5">
|
||
|
|
<input
|
||
|
|
id="budget-range"
|
||
|
|
type="range"
|
||
|
|
min={20}
|
||
|
|
max={300}
|
||
|
|
step={5}
|
||
|
|
value={state.targetBudget}
|
||
|
|
onChange={(e) => dispatch({ type: "setBudget", value: Number(e.target.value) })}
|
||
|
|
className="min-w-0 flex-1"
|
||
|
|
aria-label="Weekly budget slider"
|
||
|
|
/>
|
||
|
|
<div className="relative shrink-0">
|
||
|
|
<span className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 font-mono text-xs text-ink-muted">$</span>
|
||
|
|
<input
|
||
|
|
id="budget"
|
||
|
|
type="number"
|
||
|
|
inputMode="numeric"
|
||
|
|
min={10}
|
||
|
|
max={1000}
|
||
|
|
value={state.targetBudget}
|
||
|
|
onChange={(e) => dispatch({ type: "setBudget", value: Math.max(0, Number(e.target.value) || 0) })}
|
||
|
|
className="min-h-9 w-22 rounded-lg border border-line-strong py-1 pr-2.5 pl-6 text-right font-mono text-sm font-semibold tabular-nums"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Days */}
|
||
|
|
<div className="min-w-0">
|
||
|
|
<div className="flex items-baseline justify-between gap-2">
|
||
|
|
<label htmlFor="days" className="text-xs font-semibold tracking-wide text-ink-muted uppercase">
|
||
|
|
Days
|
||
|
|
</label>
|
||
|
|
<span className="font-mono text-xs font-semibold text-ink tabular-nums">
|
||
|
|
{state.daysCount} {state.daysCount === 1 ? "day" : "days"}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="mt-1.5 flex min-h-9 items-center">
|
||
|
|
<input
|
||
|
|
id="days"
|
||
|
|
type="range"
|
||
|
|
min={1}
|
||
|
|
max={7}
|
||
|
|
value={state.daysCount}
|
||
|
|
onChange={(e) => dispatch({ type: "setDays", value: Number(e.target.value) })}
|
||
|
|
className="w-full"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Meals per day */}
|
||
|
|
<div className="min-w-0">
|
||
|
|
<div className="flex items-baseline justify-between gap-2">
|
||
|
|
<span className="shrink-0 text-xs font-semibold tracking-wide text-ink-muted uppercase">Meals / day</span>
|
||
|
|
<span className="truncate text-xs text-ink-faint">{mealNames}</span>
|
||
|
|
</div>
|
||
|
|
<div className="mt-1.5">
|
||
|
|
<RadioGroup
|
||
|
|
name="Meals per day"
|
||
|
|
options={MEALS_OPTIONS}
|
||
|
|
value={state.mealsPerDay}
|
||
|
|
onChange={(value) => dispatch({ type: "setMealsPerDay", value })}
|
||
|
|
fullWidth
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Household size */}
|
||
|
|
<div className="min-w-0">
|
||
|
|
<div className="flex items-baseline justify-between gap-2">
|
||
|
|
<span className="shrink-0 text-xs font-semibold tracking-wide text-ink-muted uppercase">Household</span>
|
||
|
|
<span className="truncate text-xs text-ink-faint">Scales amounts</span>
|
||
|
|
</div>
|
||
|
|
<div className="mt-1.5">
|
||
|
|
<RadioGroup
|
||
|
|
name="People eating"
|
||
|
|
options={PEOPLE_OPTIONS}
|
||
|
|
value={state.householdSize}
|
||
|
|
onChange={(value) => dispatch({ type: "setHouseholdSize", value })}
|
||
|
|
fullWidth
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* --- Action Toolbar + Progressive Disclosure Toggle --------------- */}
|
||
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-line pt-3.5">
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => onGenerate("empty")}
|
||
|
|
disabled={isGenerating}
|
||
|
|
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 disabled:opacity-60"
|
||
|
|
>
|
||
|
|
<Wand2 className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
||
|
|
{isGenerating ? "Planning…" : "Auto-fill empty"}
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => onGenerate("all")}
|
||
|
|
disabled={isGenerating}
|
||
|
|
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3.5 py-2 text-sm font-medium text-ink transition-colors hover:bg-raised disabled:opacity-60"
|
||
|
|
>
|
||
|
|
<Shuffle className="size-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
|
||
|
|
Shuffle plan
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClear}
|
||
|
|
disabled={isGenerating || filledCount === 0}
|
||
|
|
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink disabled:opacity-40"
|
||
|
|
>
|
||
|
|
<RotateCcw className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
|
||
|
|
Clear
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
aria-expanded={filtersOpen}
|
||
|
|
aria-controls="kitchen-filters-drawer"
|
||
|
|
onClick={() => setFiltersOpen((o) => !o)}
|
||
|
|
className={`trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg border px-3.5 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 shrink-0" strokeWidth={1.75} aria-hidden />
|
||
|
|
<span>Kitchen & preferences</span>
|
||
|
|
<span className="hidden text-xs text-ink-faint sm:inline">({filterSummary})</span>
|
||
|
|
<ChevronDown
|
||
|
|
className={`size-4 shrink-0 text-ink-muted transition-transform ${filtersOpen ? "rotate-180" : ""}`}
|
||
|
|
aria-hidden
|
||
|
|
/>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* --- Collapsible Appliances, Cuisines & Styles -------------------- */}
|
||
|
|
{filtersOpen && (
|
||
|
|
<div id="kitchen-filters-drawer" className="mt-4 space-y-5 border-t border-line pt-4">
|
||
|
|
<fieldset>
|
||
|
|
<legend className="text-sm font-semibold text-ink">
|
||
|
|
Appliances you own <span className="font-normal text-ink-muted">· recipes needing anything else stay hidden</span>
|
||
|
|
</legend>
|
||
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
||
|
|
{APPLIANCES.map((a) => (
|
||
|
|
<Chip
|
||
|
|
key={a.id}
|
||
|
|
size="sm"
|
||
|
|
pressed={state.selectedAppliances.includes(a.id)}
|
||
|
|
onClick={() => dispatch({ type: "toggleAppliance", value: a.id })}
|
||
|
|
>
|
||
|
|
<ApplianceIcon id={a.id} className="size-3.5" />
|
||
|
|
{a.label}
|
||
|
|
</Chip>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</fieldset>
|
||
|
|
|
||
|
|
<fieldset>
|
||
|
|
<legend className="text-sm font-semibold text-ink">
|
||
|
|
Preferred cuisines <span className="font-normal text-ink-muted">· none selected = any</span>
|
||
|
|
</legend>
|
||
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
||
|
|
{CUISINES.map((c) => (
|
||
|
|
<Chip
|
||
|
|
key={c.id}
|
||
|
|
size="sm"
|
||
|
|
pressed={state.preferredCuisines.includes(c.id)}
|
||
|
|
onClick={() => dispatch({ type: "toggleCuisine", value: c.id })}
|
||
|
|
>
|
||
|
|
{c.label}
|
||
|
|
</Chip>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</fieldset>
|
||
|
|
|
||
|
|
<fieldset>
|
||
|
|
<legend className="text-sm font-semibold text-ink">
|
||
|
|
Cooking style <span className="font-normal text-ink-muted">· matches any selected</span>
|
||
|
|
</legend>
|
||
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
||
|
|
{STYLES.map((s) => (
|
||
|
|
<Chip
|
||
|
|
key={s.id}
|
||
|
|
size="sm"
|
||
|
|
pressed={state.preferredStyles.includes(s.id)}
|
||
|
|
onClick={() => dispatch({ type: "toggleStyle", value: s.id })}
|
||
|
|
>
|
||
|
|
<StyleIcon id={s.id} className="size-3.5" />
|
||
|
|
{s.label}
|
||
|
|
</Chip>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</fieldset>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|