9eecdbac3c
Second tool alongside createRecipe, same safety shape: addToShoppingList's execute only validates/echoes input, no DB write. The proposal card lets the user pick an existing list (fetched lazily) or name a new one, then confirms through the exact two-call flow AddToShoppingListButton already uses — POST /api/v1/shopping-lists (if new) then POST .../items — no new persistence code path. generateMealPlan intentionally not built as a tool: the existing /api/v1/ai/meal-plan/generate endpoint generates and writes in one step with a week/preferences input, not a specific plan payload, so it has no "save this exact draft" entry point to confirm against without a larger refactor. Documented as a known gap rather than forcing a weaker pattern. v0.46.0
156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { toast } from "sonner";
|
|
import { ShoppingCart, Check, X, Loader2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
export type ShoppingListProposal = {
|
|
items: Array<{ rawName: string; quantity?: number; unit?: string }>;
|
|
suggestedListName?: string;
|
|
};
|
|
|
|
type ShoppingList = { id: string; name: string };
|
|
|
|
const NEW_LIST_VALUE = "__new__";
|
|
|
|
/**
|
|
* Renders a pending addToShoppingList tool proposal — lets the user pick an
|
|
* existing list or name a new one, then confirms through the exact same
|
|
* two-endpoint flow AddToShoppingListButton already uses (create list if
|
|
* needed, then POST items) rather than a new code path.
|
|
*/
|
|
export function ShoppingListProposalCard({
|
|
proposal,
|
|
status,
|
|
onStatusChange,
|
|
}: {
|
|
proposal: ShoppingListProposal;
|
|
status: "pending" | "creating" | "created" | "discarded";
|
|
onStatusChange: (status: "pending" | "creating" | "created" | "discarded") => void;
|
|
}) {
|
|
const t = useTranslations("recipe");
|
|
const tShopping = useTranslations("shoppingLists");
|
|
const tChat = useTranslations("cookingChat");
|
|
const [lists, setLists] = useState<ShoppingList[] | null>(null);
|
|
const [targetListId, setTargetListId] = useState<string>(NEW_LIST_VALUE);
|
|
const [newListName, setNewListName] = useState(proposal.suggestedListName || "Shopping List");
|
|
|
|
useEffect(() => {
|
|
if (status !== "pending" || lists !== null) return;
|
|
fetch("/api/v1/shopping-lists")
|
|
.then((res) => (res.ok ? (res.json() as Promise<ShoppingList[]>) : []))
|
|
.then((data) => {
|
|
setLists(data);
|
|
if (data.length > 0) setTargetListId(data[0]!.id);
|
|
})
|
|
.catch(() => setLists([]));
|
|
}, [status, lists]);
|
|
|
|
async function handleConfirm() {
|
|
onStatusChange("creating");
|
|
try {
|
|
let listId = targetListId;
|
|
if (listId === NEW_LIST_VALUE) {
|
|
const res = await fetch("/api/v1/shopping-lists", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: newListName.trim() || "Shopping List" }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
listId = (await res.json() as { id: string }).id;
|
|
}
|
|
|
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
items: proposal.items.map((item) => ({
|
|
rawName: item.rawName,
|
|
quantity: item.quantity !== undefined ? String(item.quantity) : undefined,
|
|
unit: item.unit,
|
|
})),
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
|
|
onStatusChange("created");
|
|
toast.success(tShopping("addedCount", { count: proposal.items.length }));
|
|
} catch {
|
|
onStatusChange("pending");
|
|
toast.error(t("shoppingListAddFailed"));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="ml-9 rounded-lg border bg-card p-3 space-y-2 max-w-[80%]">
|
|
<div className="flex items-center gap-2">
|
|
<ShoppingCart className="h-3.5 w-3.5 text-primary shrink-0" />
|
|
<span className="font-medium text-sm">{tChat("shoppingProposalTitle", { count: proposal.items.length })}</span>
|
|
</div>
|
|
<ul className="text-xs text-muted-foreground space-y-0.5">
|
|
{proposal.items.slice(0, 5).map((item, i) => (
|
|
<li key={i} className="truncate">
|
|
{item.quantity ? `${item.quantity} ` : ""}{item.unit ? `${item.unit} ` : ""}{item.rawName}
|
|
</li>
|
|
))}
|
|
{proposal.items.length > 5 && <li>{tChat("shoppingProposalMore", { count: proposal.items.length - 5 })}</li>}
|
|
</ul>
|
|
|
|
{status === "created" ? (
|
|
<p className="text-xs text-primary flex items-center gap-1"><Check className="h-3 w-3" />{tChat("proposalCreated")}</p>
|
|
) : status === "discarded" ? (
|
|
<p className="text-xs text-muted-foreground flex items-center gap-1"><X className="h-3 w-3" />{tChat("proposalDiscarded")}</p>
|
|
) : (
|
|
<>
|
|
{lists !== null && (
|
|
<div className="space-y-1.5">
|
|
<Select value={targetListId} onValueChange={(v) => setTargetListId(v ?? NEW_LIST_VALUE)}>
|
|
<SelectTrigger className="h-7 text-xs w-full"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{lists.map((l) => (
|
|
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
|
|
))}
|
|
<SelectItem value={NEW_LIST_VALUE}>{tShopping("modeNew")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{targetListId === NEW_LIST_VALUE && (
|
|
<Input
|
|
value={newListName}
|
|
onChange={(e) => setNewListName(e.target.value)}
|
|
placeholder={tShopping("listNamePlaceholder")}
|
|
className="h-7 text-xs"
|
|
maxLength={100}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
className="h-7 text-xs gap-1"
|
|
disabled={status === "creating" || lists === null}
|
|
onClick={() => void handleConfirm()}
|
|
>
|
|
{status === "creating" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
|
{tChat("shoppingProposalAddButton")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 text-xs"
|
|
disabled={status === "creating"}
|
|
onClick={() => onStatusChange("discarded")}
|
|
>
|
|
{tChat("proposalDiscardButton")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|