2154512e54
Recipe cards, the recipe detail page, and meal planner still rendered raw
"{n} servings"/"{n}m cook"/"{n} srv" text and untranslated difficulty labels
outside the earlier i18n pass. Also wires the user's locale into AI features
that previously always answered in English regardless of app language:
recipe chat, ingredient substitution, recipe ideas, and generate-from-idea.
The recipe-language picker in the AI generate dialog now defaults to the
user's locale instead of always defaulting to English.
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback } from "react";
|
|
import { Plus, Trash2, Sparkles } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
|
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
|
|
|
|
type Recipe = { id: string; title: string };
|
|
type Entry = {
|
|
id: string;
|
|
day: Day;
|
|
mealType: MealType;
|
|
servings: number;
|
|
recipe: Recipe | null;
|
|
note: string | null;
|
|
};
|
|
|
|
type UserRecipe = { id: string; title: string };
|
|
|
|
function AddEntryModal({
|
|
weekStart,
|
|
day,
|
|
mealType,
|
|
userRecipes,
|
|
onAdded,
|
|
onClose,
|
|
dayLabel,
|
|
mealLabel,
|
|
}: {
|
|
weekStart: string;
|
|
day: Day;
|
|
mealType: MealType;
|
|
userRecipes: UserRecipe[];
|
|
onAdded: (entry: Entry) => void;
|
|
onClose: () => void;
|
|
dayLabel: string;
|
|
mealLabel: string;
|
|
}) {
|
|
const [search, setSearch] = useState("");
|
|
const [servings, setServings] = useState("2");
|
|
const [saving, setSaving] = useState(false);
|
|
const t = useTranslations("mealPlan");
|
|
|
|
const filtered = userRecipes.filter((r) =>
|
|
r.title.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
async function add(recipe: UserRecipe) {
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ day, mealType, recipeId: recipe.id, servings: parseInt(servings) || 2 }),
|
|
});
|
|
if (!res.ok) { toast.error("Failed to add"); return; }
|
|
const { id } = await res.json() as { id: string };
|
|
onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, note: null });
|
|
onClose();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open onOpenChange={() => onClose()}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("addTo", { day: dayLabel, meal: mealLabel })}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<Input
|
|
placeholder={t("searchRecipes")}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={servings}
|
|
onChange={(e) => setServings(e.target.value)}
|
|
placeholder={t("servings")}
|
|
/>
|
|
<div className="max-h-56 overflow-y-auto space-y-1">
|
|
{filtered.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-4 text-center">{t("noRecipes")}</p>
|
|
) : (
|
|
filtered.map((recipe) => (
|
|
<button
|
|
key={recipe.id}
|
|
onClick={() => add(recipe)}
|
|
disabled={saving}
|
|
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
|
|
>
|
|
{recipe.title}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function MealPlanner({
|
|
weekStart,
|
|
initialEntries,
|
|
userRecipes,
|
|
}: {
|
|
weekStart: string;
|
|
initialEntries: Entry[];
|
|
userRecipes: UserRecipe[];
|
|
}) {
|
|
const [entries, setEntries] = useState<Entry[]>(initialEntries);
|
|
const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null);
|
|
const [showAiModal, setShowAiModal] = useState(false);
|
|
const [aiGenerating, setAiGenerating] = useState(false);
|
|
const [aiPhase, setAiPhase] = useState("");
|
|
const [dietaryPrefs, setDietaryPrefs] = useState("");
|
|
const [aiServings, setAiServings] = useState("2");
|
|
const [usePantry, setUsePantry] = useState(true);
|
|
const [pantryMode, setPantryMode] = useState(false);
|
|
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
|
const t = useTranslations("mealPlan");
|
|
|
|
async function generateWithAi() {
|
|
setAiGenerating(true);
|
|
setAiPhase("Analyzing your preferences…");
|
|
const phases = [
|
|
[1500, "Planning breakfast, lunch & dinner…"],
|
|
[5000, "Selecting recipes for each day…"],
|
|
[10000, "Balancing nutrition across the week…"],
|
|
[16000, "Finalizing your meal plan…"],
|
|
] as const;
|
|
const timers = phases.map(([delay, label]) =>
|
|
setTimeout(() => setAiPhase(label), delay)
|
|
);
|
|
try {
|
|
const res = await fetch("/api/v1/ai/meal-plan/generate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
weekStart,
|
|
dietaryPrefs: dietaryPrefs.trim() || undefined,
|
|
servings: parseInt(aiServings) || 2,
|
|
usePantry,
|
|
pantryMode,
|
|
difficulty: aiDifficulty || undefined,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json() as { error?: string };
|
|
throw new Error(data.error ?? "Generation failed");
|
|
}
|
|
const data = await res.json() as {
|
|
entries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }>;
|
|
};
|
|
// Merge generated entries into state
|
|
setEntries((prev) => {
|
|
const updated = [...prev];
|
|
for (const e of data.entries) {
|
|
const idx = updated.findIndex((u) => u.day === e.day && u.mealType === e.mealType);
|
|
const newEntry: Entry = {
|
|
id: e.id,
|
|
day: e.day as Day,
|
|
mealType: e.mealType as MealType,
|
|
servings: parseInt(aiServings) || 2,
|
|
recipe: { id: e.recipeId, title: e.recipeTitle },
|
|
note: null,
|
|
};
|
|
if (idx >= 0) updated[idx] = newEntry;
|
|
else updated.push(newEntry);
|
|
}
|
|
return updated;
|
|
});
|
|
setShowAiModal(false);
|
|
toast.success(t("aiGenerated"));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Generation failed");
|
|
} finally {
|
|
timers.forEach(clearTimeout);
|
|
setAiGenerating(false);
|
|
setAiPhase("");
|
|
}
|
|
}
|
|
|
|
const DAYS: { key: Day; label: string }[] = [
|
|
{ key: "mon", label: t("days.mon") },
|
|
{ key: "tue", label: t("days.tue") },
|
|
{ key: "wed", label: t("days.wed") },
|
|
{ key: "thu", label: t("days.thu") },
|
|
{ key: "fri", label: t("days.fri") },
|
|
{ key: "sat", label: t("days.sat") },
|
|
{ key: "sun", label: t("days.sun") },
|
|
];
|
|
|
|
const MEAL_TYPES: { key: MealType; label: string }[] = [
|
|
{ key: "breakfast", label: t("meals.breakfast") },
|
|
{ key: "lunch", label: t("meals.lunch") },
|
|
{ key: "dinner", label: t("meals.dinner") },
|
|
{ key: "snack", label: t("meals.snack") },
|
|
];
|
|
|
|
const getEntry = (day: Day, mealType: MealType) =>
|
|
entries.find((e) => e.day === day && e.mealType === mealType);
|
|
|
|
const handleAdded = useCallback((entry: Entry) => {
|
|
setEntries((prev) => [
|
|
...prev.filter((e) => !(e.day === entry.day && e.mealType === entry.mealType)),
|
|
entry,
|
|
]);
|
|
}, []);
|
|
|
|
async function removeEntry(entry: Entry) {
|
|
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/${entry.id}`, { method: "DELETE" });
|
|
if (res.ok) {
|
|
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
|
|
} else {
|
|
toast.error("Failed to remove");
|
|
}
|
|
}
|
|
|
|
const addingDay = adding ? DAYS.find((d) => d.key === adding.day) : null;
|
|
const addingMeal = adding ? MEAL_TYPES.find((m) => m.key === adding.mealType) : null;
|
|
|
|
return (
|
|
<>
|
|
{/* AI Generate button */}
|
|
<div className="flex justify-end mb-4">
|
|
<Button variant="ghost" size="sm" onClick={() => setShowAiModal(true)} className="gap-2">
|
|
<Sparkles className="h-4 w-4" />
|
|
{t("generateWithAi")}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[640px] border-collapse table-fixed">
|
|
<colgroup>
|
|
<col className="w-24" />
|
|
{DAYS.map(({ key }) => (
|
|
<col key={key} />
|
|
))}
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
|
|
{DAYS.map(({ key, label }) => (
|
|
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">{label}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{MEAL_TYPES.map(({ key: mealType, label }) => (
|
|
<tr key={mealType} className="border-t">
|
|
<td className="py-3 pr-3">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</span>
|
|
</td>
|
|
{DAYS.map(({ key: day }) => {
|
|
const entry = getEntry(day, mealType);
|
|
return (
|
|
<td key={day} className="py-2 px-1 align-top">
|
|
{entry?.recipe ? (
|
|
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
|
|
<div className="font-medium line-clamp-2 pr-4">{entry.recipe.title}</div>
|
|
<div className="text-muted-foreground mt-0.5">{t("servingsAbbrev", { count: entry.servings })}</div>
|
|
<button
|
|
onClick={() => removeEntry(entry)}
|
|
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setAdding({ day, mealType })}
|
|
className="w-full min-h-[52px] rounded-lg border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 text-muted-foreground/40" />
|
|
</button>
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{adding && addingDay && addingMeal && (
|
|
<AddEntryModal
|
|
weekStart={weekStart}
|
|
day={adding.day}
|
|
mealType={adding.mealType}
|
|
userRecipes={userRecipes}
|
|
onAdded={handleAdded}
|
|
onClose={() => setAdding(null)}
|
|
dayLabel={addingDay.label}
|
|
mealLabel={addingMeal.label}
|
|
/>
|
|
)}
|
|
|
|
{/* AI generation modal */}
|
|
{showAiModal && (
|
|
<Dialog open onOpenChange={(open) => { if (!aiGenerating) setShowAiModal(open); }}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4" />
|
|
{t("generateWithAi")}
|
|
</DialogTitle>
|
|
<DialogDescription>{t("aiModalDescription")}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">{t("dietaryPrefs")}</label>
|
|
<Input
|
|
placeholder={t("dietaryPrefsPlaceholder")}
|
|
value={dietaryPrefs}
|
|
onChange={(e) => setDietaryPrefs(e.target.value)}
|
|
disabled={aiGenerating}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">{t("servings")}</label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={20}
|
|
value={aiServings}
|
|
onChange={(e) => setAiServings(e.target.value)}
|
|
disabled={aiGenerating}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Difficulty</label>
|
|
<Select value={aiDifficulty} onValueChange={(v) => setAiDifficulty(v as typeof aiDifficulty)} disabled={aiGenerating}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Any" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">Any</SelectItem>
|
|
<SelectItem value="easy">Easy</SelectItem>
|
|
<SelectItem value="medium">Medium</SelectItem>
|
|
<SelectItem value="hard">Hard</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4"
|
|
checked={usePantry}
|
|
onChange={(e) => {
|
|
setUsePantry(e.target.checked);
|
|
if (!e.target.checked) setPantryMode(false);
|
|
}}
|
|
disabled={aiGenerating}
|
|
/>
|
|
<span className="text-sm font-medium">{t("includePantryItems")}</span>
|
|
</label>
|
|
{usePantry && (
|
|
<label className="flex items-start gap-2 cursor-pointer select-none pl-6">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 mt-0.5"
|
|
checked={pantryMode}
|
|
onChange={(e) => setPantryMode(e.target.checked)}
|
|
disabled={aiGenerating}
|
|
/>
|
|
<span className="space-y-0.5">
|
|
<span className="text-sm font-medium block">{t("pantryMode")}</span>
|
|
<span className="text-sm text-muted-foreground block">{t("pantryModeDescription")}</span>
|
|
</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
<FakeProgressBar active={aiGenerating} durationMs={20000} label={aiPhase} />
|
|
<div className="flex gap-2 justify-end">
|
|
<Button variant="ghost" onClick={() => setShowAiModal(false)} disabled={aiGenerating}>
|
|
{t("cancel")}
|
|
</Button>
|
|
<Button onClick={() => { void generateWithAi(); }} disabled={aiGenerating} className="gap-2">
|
|
{aiGenerating ? (
|
|
<>
|
|
<span className="animate-spin inline-block h-3.5 w-3.5 border-2 border-current border-t-transparent rounded-full" />
|
|
{t("generating")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
{t("generate")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
);
|
|
}
|