Files
Epicure/apps/web/components/meal-plan/shopping-list-view.tsx
T
Arnaud 51a323b054 fix: shopping list categories were English-only and untranslated, page too narrow
Confirmed from a real screenshot: every item in an all-French shopping list
was uncategorized because guessAisle() only matched English keywords —
"sometimes reorganize doesn't work" was actually "always fails on non-English
ingredient names". Also fixed:

- Categories are now translated (grocery-categories.ts stores locale-
  independent slugs like "produce", translated for display via
  shoppingLists.categories.* instead of storing/rendering the English label
  directly)
- Added French keyword coverage to the aisle guesser so auto-categorization
  actually works for French recipes
- Users can now type a custom category name from the item's category menu
- The sort-mode dropdown was showing the raw value ("category") instead of
  its label — base-ui's Select.Value has no built-in value->label lookup,
  it needs an explicit render function, which no Select in this call site
  had
- Widened the shopping list detail page (max-w-lg -> max-w-2xl)
- Root-caused a second bug visible in the same screenshot: ingredients with
  the quantity embedded in the name (e.g. "1 avocat") still showed that way
  even though quantity/unit were already populated separately, because the
  extraction helper only fired when quantity was empty. Now it always
  cleans the name but only backfills quantity/unit when they're missing,
  and runs at shopping-list generation time too (not just recipe save) so
  it also cleans up already-contaminated recipes, not just new ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:03:11 +02:00

610 lines
22 KiB
TypeScript

"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<Item[]>(initialItems);
const [movingToPantry, setMovingToPantry] = useState(false);
const [query, setQuery] = useState("");
const [sortMode, setSortMode] = useState<SortMode>("category");
const [deleteTarget, setDeleteTarget] = useState<Item | null>(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 <p className="text-muted-foreground text-sm">{t("listEmptyState")}</p>;
}
// 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<Record<string, Item[]>>((acc, item) => {
const key = item.aisle ?? t("aisleOther");
(acc[key] ??= []).push(item);
return acc;
}, {})
: {};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{t("checkedCount", { checked: checkedCount, total: items.length })}</p>
<div className="flex items-center gap-2">
{!readOnly && uncategorizedCount > 0 && (
<Button size="sm" variant="ghost" onClick={autoCategorize} disabled={autoCategorizing}>
{autoCategorizing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{t("autoCategorize")}
</Button>
)}
{checkedCount > 0 && (
<Button size="sm" variant="outline" onClick={moveToPantry} disabled={movingToPantry}>
{movingToPantry ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
{t("moveToPantry", { count: checkedCount })}
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchItemsPlaceholder")}
className="pl-8"
/>
</div>
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="sm:w-48">
<SelectValue>
{(v: SortMode) => ({ category: t("sortByCategory"), alpha: t("sortByAlpha"), unchecked: t("sortByUnchecked") })[v]}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="category">{t("sortByCategory")}</SelectItem>
<SelectItem value="alpha">{t("sortByAlpha")}</SelectItem>
<SelectItem value="unchecked">{t("sortByUnchecked")}</SelectItem>
</SelectContent>
</Select>
</div>
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noSearchResults")}</p>
) : showGroups ? (
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
<ItemGroup
key={aisle}
aisleLabel={categoryLabel(aisle)}
items={aisleItems}
showHeader={Object.keys(grouped).length > 1}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={toggleItem}
onChangeCategory={changeCategory}
onDeleteRequest={setDeleteTarget}
onDragEnd={(event) => handleGroupDragEnd(aisleItems, event)}
/>
))
) : (
<FlatItemList
items={flatItems}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={toggleItem}
onChangeCategory={changeCategory}
onDeleteRequest={setDeleteTarget}
/>
)}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("removeItemConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget ? t("removeItemConfirmDescription", { name: deleteTarget.rawName }) : ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={deleting}
onClick={() => deleteTarget && deleteItem(deleteTarget)}
>
{deleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
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<typeof useTranslations>;
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 (
<div className="space-y-2">
{showHeader && (
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisleLabel}</h2>
)}
<div className="rounded-xl border divide-y">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
{items.map((item) => (
<SortableItemRow
key={item.id}
item={item}
readOnly={readOnly}
draggable={!readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={onToggle}
onChangeCategory={onChangeCategory}
onDeleteRequest={onDeleteRequest}
/>
))}
</SortableContext>
</DndContext>
</div>
</div>
);
}
function FlatItemList({
items,
readOnly,
categoryOptions,
categoryLabel,
tShopping,
onToggle,
onChangeCategory,
onDeleteRequest,
}: {
items: Item[];
readOnly: boolean;
categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void;
}) {
return (
<div className="rounded-xl border divide-y">
{items.map((item) => (
<ItemRow
key={item.id}
item={item}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={onToggle}
onChangeCategory={onChangeCategory}
onDeleteRequest={onDeleteRequest}
/>
))}
</div>
);
}
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 (
<div ref={setNodeRef} style={style} className={cn(isDragging && "z-10 relative bg-background")}>
<ItemRow
{...props}
dragHandle={draggable ? (
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
) : null}
/>
</div>
);
}
type ItemRowProps = {
item: Item;
readOnly: boolean;
categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>;
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 (
<div className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors">
{dragHandle}
<button
onClick={() => onToggle(item)}
disabled={readOnly}
className="flex flex-1 items-center gap-3 text-left disabled:cursor-default"
>
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
item.checked ? "bg-primary border-primary" : "border-input"
)}>
{item.checked && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
{item.rawName}
</span>
{item.inPantry && (
<span className="text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 rounded px-1.5 py-0.5 shrink-0">
{tShopping("alreadyInPantry")}
</span>
)}
{(hasQuantity(item.quantity) || item.unit) && (
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""}
</span>
)}
</button>
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground">
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>{tShopping("changeCategory")}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
{categoryOptions.map((category) => (
<DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}>
{categoryLabel(category)}
</DropdownMenuItem>
))}
<DropdownMenuItem onClick={() => onChangeCategory(item, null)}>
{tShopping("aisleOther")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="flex items-center gap-1 p-1.5" onClick={(e) => e.stopPropagation()}>
<Input
value={newCategory}
onChange={(e) => 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"
/>
<Button
size="sm"
variant="ghost"
className="h-7 shrink-0 px-2 text-xs"
disabled={!newCategory.trim()}
onClick={() => { onChangeCategory(item, newCategory.trim()); setNewCategory(""); }}
>
{tShopping("addCategory")}
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDeleteRequest(item)}>
<Trash2 className="h-4 w-4" />
{tShopping("deleteItem")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}