"use client"; import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import { Check, Package, Loader2, GripVertical, MoreVertical, Trash2, Search, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { hasQuantity } from "@/lib/fractions"; import { guessAisle, GROCERY_CATEGORIES } from "@/lib/grocery-categories"; import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors, closestCenter, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; type Item = { id: string; rawName: string; quantity: string | null; unit: string | null; aisle: string | null; checked: boolean; inPantry?: boolean; sortOrder?: number; }; type SortMode = "category" | "alpha" | "unchecked"; export function ShoppingListView({ listId, initialItems, readOnly = false, }: { listId: string; initialItems: Item[]; readOnly?: boolean; }) { const t = useTranslations("mealPlan"); const tShopping = useTranslations("shoppingLists"); const tCommon = useTranslations("common"); const [items, setItems] = useState(initialItems); const [movingToPantry, setMovingToPantry] = useState(false); const [query, setQuery] = useState(""); const [sortMode, setSortMode] = useState("category"); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [autoCategorizing, setAutoCategorizing] = useState(false); // Predefined categories, plus any custom category already in use on this list // (so a custom category someone created stays selectable for other items too), // plus "Other" (represented as `null` under the hood) always last. const customCategoriesInUse = useMemo( () => Array.from(new Set(items.map((i) => i.aisle).filter((a): a is string => !!a && !(GROCERY_CATEGORIES as readonly string[]).includes(a)))).sort(), [items] ); const categoryOptions = useMemo( () => [...GROCERY_CATEGORIES, ...customCategoriesInUse], [customCategoriesInUse] ); function categoryLabel(category: string): string { return (GROCERY_CATEGORIES as readonly string[]).includes(category) ? tShopping(`categories.${category}`) : category; } const checkedItems = items.filter((i) => i.checked); const isSearching = query.trim().length > 0; // Any item generated without an aisle (e.g. from an older list, before auto-categorization // existed, or added manually without one) — offering a one-click fix for those. const uncategorizedCount = items.filter((i) => !i.aisle).length; async function moveToPantry() { if (checkedItems.length === 0) return; setMovingToPantry(true); try { const res = await fetch("/api/v1/pantry/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: checkedItems.map((i) => ({ rawName: i.rawName, quantity: i.quantity ?? undefined, unit: i.unit ?? undefined, })), }), }); if (!res.ok) { toast.error(t("moveToPantryFailed")); return; } toast.success(t("addedToPantry", { count: checkedItems.length })); } finally { setMovingToPantry(false); } } async function toggleItem(item: Item) { if (readOnly) return; const next = !item.checked; setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i)); try { const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ checked: next }), }); if (!res.ok) throw new Error(); } catch { setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: !next } : i)); toast.error(tCommon("updateFailed")); } } async function changeCategory(item: Item, nextAisle: string | null) { if (readOnly) return; const prevAisle = item.aisle; setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i)); try { const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ aisle: nextAisle }), }); if (!res.ok) throw new Error(); } catch { setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: prevAisle } : i)); toast.error(tCommon("updateFailed")); } } async function deleteItem(item: Item) { if (readOnly) return; setDeleting(true); const prevItems = items; setItems((prev) => prev.filter((i) => i.id !== item.id)); try { const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, { method: "DELETE" }); if (!res.ok) throw new Error(); setDeleteTarget(null); } catch { setItems(prevItems); toast.error(t("removeFailed")); } finally { setDeleting(false); } } // One-click fix for lists whose items predate auto-categorization (or were added // manually without a category) — guesses an aisle for every uncategorized item and // persists all of them in a single batched request rather than one PUT per item. async function autoCategorize() { const targets = items.filter((i) => !i.aisle); if (targets.length === 0) return; setAutoCategorizing(true); const updates = targets .map((i) => ({ id: i.id, aisle: guessAisle(i.rawName) })) .filter((u) => u.aisle !== null) as { id: string; aisle: string }[]; if (updates.length === 0) { setAutoCategorizing(false); toast.error(t("autoCategorizeNoneFound")); return; } const byId = new Map(updates.map((u) => [u.id, u.aisle])); setItems((prev) => prev.map((i) => byId.has(i.id) ? { ...i, aisle: byId.get(i.id)! } : i)); try { const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: updates }), }); if (!res.ok) throw new Error(); toast.success(t("autoCategorizeSuccess", { count: updates.length })); } catch { toast.error(tCommon("updateFailed")); } finally { setAutoCategorizing(false); } } // Persists a new within-group order for a set of reordered items in a single batched // request (rather than firing one PUT per dragged item). async function persistOrder(reordered: Item[]) { const updates = reordered.map((item, index) => ({ id: item.id, sortOrder: index })); try { const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: updates }), }); if (!res.ok) throw new Error(); } catch { toast.error(tCommon("updateFailed")); } } function handleGroupDragEnd(groupItems: Item[], event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = groupItems.findIndex((i) => i.id === active.id); const newIndex = groupItems.findIndex((i) => i.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const reordered = arrayMove(groupItems, oldIndex, newIndex); const reorderedIds = new Set(reordered.map((i) => i.id)); setItems((prev) => { // Splice the reordered group items back into their original positions within // the full list, leaving every other item untouched. let cursor = 0; return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i)); }); void persistOrder(reordered); } const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return items; return items.filter((i) => i.rawName.toLowerCase().includes(q)); }, [items, query]); const checkedCount = items.filter((i) => i.checked).length; if (items.length === 0) { return

{t("listEmptyState")}

; } // Search flattens category grouping — when hunting for a specific item you want to // find it regardless of which aisle it landed in, and headers-with-one-match reads // worse than a simple flat result list. Outside of search, "category" sort mode keeps // the grouped view (and is the only mode where drag-to-reorder applies, since // "within category order" is the only ordering concept reordering makes sense for). const showGroups = !isSearching && sortMode === "category"; let flatItems: Item[] = []; if (!showGroups) { flatItems = [...filtered]; if (sortMode === "alpha" || isSearching) { flatItems.sort((a, b) => a.rawName.localeCompare(b.rawName)); } else if (sortMode === "unchecked") { flatItems.sort((a, b) => { if (a.checked !== b.checked) return a.checked ? 1 : -1; return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.rawName.localeCompare(b.rawName); }); } } const grouped = showGroups ? filtered.reduce>((acc, item) => { const key = item.aisle ?? t("aisleOther"); (acc[key] ??= []).push(item); return acc; }, {}) : {}; return (

{t("checkedCount", { checked: checkedCount, total: items.length })}

{!readOnly && uncategorizedCount > 0 && ( )} {checkedCount > 0 && ( )}
setQuery(e.target.value)} placeholder={t("searchItemsPlaceholder")} className="pl-8" />
{filtered.length === 0 ? (

{t("noSearchResults")}

) : showGroups ? ( Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => ( 1} readOnly={readOnly} categoryOptions={categoryOptions} categoryLabel={categoryLabel} tShopping={tShopping} onToggle={toggleItem} onChangeCategory={changeCategory} onDeleteRequest={setDeleteTarget} onDragEnd={(event) => handleGroupDragEnd(aisleItems, event)} /> )) ) : ( )} !open && setDeleteTarget(null)}> {t("removeItemConfirmTitle")} {deleteTarget ? t("removeItemConfirmDescription", { name: deleteTarget.rawName }) : ""} {tCommon("cancel")} deleteTarget && deleteItem(deleteTarget)} > {deleting ? : tCommon("delete")}
); } function ItemGroup({ aisleLabel, items, showHeader, readOnly, categoryOptions, categoryLabel, tShopping, onToggle, onChangeCategory, onDeleteRequest, onDragEnd, }: { aisleLabel: string; items: Item[]; showHeader: boolean; readOnly: boolean; categoryOptions: string[]; categoryLabel: (category: string) => string; tShopping: ReturnType; onToggle: (item: Item) => void; onChangeCategory: (item: Item, category: string | null) => void; onDeleteRequest: (item: Item) => void; onDragEnd: (event: DragEndEvent) => void; }) { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); return (
{showHeader && (

{aisleLabel}

)}
i.id)} strategy={verticalListSortingStrategy}> {items.map((item) => ( ))}
); } function FlatItemList({ items, readOnly, categoryOptions, categoryLabel, tShopping, onToggle, onChangeCategory, onDeleteRequest, }: { items: Item[]; readOnly: boolean; categoryOptions: string[]; categoryLabel: (category: string) => string; tShopping: ReturnType; onToggle: (item: Item) => void; onChangeCategory: (item: Item, category: string | null) => void; onDeleteRequest: (item: Item) => void; }) { return (
{items.map((item) => ( ))}
); } function SortableItemRow(props: ItemRowProps & { draggable: boolean }) { const { item, draggable } = props; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id, disabled: !draggable }); const style = { transform: CSS.Transform.toString(transform), transition, }; return (
) : null} />
); } type ItemRowProps = { item: Item; readOnly: boolean; categoryOptions: string[]; categoryLabel: (category: string) => string; tShopping: ReturnType; onToggle: (item: Item) => void; onChangeCategory: (item: Item, category: string | null) => void; onDeleteRequest: (item: Item) => void; dragHandle?: React.ReactNode; }; function ItemRow({ item, readOnly, categoryOptions, categoryLabel, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) { const [newCategory, setNewCategory] = useState(""); return (
{dragHandle} {!readOnly && ( {tShopping("changeCategory")} {categoryOptions.map((category) => ( onChangeCategory(item, category)}> {categoryLabel(category)} ))} onChangeCategory(item, null)}> {tShopping("aisleOther")}
e.stopPropagation()}> setNewCategory(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newCategory.trim()) { onChangeCategory(item, newCategory.trim()); setNewCategory(""); } }} placeholder={tShopping("newCategoryPlaceholder")} className="h-7 text-xs" />
onDeleteRequest(item)}> {tShopping("deleteItem")}
)}
); }