Files
Epicure/apps/web/components/recipe/add-to-shopping-list-button.tsx
T
2026-07-01 11:10:37 +02:00

228 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);
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>
</>
);
}