247 lines
9.3 KiB
TypeScript
247 lines
9.3 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react";
|
||
|
|
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||
|
|
import { generateMealPlanAction } from "@/app/actions";
|
||
|
|
import { Cart, buildGroceryList } from "@/app/lib/grocery";
|
||
|
|
import { activeRecipeIds } from "@/app/lib/plan";
|
||
|
|
import { averageGroceryProvider } from "@/app/lib/pricing";
|
||
|
|
import { RECIPES } from "@/app/lib/recipes";
|
||
|
|
import { usePlanState } from "@/app/lib/usePlanState";
|
||
|
|
import type { FillMode } from "@/app/types";
|
||
|
|
import { BudgetMeter } from "./BudgetMeter";
|
||
|
|
import { ConfigPanel } from "./ConfigPanel";
|
||
|
|
import { GroceryListDrawer, GrocerySummaryPanel } from "./GroceryListDrawer";
|
||
|
|
import { IngredientPanel } from "./IngredientPanel";
|
||
|
|
import { MealGrid } from "./MealGrid";
|
||
|
|
import { MealSelectorModal } from "./MealSelectorModal";
|
||
|
|
import { Navbar } from "./Navbar";
|
||
|
|
import { RecipeDialog } from "./RecipeDialog";
|
||
|
|
|
||
|
|
interface UndoSnapshot {
|
||
|
|
message: string;
|
||
|
|
previousSlots: Record<string, string>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function PlannerDashboard() {
|
||
|
|
const { state, dispatch, syncStatus } = usePlanState();
|
||
|
|
const [isGenerating, startGenerating] = useTransition();
|
||
|
|
const [notes, setNotes] = useState<string[]>([]);
|
||
|
|
const [openSlot, setOpenSlot] = useState<string | null>(null);
|
||
|
|
const [viewSlot, setViewSlot] = useState<string | null>(null);
|
||
|
|
const [groceriesOpen, setGroceriesOpen] = useState(false);
|
||
|
|
const [undo, setUndo] = useState<UndoSnapshot | null>(null);
|
||
|
|
|
||
|
|
// Auto-dismiss undo toast after 6 seconds
|
||
|
|
useEffect(() => {
|
||
|
|
if (!undo) return;
|
||
|
|
const timer = window.setTimeout(() => setUndo(null), 6000);
|
||
|
|
return () => window.clearTimeout(timer);
|
||
|
|
}, [undo]);
|
||
|
|
|
||
|
|
const summary = useMemo(
|
||
|
|
() => buildGroceryList(activeRecipeIds(state), { householdSize: state.householdSize, onHandItems: state.onHandItems }),
|
||
|
|
[state],
|
||
|
|
);
|
||
|
|
|
||
|
|
const mealCosts = useMemo(() => {
|
||
|
|
const emptyCart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems });
|
||
|
|
return new Map(RECIPES.map((r) => [r.id, emptyCart.marginal(r.id).eaten]));
|
||
|
|
}, [state.householdSize, state.onHandItems]);
|
||
|
|
|
||
|
|
function handleGenerate(fillMode: FillMode) {
|
||
|
|
startGenerating(async () => {
|
||
|
|
try {
|
||
|
|
const result = await generateMealPlanAction({ state, fillMode, seed: Date.now() });
|
||
|
|
dispatch({ type: "replaceSlots", slots: result.slots });
|
||
|
|
setNotes(result.notes);
|
||
|
|
setUndo(null);
|
||
|
|
} catch {
|
||
|
|
setNotes(["Couldn't reach the planner. Check your connection and try again."]);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleRemoveSlot = useCallback(
|
||
|
|
(key: string, recipeTitle: string) => {
|
||
|
|
setUndo({ message: `Removed "${recipeTitle}"`, previousSlots: { ...state.slots } });
|
||
|
|
dispatch({ type: "setSlot", key, recipeId: null });
|
||
|
|
},
|
||
|
|
[dispatch, state.slots],
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleClearWeek = useCallback(() => {
|
||
|
|
const count = activeRecipeIds(state).length;
|
||
|
|
if (count === 0) return;
|
||
|
|
setUndo({ message: `Cleared ${count} planned meal${count === 1 ? "" : "s"}`, previousSlots: { ...state.slots } });
|
||
|
|
dispatch({ type: "clearSlots" });
|
||
|
|
}, [dispatch, state]);
|
||
|
|
|
||
|
|
const handleUndo = useCallback(() => {
|
||
|
|
if (!undo) return;
|
||
|
|
dispatch({ type: "replaceSlots", slots: undo.previousSlots });
|
||
|
|
setUndo(null);
|
||
|
|
}, [dispatch, undo]);
|
||
|
|
|
||
|
|
const closeSelector = useCallback(() => setOpenSlot(null), []);
|
||
|
|
const closeRecipe = useCallback(() => setViewSlot(null), []);
|
||
|
|
const closeGroceries = useCallback(() => setGroceriesOpen(false), []);
|
||
|
|
const toggleOnHand = useCallback(
|
||
|
|
(itemId: string, onHand: boolean) => dispatch({ type: "setItemPreference", itemIds: [itemId], preference: onHand ? "have" : "use" }),
|
||
|
|
[dispatch],
|
||
|
|
);
|
||
|
|
|
||
|
|
const mealCount = activeRecipeIds(state).length;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<Navbar checkoutTotal={summary.checkoutTotal} budget={state.targetBudget} syncStatus={syncStatus} isGenerating={isGenerating} />
|
||
|
|
|
||
|
|
<main className="mx-auto max-w-7xl px-4 pt-6 pb-28 sm:px-6 lg:pb-12">
|
||
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||
|
|
{/* Left: compact control bar -> immediate MealGrid -> Pantry below */}
|
||
|
|
<div className="space-y-6 lg:col-span-2">
|
||
|
|
<ConfigPanel
|
||
|
|
state={state}
|
||
|
|
dispatch={dispatch}
|
||
|
|
onGenerate={handleGenerate}
|
||
|
|
onClear={handleClearWeek}
|
||
|
|
isGenerating={isGenerating}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{notes.length > 0 && (
|
||
|
|
<div
|
||
|
|
role="status"
|
||
|
|
className="flex items-start justify-between gap-3 rounded-xl border border-status-warn-line bg-status-warn-bg p-3.5 text-sm text-status-warn"
|
||
|
|
>
|
||
|
|
<div className="flex items-start gap-2.5">
|
||
|
|
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||
|
|
<ul className="space-y-1">
|
||
|
|
{notes.map((note) => (
|
||
|
|
<li key={note}>{note}</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setNotes([])}
|
||
|
|
aria-label="Dismiss notice"
|
||
|
|
className="grid size-8 shrink-0 place-items-center rounded-lg hover:bg-black/5"
|
||
|
|
>
|
||
|
|
<X className="size-4" aria-hidden />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
|
||
|
|
<MealGrid
|
||
|
|
state={state}
|
||
|
|
dispatch={dispatch}
|
||
|
|
mealCosts={mealCosts}
|
||
|
|
onOpenSlot={setOpenSlot}
|
||
|
|
onViewSlot={setViewSlot}
|
||
|
|
onRemoveSlot={handleRemoveSlot}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<IngredientPanel state={state} dispatch={dispatch} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Right: persistent Market Receipt summary (desktop) */}
|
||
|
|
<aside className="hidden lg:block">
|
||
|
|
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-line bg-surface p-5">
|
||
|
|
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
|
||
|
|
<GrocerySummaryPanel
|
||
|
|
summary={summary}
|
||
|
|
budget={state.targetBudget}
|
||
|
|
priceSourceLabel={averageGroceryProvider.label}
|
||
|
|
onToggleOnHand={toggleOnHand}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
</div>
|
||
|
|
</main>
|
||
|
|
|
||
|
|
{/* Undo Toast for accidental meal removal or Clear */}
|
||
|
|
{undo && (
|
||
|
|
<div
|
||
|
|
role="status"
|
||
|
|
aria-live="polite"
|
||
|
|
className="fixed bottom-20 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-line-strong bg-surface px-4 py-2.5 text-sm shadow-lg lg:bottom-6"
|
||
|
|
>
|
||
|
|
<span className="text-ink">{undo.message}</span>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={handleUndo}
|
||
|
|
className="trim-cap inline-flex min-h-8 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-on-accent hover:bg-brand-500"
|
||
|
|
>
|
||
|
|
<RotateCcw className="size-3.5" aria-hidden />
|
||
|
|
Undo
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setUndo(null)}
|
||
|
|
aria-label="Dismiss undo"
|
||
|
|
className="grid size-7 place-items-center rounded text-ink-faint hover:text-ink"
|
||
|
|
>
|
||
|
|
<X className="size-3.5" aria-hidden />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Mobile: sticky bottom bar */}
|
||
|
|
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-surface/95 px-4 py-3 backdrop-blur-md lg:hidden">
|
||
|
|
<div className="mx-auto flex max-w-7xl items-center gap-4">
|
||
|
|
<div className="flex-1">
|
||
|
|
<BudgetMeter spent={summary.checkoutTotal} budget={state.targetBudget} compact />
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setGroceriesOpen(true)}
|
||
|
|
className="trim-cap inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs hover:bg-brand-500"
|
||
|
|
>
|
||
|
|
<ShoppingBag className="size-4" strokeWidth={2} aria-hidden />
|
||
|
|
Receipt ({summary.lines.length})
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<p className="sr-only" aria-live="polite">
|
||
|
|
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<GroceryListDrawer
|
||
|
|
open={groceriesOpen}
|
||
|
|
onClose={closeGroceries}
|
||
|
|
summary={summary}
|
||
|
|
budget={state.targetBudget}
|
||
|
|
priceSourceLabel={averageGroceryProvider.label}
|
||
|
|
onToggleOnHand={toggleOnHand}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<RecipeDialog
|
||
|
|
slotKey={viewSlot}
|
||
|
|
state={state}
|
||
|
|
onClose={closeRecipe}
|
||
|
|
onSwap={(key) => {
|
||
|
|
setViewSlot(null);
|
||
|
|
setOpenSlot(key);
|
||
|
|
}}
|
||
|
|
onRemove={(key, title) => {
|
||
|
|
handleRemoveSlot(key, title);
|
||
|
|
setViewSlot(null);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<MealSelectorModal
|
||
|
|
slotKey={openSlot}
|
||
|
|
state={state}
|
||
|
|
onClose={closeSelector}
|
||
|
|
onPick={(recipeId) => {
|
||
|
|
if (openSlot) dispatch({ type: "setSlot", key: openSlot, recipeId });
|
||
|
|
setOpenSlot(null);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|