Files
Epicure/apps/web/components/recipe/add-to-shopping-list-button.tsx
T
Arnaud afff6cf9eb fix: substitute lookup, zero-quantity display, chatbot close button + markdown
- Substitute finder never applied the user's BYOK/admin AI key (only fell
  back to raw process.env), so it silently failed whenever the key was
  stored via settings rather than a literal env var. Now resolves via
  getDefaultProviderWithKey like every other AI route. Popover also
  surfaces real error messages instead of swallowing failures.
- Ingredients with quantity 0 (salt, pepper, "to taste") rendered the
  literal digit "0" in cooking mode, print view, public recipe page,
  shopping lists, and serving scaler — several sites relied on generic
  truthiness/filter(Boolean), which doesn't catch a stored "0" string.
  Added a shared hasQuantity() helper and applied it everywhere quantity
  is rendered, plus in the AI recipe-chat context sent to the model.
- Recipe chat panel rendered a duplicate close button on top of shadcn
  Sheet's own built-in close X, producing a garbled overlapping glyph.
  Removed the duplicate.
- Recipe chat assistant replies are markdown from the model but were
  rendered as raw text; added react-markdown so formatting actually
  renders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 07:43:37 +02:00

229 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { ShoppingCart, Loader2, Plus, Check } 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 { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
type Ingredient = {
rawName: string;
quantity?: string | number | null;
unit?: string | null;
};
type ShoppingList = { id: string; name: string };
function scaleQty(quantity: string | number | null | undefined, scale: number): string | undefined {
if (!quantity) return undefined;
const n = typeof quantity === "number" ? quantity : parseFloat(quantity);
if (isNaN(n)) return String(quantity);
if (n === 0) return undefined;
const scaled = n * scale;
return scaled % 1 === 0 ? String(scaled) : scaled.toFixed(2).replace(/\.?0+$/, "");
}
export function AddToShoppingListButton({
recipeId,
recipeTitle,
baseServings,
ingredients,
}: {
recipeId: string;
recipeTitle: string;
baseServings: number;
ingredients: Ingredient[];
}) {
const [open, setOpen] = useState(false);
const [lists, setLists] = useState<ShoppingList[]>([]);
const [loadingLists, setLoadingLists] = useState(false);
const [mode, setMode] = useState<"existing" | "new">("existing");
const [selectedListId, setSelectedListId] = useState<string>("");
const [newListName, setNewListName] = useState(`${recipeTitle} shopping`);
const [servings, setServings] = useState(baseServings);
const [adding, setAdding] = useState(false);
useEffect(() => {
if (!open) return;
setLoadingLists(true);
fetch("/api/v1/shopping-lists")
.then((r) => r.json() as Promise<ShoppingList[]>)
.then((data) => {
setLists(data);
if (data.length > 0) {
setMode("existing");
setSelectedListId(data[0]!.id);
} else {
setMode("new");
}
})
.catch(() => setMode("new"))
.finally(() => setLoadingLists(false));
}, [open]);
const scale = servings / baseServings;
const items = ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: scaleQty(ing.quantity, scale),
unit: ing.unit ?? undefined,
}));
async function handleAdd() {
setAdding(true);
try {
let listId = selectedListId;
if (mode === "new") {
const res = await fetch("/api/v1/shopping-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newListName.trim() || recipeTitle }),
});
if (!res.ok) { toast.error("Failed to create list"); return; }
const created = await res.json() as { id: string };
listId = created.id;
}
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
if (!res.ok) { toast.error("Failed to add ingredients"); return; }
toast.success(`${items.length} ingredients added to list`);
setOpen(false);
} finally {
setAdding(false);
}
}
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<ShoppingCart className="h-4 w-4" />
</Button>
} />
<TooltipContent>Add to list</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5 text-primary" />
Add to shopping list
</DialogTitle>
<DialogDescription>
Add the ingredients of <strong>{recipeTitle}</strong> to a shopping list.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Servings scaler */}
<div className="flex items-center gap-3">
<Label className="shrink-0">Servings</Label>
<div className="flex items-center gap-2">
<Button
type="button" variant="ghost" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => Math.max(1, s - 1))}
disabled={servings <= 1}
></Button>
<span className="w-8 text-center font-medium">{servings}</span>
<Button
type="button" variant="ghost" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => s + 1)}
>+</Button>
</div>
{scale !== 1 && (
<span className="text-xs text-muted-foreground">({scale > 1 ? "×" : "÷"}{Math.abs(scale) !== 1 ? (scale > 1 ? scale.toFixed(2).replace(/\.?0+$/, "") : (1 / scale).toFixed(2).replace(/\.?0+$/, "")) : ""} scaled)</span>
)}
</div>
<Separator />
{/* List picker */}
{loadingLists ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading your lists
</div>
) : (
<div className="space-y-3">
{lists.length > 0 && (
<RadioGroup value={mode} onValueChange={(v: string) => setMode(v as "existing" | "new")}>
<div className="flex items-center gap-2">
<RadioGroupItem value="existing" id="mode-existing" />
<Label htmlFor="mode-existing">Add to existing list</Label>
</div>
{mode === "existing" && (
<div className="ml-6 space-y-1.5">
{lists.map((list) => (
<button
key={list.id}
onClick={() => setSelectedListId(list.id)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center justify-between ${
selectedListId === list.id
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
}`}
>
{list.name}
{selectedListId === list.id && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
<div className="flex items-center gap-2">
<RadioGroupItem value="new" id="mode-new" />
<Label htmlFor="mode-new">Create new list</Label>
</div>
</RadioGroup>
)}
{(mode === "new" || lists.length === 0) && (
<div className="space-y-1.5 ml-6">
<Input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder="List name"
/>
</div>
)}
</div>
)}
<Separator />
<p className="text-xs text-muted-foreground">{items.length} ingredient{items.length !== 1 ? "s" : ""} will be added.</p>
<div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setOpen(false)} disabled={adding}>Cancel</Button>
<Button onClick={handleAdd} disabled={adding || loadingLists || (mode === "existing" && !selectedListId)}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? "Adding…" : "Add ingredients"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}