Files
Arnaud d8dc0aa465 fix: variations/adapt-recipe silent errors and no-op duplicates
AI adapt/variations flows had no catch on their fetch calls, so a
network failure surfaced as nothing happening rather than an error
toast. They also unconditionally persisted the AI's output even when
it was identical to the source recipe, and the adapt route never
recorded a recipeVariations row (only plain forks did) or enforced
the per-tier recipe-count limit other create paths already check.

v0.39.0
2026-07-17 16:48:38 +02:00

204 lines
6.6 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { Wand2, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
type Ingredient = { rawName: string };
export function AdaptRecipeButton({
recipeId,
ingredients,
}: {
recipeId: string;
ingredients: Ingredient[];
}) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const router = useRouter();
const [open, setOpen] = useState(false);
const [excluded, setExcluded] = useState<Set<string>>(new Set());
const [extraConstraints, setExtraConstraints] = useState("");
const [adapting, setAdapting] = useState(false);
const [adaptationNotes, setAdaptationNotes] = useState("");
function toggleExclude(name: string) {
setExcluded((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
function reset() {
setExcluded(new Set());
setExtraConstraints("");
setAdaptationNotes("");
}
async function handleAdapt() {
if (excluded.size === 0 && !extraConstraints.trim()) {
toast.error(t("adaptConstraintRequired"));
return;
}
setAdapting(true);
setAdaptationNotes("");
try {
const res = await fetch(`/api/v1/ai/adapt/${recipeId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
excludeIngredients: Array.from(excluded),
extraConstraints: extraConstraints.trim() || undefined,
}),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? t("adaptFailed"));
return;
}
const { id, adaptationNotes: notes, unchanged } = await res.json() as { id: string; adaptationNotes: string; unchanged?: boolean };
setAdaptationNotes(notes);
setOpen(false);
reset();
if (unchanged) {
toast.info(t("adaptUnchanged"));
return;
}
toast.success(t("adapted"));
router.push(`/recipes/${id}/edit`);
} catch {
toast.error(t("adaptFailed"));
} finally {
setAdapting(false);
}
}
const hasConstraints = excluded.size > 0 || extraConstraints.trim().length > 0;
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} aria-label={t("adaptTooltip")}>
<Wand2 className="h-4 w-4" />
</Button>
} />
<TooltipContent>{t("adaptTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5 text-primary" />
{t("adaptTitle")}
</DialogTitle>
<DialogDescription>
{t("adaptDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
{/* Ingredient chips */}
<div className="space-y-2">
<Label>
{t("excludeIngredients")}
{excluded.size > 0 && (
<span className="ml-2 text-xs text-destructive font-normal">
{t("excludedCount", { count: excluded.size })}
</span>
)}
</Label>
<div className="flex flex-wrap gap-2">
{ingredients.map((ing, idx) => {
const isExcluded = excluded.has(ing.rawName);
return (
<button
key={`${ing.rawName}-${idx}`}
type="button"
onClick={() => toggleExclude(ing.rawName)}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm border transition-all",
isExcluded
? "bg-destructive/10 border-destructive/40 text-destructive line-through"
: "bg-muted border-transparent hover:border-border hover:bg-accent"
)}
>
{isExcluded && <X className="h-3 w-3 shrink-0" />}
{ing.rawName}
</button>
);
})}
</div>
</div>
{/* Free-text constraints */}
<div className="space-y-2">
<Label htmlFor="extra-constraints">
{t("additionalConstraints")}
<span className="ml-2 text-xs text-muted-foreground font-normal">{t("optional")}</span>
</Label>
<Textarea
id="extra-constraints"
value={extraConstraints}
onChange={(e) => setExtraConstraints(e.target.value)}
placeholder={t("adaptConstraintPlaceholder")}
rows={2}
disabled={adapting}
/>
</div>
{adapting && (
<p className="text-sm text-muted-foreground">
{t("adapting")}
</p>
)}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => { setOpen(false); reset(); }}
disabled={adapting}
>
{tCommon("cancel")}
</Button>
{excluded.size > 0 && (
<Button variant="ghost" size="sm" onClick={() => setExcluded(new Set())} disabled={adapting}>
{t("clearExclusions")}
</Button>
)}
<Button onClick={handleAdapt} disabled={adapting || !hasConstraints}>
{adapting
? <><Loader2 className="h-4 w-4 animate-spin" />{t("adaptingButton")}</>
: <><Wand2 className="h-4 w-4" />{t("adaptButton")}</>
}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}