228 lines
8.3 KiB
TypeScript
228 lines
8.3 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|