"use client"; import { useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import { Check, Package, Loader2, GripVertical, 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; 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, DragOverEvent, DragStartEvent, 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"; // Sentinel group key for items with no aisle (the "Other" bucket) — using a stable, // locale-independent key (rather than the translated "Other" text) as the group // identifier avoids that identifier changing across locales and lets it double as a // dnd-kit droppable/group key. const OTHER_KEY = "__other__"; 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 sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); // Aisle the dragged item had when the drag started — captured so onDragEnd can tell // whether the item actually changed category (and needs a persisted aisle update) as // opposed to just being reordered within its original group. const dragStartAisleRef = useRef(null); 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 groupKeyOf(item: Item): string { return item.aisle ?? OTHER_KEY; } // Persists just the aisle for one item, without touching local state — used at drag // end once the item has already visually settled into its destination group via // onDragOver. Reverts on failure (only the field, not its position — a rare-case // rollback, not worth re-deriving the exact prior array position for). async function persistAisleChange(itemId: string, nextAisle: string | null, prevAisle: string | null) { try { const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${itemId}`, { 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 === itemId ? { ...i, aisle: prevAisle } : i)); toast.error(tCommon("updateFailed")); } } function handleDragStart(event: DragStartEvent) { const activeItem = items.find((i) => i.id === event.active.id); dragStartAisleRef.current = activeItem?.aisle ?? null; } // Live cross-category preview: without this, dragging over a different group's rows // does nothing visually until drop, since dnd-kit only computes a transform for items // that are actually present in the target group's array. Moving the item into the // destination group's position in `items` as soon as it's dragged over makes it // render there immediately, like any other multi-container dnd-kit board. Purely // local — nothing is persisted until drag end. function handleDragOver(event: DragOverEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const activeItem = items.find((i) => i.id === active.id); const overItem = items.find((i) => i.id === over.id); if (!activeItem || !overItem) return; if (groupKeyOf(activeItem) === groupKeyOf(overItem)) return; setItems((prev) => { const withoutActive = prev.filter((i) => i.id !== activeItem.id); const overIndex = withoutActive.findIndex((i) => i.id === overItem.id); if (overIndex === -1) return prev; const next = [...withoutActive]; next.splice(overIndex, 0, { ...activeItem, aisle: overItem.aisle }); return next; }); } // Single handler for the whole list (not per-group) — a per-group DndContext can // only ever resolve drops within its own group, which is exactly why cross-category // dragging didn't work before. By the time this fires, onDragOver has already moved // the item into its destination group locally, so this just finalizes the exact // position within that group and persists whatever actually changed: the new order, // and — only if the category actually changed since drag start — the new aisle. function handleDragEnd(event: DragEndEvent) { const { active, over } = event; const startAisle = dragStartAisleRef.current; dragStartAisleRef.current = null; if (!over) { // Dropped outside any droppable — onDragOver may have already moved the item // into a different group locally (pure preview, never persisted); put it back // rather than leaving a moved-but-unsaved item that only "resets" on refresh. setItems((prev) => prev.map((i) => i.id === active.id ? { ...i, aisle: startAisle } : i)); return; } const activeItem = items.find((i) => i.id === active.id); if (!activeItem) return; const destKey = groupKeyOf(activeItem); const groupItems = items.filter((i) => groupKeyOf(i) === destKey); const oldIndex = groupItems.findIndex((i) => i.id === active.id); const newIndex = active.id === over.id ? oldIndex : groupItems.findIndex((i) => i.id === over.id); if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { const reordered = arrayMove(groupItems, oldIndex, newIndex); const reorderedIds = new Set(reordered.map((i) => i.id)); setItems((prev) => { let cursor = 0; return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i)); }); void persistOrder(reordered); } if (startAisle !== activeItem.aisle) { void persistAisleChange(activeItem.id, activeItem.aisle, startAisle); } } 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 = groupKeyOf(item); (acc[key] ??= []).push(item); return acc; }, {}) : {}; function groupLabel(key: string): string { return key === OTHER_KEY ? tShopping("aisleOther") : categoryLabel(key); } 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]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => ( 1} readOnly={readOnly} categoryOptions={categoryOptions} categoryLabel={categoryLabel} tShopping={tShopping} onToggle={toggleItem} onChangeCategory={changeCategory} onDeleteRequest={setDeleteTarget} /> ))}
) : ( )} !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, }: { 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; }) { 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 && (
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")}
)} ); }