Files
Epicure/apps/web/components/recipe/recipe-form.tsx
T
Arnaud 61014a3224 feat: manually create and edit batch-cook recipes
recipe-form.tsx previously had no batch-cook awareness at all — editing an
AI-generated batch-cook recipe and saving silently corrupted it (steps lost
their per-dish `appliesTo` tags, recipeBatchDishes rows went stale/orphaned,
since the create/update API schemas and edit-page query never touched them).

Adds a "batch-cook recipe" toggle to the form, an editable dish list (name,
description, fridge days, freezer-friendly/note, day-of instructions), and
per-step dish tagging (click a dish chip to mark which dish(es) that step
advances; empty = shared prep). Renaming a dish propagates to any step
still tagged with the old name instead of orphaning it. Wired through
Create/Update API schemas and the edit page's query/payload.

Verified locally: created a 2-dish batch recipe from scratch through the
real form, confirmed it renders identically to an AI-generated one (grouped
steps, dishes & storage cards), edited it, renamed a dish, reloaded, and
confirmed the step tag followed the rename rather than orphaning.
2026-07-12 14:45:24 +02:00

760 lines
27 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, GripVertical, X, Tag } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { DietaryTagPicker } from "./dietary-tag-picker";
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
type DietaryTags = {
vegan?: boolean;
vegetarian?: boolean;
glutenFree?: boolean;
dairyFree?: boolean;
nutFree?: boolean;
halal?: boolean;
kosher?: boolean;
};
type IngredientRow = {
id: string;
rawName: string;
quantity: string;
unit: string;
note: string;
};
type StepRow = {
id: string;
instruction: string;
timerSeconds: string;
appliesTo: string[];
};
type DishRow = {
id: string;
name: string;
description: string;
fridgeDays: string;
freezerFriendly: boolean;
freezerNote: string;
dayOfInstructions: string;
};
type RecipeFormProps = {
recipeId?: string;
defaultValues?: {
title?: string;
description?: string;
baseServings?: number;
visibility?: "private" | "unlisted" | "public";
difficulty?: "easy" | "medium" | "hard" | null;
prepMins?: number | null;
cookMins?: number | null;
dietaryTags?: DietaryTags;
tags?: string[];
ingredients?: IngredientRow[];
steps?: StepRow[];
photos?: PhotoEntry[];
isBatchCook?: boolean;
dishes?: DishRow[];
};
};
function newIngredient(): IngredientRow {
return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" };
}
function newStep(): StepRow {
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "", appliesTo: [] };
}
function newDish(): DishRow {
return { id: crypto.randomUUID(), name: "", description: "", fridgeDays: "3", freezerFriendly: false, freezerNote: "", dayOfInstructions: "" };
}
export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const router = useRouter();
const t = useTranslations("recipeForm");
const t_recipe = useTranslations("recipe");
const t_common = useTranslations("common");
const isEdit = !!recipeId;
const id = recipeId ?? crypto.randomUUID();
const [title, setTitle] = useState(defaultValues?.title ?? "");
const [description, setDescription] = useState(defaultValues?.description ?? "");
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
const [visibility, setVisibility] = useState<"private" | "unlisted" | "public">(defaultValues?.visibility ?? "private");
const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? ""));
const [tags, setTags] = useState<string[]>(defaultValues?.tags ?? []);
const [tagInput, setTagInput] = useState("");
const tagInputRef = useRef<HTMLInputElement>(null);
const [dietaryTags, setDietaryTags] = useState<DietaryTags>(defaultValues?.dietaryTags ?? {});
const [ingredients, setIngredients] = useState<IngredientRow[]>(
defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()]
);
const [steps, setSteps] = useState<StepRow[]>(
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
);
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
const [isBatchCook, setIsBatchCook] = useState(defaultValues?.isBatchCook ?? false);
const [dishes, setDishes] = useState<DishRow[]>(
defaultValues?.dishes?.length ? defaultValues.dishes : [newDish()]
);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
const hasMountedRef = useRef(false);
// Mark the form dirty on any edit after the initial mount, so we can warn the
// user before they navigate away and silently lose their changes.
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
}
setDirty(true);
}, [
title,
description,
baseServings,
visibility,
difficulty,
prepMins,
cookMins,
tags,
dietaryTags,
ingredients,
steps,
photos,
isBatchCook,
dishes,
]);
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
useEffect(() => {
if (!dirty || saving) return;
function handleBeforeUnload(e: BeforeUnloadEvent) {
e.preventDefault();
e.returnValue = "";
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [dirty, saving]);
function handleCancelClick() {
if (dirty) {
setDiscardConfirmOpen(true);
} else {
router.back();
}
}
function updateIngredient(i: number, patch: Partial<IngredientRow>) {
setIngredients((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
}
function removeIngredient(i: number) {
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
}
function addTag(raw: string) {
const tag = raw.trim().toLowerCase().slice(0, 50);
if (!tag || tags.includes(tag) || tags.length >= 20) return;
setTags((prev) => [...prev, tag]);
setTagInput("");
}
function removeTag(tag: string) {
setTags((prev) => prev.filter((t) => t !== tag));
}
function handleTagKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
addTag(tagInput);
} else if (e.key === "Backspace" && !tagInput && tags.length > 0) {
setTags((prev) => prev.slice(0, -1));
}
}
function updateStep(i: number, patch: Partial<StepRow>) {
setSteps((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
}
function removeStep(i: number) {
setSteps((prev) => prev.filter((_, idx) => idx !== i));
}
function toggleStepDish(i: number, dishName: string) {
setSteps((prev) => prev.map((row, idx) => {
if (idx !== i) return row;
const has = row.appliesTo.includes(dishName);
return { ...row, appliesTo: has ? row.appliesTo.filter((n) => n !== dishName) : [...row.appliesTo, dishName] };
}));
}
function updateDish(i: number, patch: Partial<DishRow>) {
const oldName = dishes[i]?.name;
setDishes((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
// appliesTo tracks dishes by name (matches the DB's join-by-string-array
// design), so a rename must be propagated or steps silently detach.
if (patch.name !== undefined && oldName && patch.name !== oldName) {
setSteps((prev) => prev.map((row) => ({
...row,
appliesTo: row.appliesTo.map((n) => n === oldName ? patch.name! : n),
})));
}
}
function removeDish(i: number) {
const removedName = dishes[i]?.name;
setDishes((prev) => prev.filter((_, idx) => idx !== i));
if (removedName) {
setSteps((prev) => prev.map((row) => ({ ...row, appliesTo: row.appliesTo.filter((n) => n !== removedName) })));
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) {
toast.error(t("titleRequired"));
return;
}
const filteredIngredients = ingredients
.filter((ing) => ing.rawName.trim())
.map((ing, i) => ({
rawName: ing.rawName.trim(),
quantity: ing.quantity.trim() || undefined,
unit: ing.unit.trim() || undefined,
note: ing.note.trim() || undefined,
order: i,
}));
if (filteredIngredients.length === 0) {
toast.error(t("ingredientsRequired"));
return;
}
const filteredDishes = isBatchCook
? dishes
.filter((d) => d.name.trim())
.map((d) => ({
name: d.name.trim(),
description: d.description.trim() || undefined,
fridgeDays: parseInt(d.fridgeDays) || 3,
freezerFriendly: d.freezerFriendly,
freezerNote: d.freezerNote.trim() || undefined,
dayOfInstructions: d.dayOfInstructions.trim(),
}))
: [];
if (isBatchCook && filteredDishes.length === 0) {
toast.error(t("dishesRequired"));
return;
}
if (isBatchCook && filteredDishes.some((d) => !d.dayOfInstructions)) {
toast.error(t("dayOfInstructionsRequired"));
return;
}
const dishNames = new Set(filteredDishes.map((d) => d.name));
const filteredSteps = steps
.filter((s) => s.instruction.trim())
.map((s, i) => ({
instruction: s.instruction.trim(),
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
order: i,
appliesTo: isBatchCook ? s.appliesTo.filter((n) => dishNames.has(n)) : [],
}));
if (filteredSteps.length === 0) {
toast.error(t("stepsRequired"));
return;
}
setSaving(true);
try {
const payload = {
title: title.trim(),
description: description.trim() || undefined,
baseServings: parseInt(baseServings) || 4,
visibility,
difficulty: difficulty || undefined,
prepMins: prepMins ? parseInt(prepMins) : undefined,
cookMins: cookMins ? parseInt(cookMins) : undefined,
tags,
dietaryTags,
ingredients: filteredIngredients,
steps: filteredSteps,
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
isBatchCook,
dishes: filteredDishes,
};
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? t("saveError"));
return;
}
const saved = await res.json() as { id: string };
toast.success(isEdit ? t("updateSuccess") : t("createSuccess"));
setDirty(false);
router.push(`/recipes/${saved.id}`);
router.refresh();
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-8 max-w-3xl pb-20">
{/* Basic info */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{t("titleLabel")}</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("descriptionLabel")}</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("descriptionPlaceholder")}
rows={3}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="servings">{t("servings")}</Label>
<Input
id="servings"
type="number"
min={1}
max={100}
value={baseServings}
onChange={(e) => setBaseServings(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="prep">{t("prepMins")}</Label>
<Input
id="prep"
type="number"
min={0}
value={prepMins}
onChange={(e) => setPrepMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cook">{t("cookMins")}</Label>
<Input
id="cook"
type="number"
min={0}
value={cookMins}
onChange={(e) => setCookMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="difficulty">{t("difficulty")}</Label>
<select
id="difficulty"
value={difficulty}
onChange={(e) => setDifficulty(e.target.value as "easy" | "medium" | "hard" | "")}
className="flex h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value=""></option>
<option value="easy">{t_recipe("difficulty.easy")}</option>
<option value="medium">{t_recipe("difficulty.medium")}</option>
<option value="hard">{t_recipe("difficulty.hard")}</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="visibility">{t_recipe("visibilityMenuLabel")}</Label>
<select
id="visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as "private" | "unlisted" | "public")}
className="flex h-8 w-full max-w-[200px] rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="private">{t_recipe("visibility.private")}</option>
<option value="unlisted">{t_recipe("visibility.unlisted")}</option>
<option value="public">{t_recipe("visibility.public")}</option>
</select>
</div>
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<div
className="flex flex-wrap gap-1.5 min-h-[36px] rounded-lg border border-input bg-transparent px-2.5 py-1.5 cursor-text focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50"
onClick={() => tagInputRef.current?.focus()}
>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs bg-muted text-muted-foreground"
>
<Tag className="h-2.5 w-2.5" />
{tag}
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
className="hover:text-foreground transition-colors"
aria-label={`Remove tag ${tag}`}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
{tags.length < 20 && (
<input
ref={tagInputRef}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={() => { if (tagInput.trim()) addTag(tagInput); }}
placeholder={tags.length === 0 ? "Add tags… (press Enter)" : ""}
className="flex-1 min-w-[120px] bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
)}
</div>
<p className="text-xs text-muted-foreground">Press Enter to add · Backspace to remove last · max 20 tags</p>
</div>
</div>
<Separator />
{/* Dietary tags */}
<div className="space-y-2">
<Label>{t("dietaryTags")}</Label>
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
</div>
<Separator />
{/* Batch cooking */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<Switch id="batch-cook" checked={isBatchCook} onCheckedChange={setIsBatchCook} />
<Label htmlFor="batch-cook" className="cursor-pointer">{t("batchCookToggle")}</Label>
</div>
<p className="text-xs text-muted-foreground">{t("batchCookToggleHelp")}</p>
</div>
<Separator />
{/* Photos */}
<div className="space-y-2">
<Label>{t("photos")}</Label>
<PhotoUploader recipeId={id} photos={photos} onChange={setPhotos} />
</div>
<Separator />
{/* Ingredients */}
<div className="space-y-3">
<Label>{t("ingredients")}</Label>
<div className="space-y-2">
{ingredients.map((ing, i) => (
<div key={ing.id} className="flex flex-wrap gap-2 items-start">
<GripVertical className="h-4 w-4 text-muted-foreground mt-2 cursor-grab shrink-0" />
<Input
value={ing.quantity}
onChange={(e) => updateIngredient(i, { quantity: e.target.value })}
placeholder={t("amount")}
className="w-14 sm:w-16 shrink-0"
/>
<Input
value={ing.unit}
onChange={(e) => updateIngredient(i, { unit: e.target.value })}
placeholder={t("unit")}
className="w-16 sm:w-24 shrink-0"
/>
<Input
value={ing.rawName}
onChange={(e) => updateIngredient(i, { rawName: e.target.value })}
placeholder={t("ingredient")}
className="flex-1 min-w-[100px]"
/>
<Input
value={ing.note}
onChange={(e) => updateIngredient(i, { note: e.target.value })}
placeholder={t("note")}
className="w-full sm:w-48 sm:shrink-0"
/>
<button
type="button"
onClick={() => removeIngredient(i)}
disabled={ingredients.length === 1}
className="mt-1.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIngredients((p) => [...p, newIngredient()])}
>
<Plus className="h-4 w-4" />
{t("addIngredient")}
</Button>
</div>
<Separator />
{/* Batch-cook dishes */}
{isBatchCook && (
<>
<div className="space-y-3">
<Label>{t("dishes")}</Label>
<div className="space-y-4">
{dishes.map((dish, i) => (
<div key={dish.id} className="rounded-lg border p-3 space-y-2">
<div className="flex gap-2 items-start">
<Input
value={dish.name}
onChange={(e) => updateDish(i, { name: e.target.value })}
placeholder={t("dishName")}
className="flex-1 min-w-0 font-medium"
/>
<button
type="button"
onClick={() => removeDish(i)}
disabled={dishes.length === 1}
className="mt-1.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<Textarea
value={dish.description}
onChange={(e) => updateDish(i, { description: e.target.value })}
placeholder={t("dishDescription")}
rows={2}
/>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 items-end">
<div className="space-y-1">
<Label htmlFor={`fridge-${dish.id}`} className="text-xs text-muted-foreground">{t("fridgeDays")}</Label>
<Input
id={`fridge-${dish.id}`}
type="number"
min={1}
max={14}
value={dish.fridgeDays}
onChange={(e) => updateDish(i, { fridgeDays: e.target.value })}
/>
</div>
<div className="flex items-center gap-2 pb-1.5">
<Switch
id={`freezer-${dish.id}`}
checked={dish.freezerFriendly}
onCheckedChange={(v) => updateDish(i, { freezerFriendly: v })}
/>
<Label htmlFor={`freezer-${dish.id}`} className="cursor-pointer text-xs">{t("freezerFriendly")}</Label>
</div>
{dish.freezerFriendly && (
<div className="space-y-1 col-span-2">
<Label htmlFor={`freezer-note-${dish.id}`} className="text-xs text-muted-foreground">{t("freezerNote")}</Label>
<Input
id={`freezer-note-${dish.id}`}
value={dish.freezerNote}
onChange={(e) => updateDish(i, { freezerNote: e.target.value })}
/>
</div>
)}
</div>
<div className="space-y-1">
<Label htmlFor={`dayof-${dish.id}`} className="text-xs text-muted-foreground">{t("dayOfInstructions")}</Label>
<Textarea
id={`dayof-${dish.id}`}
value={dish.dayOfInstructions}
onChange={(e) => updateDish(i, { dayOfInstructions: e.target.value })}
rows={2}
/>
</div>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDishes((p) => [...p, newDish()])}
>
<Plus className="h-4 w-4" />
{t("addDish")}
</Button>
</div>
<Separator />
</>
)}
{/* Steps */}
<div className="space-y-3">
<Label>{t("steps")}</Label>
<div className="space-y-3">
{steps.map((step, i) => (
<div key={step.id} className="space-y-1.5">
<div className="flex gap-3 items-start">
<div className="mt-2.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-bold text-muted-foreground">
{i + 1}
</div>
<Textarea
value={step.instruction}
onChange={(e) => updateStep(i, { instruction: e.target.value })}
placeholder={t("stepPlaceholder", { n: i + 1 })}
rows={2}
className="flex-1 min-w-0"
/>
<Input
value={step.timerSeconds}
onChange={(e) => updateStep(i, { timerSeconds: e.target.value })}
placeholder={t("timerSeconds")}
type="number"
min={0}
className="w-28 shrink-0"
/>
<button
type="button"
onClick={() => removeStep(i)}
disabled={steps.length === 1}
className="mt-2.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{isBatchCook && dishes.some((d) => d.name.trim()) && (
<div className="flex flex-wrap gap-1.5 pl-9">
<span className="text-xs text-muted-foreground self-center">{t("appliesTo")}:</span>
{dishes.filter((d) => d.name.trim()).map((d) => (
<button
key={d.id}
type="button"
onClick={() => toggleStepDish(i, d.name.trim())}
className={cn(
"px-2 py-0.5 rounded-full text-xs border transition-colors",
step.appliesTo.includes(d.name.trim())
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-input hover:border-muted-foreground/50"
)}
>
{d.name.trim()}
</button>
))}
{step.appliesTo.length === 0 && (
<span className="text-xs text-muted-foreground italic self-center">{t("sharedPrep")}</span>
)}
</div>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSteps((p) => [...p, newStep()])}
>
<Plus className="h-4 w-4" />
{t("addStep")}
</Button>
</div>
<Separator />
<div className="flex gap-3">
<Button type="submit" disabled={saving}>
{saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
</Button>
<Button type="button" variant="outline" onClick={handleCancelClick}>
{t_common("cancel")}
</Button>
</div>
{/* Floating save bar — the form above can get long, this stays reachable without scrolling back down */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 rounded-full border bg-popover p-1.5 shadow-lg">
<Button type="submit" size="sm" className="rounded-full" disabled={saving}>
{saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
</Button>
<Button type="button" size="sm" variant="ghost" className="rounded-full" onClick={handleCancelClick}>
{t_common("cancel")}
</Button>
</div>
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("discardChangesTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("discardChangesDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("keepEditing")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDiscardConfirmOpen(false);
setDirty(false);
router.back();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("discardChanges")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
);
}