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