Files
Meal-Planner/frontend/src/pages/ShoppingList.tsx
T
admin ccc70aaf72 feat(ui): close 6 P1 audit findings + 1 bonus mobile fix (Sprint 2)
- Dashboard MealCard: title truncate -> line-clamp-2, image shrinks to
  40x40 on <md to give the title room (B6).
- MealDetail: hero reworked to normal flow with stronger gradient;
  description runs through new cleanDescription() helper that strips
  14 spoonacular SEO patterns and trims to the last full sentence.
  Raw description moved to a 'Notes from source' disclosure (B7).
- Pantry: free-text aisle/unit replaced with <Select> populated from
  the new PANTRY_AISLES canonical enum; ingredient name field marked
  required. New PANTRY_AISLES export + PantryAisle type in types (B8).
- backend: alembic 0015_normalize_pantry_aisles maps free-text
  ingredient.aisle and grocery_item.aisle to canonical labels in a
  single transaction; downgrade raises (restore from snapshot).
  backend/scripts/dry_run_aisle_migration.sql is the read-only
  preview helper.
- ShoppingList: human-readable AISLE_LABEL map replaces raw snake_case
  aisle keys; 3-col stat grid with compact mobile sizing (B9 + S3.3).
- Pantry table: role/aria-label region and a right-edge white
  gradient hint at mobile horizontal overflow (B10).
- Recipes: pending/applied filter split, Apply and Reset buttons,
  active-count chip on the Filters button, role=region + aria-label
  on the panel (B11).
- Review/sprint2-verification.md and fix-ui-audit.md updated.

Build: npm run build (tsc + vite) green. tsc emits 0 errors.

Co-located audit + plan docs kept in sync: Review/ui-nielsen-audit.md
gains a Sprint 2 status block; fix-ui-audit.md has implementation
notes for each Sprint 2 task.
2026-06-03 17:36:26 -07:00

301 lines
11 KiB
TypeScript

import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw } from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { ShoppingList } from '../types'
import { Button } from '../components/ui/Button'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
const AISLE_LABEL: Record<string, string> = {
produce: 'Produce',
meat: 'Meat & Seafood',
seafood: 'Meat & Seafood',
meat_seafood: 'Meat & Seafood',
chicken: 'Meat & Seafood',
beef: 'Meat & Seafood',
pork: 'Meat & Seafood',
dairy: 'Dairy & Eggs',
eggs: 'Dairy & Eggs',
cheese: 'Dairy & Eggs',
milk: 'Dairy & Eggs',
yogurt: 'Dairy & Eggs',
pantry: 'Pantry',
canned: 'Pantry',
canned_goods: 'Pantry',
dry: 'Pantry',
snacks: 'Pantry',
frozen: 'Frozen',
bakery: 'Bakery',
bread: 'Bakery',
beverages: 'Beverages',
drinks: 'Beverages',
spices: 'Spices',
seasoning: 'Spices',
other: 'Other',
}
function aisleDisplay(key: string): string {
return AISLE_LABEL[key.toLowerCase()] ?? key
}
function ShoppingListSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-9 w-28" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
<Card>
<CardBody>
<SkeletonText lines={6} />
</CardBody>
</Card>
</div>
)
}
function SkeletonCard() {
return (
<Card>
<CardBody>
<Skeleton className="h-10 w-24 mb-2" />
<Skeleton className="h-4 w-32" />
</CardBody>
</Card>
)
}
function storageKey(week: string) {
return `shopping-list-checked-${week}`
}
export default function ShoppingListPage() {
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
queryKey: ['shoppingList'],
queryFn: () => mealPlannerApi.shoppingList.get().then(r => r.data),
})
const week = shoppingList?.week_start_date || ''
const [checked, setChecked] = useState<Set<string>>(new Set())
useEffect(() => {
if (!week) return
try {
const raw = localStorage.getItem(storageKey(week))
if (raw) {
const parsed = JSON.parse(raw) as string[]
setChecked(new Set(parsed))
} else {
setChecked(new Set())
}
} catch {
setChecked(new Set())
}
}, [week])
useEffect(() => {
if (!week) return
localStorage.setItem(storageKey(week), JSON.stringify(Array.from(checked)))
}, [checked, week])
const toggle = (id: string) => {
setChecked(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const clearAll = () => setChecked(new Set())
const progress =
shoppingList && shoppingList.items.length > 0
? Math.round(
(Array.from(checked).filter(id =>
shoppingList.items.some(i => i.ingredient_id === id)
).length /
shoppingList.items.length) *
100
)
: 0
if (isLoading) {
return <ShoppingListSkeleton />
}
if (!shoppingList || shoppingList.items.length === 0) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
<ShoppingCart className="w-5 h-5 text-primary-600" />
</div>
<h1 className="text-2xl font-bold text-surface-900">Shopping List</h1>
</div>
<EmptyState
icon={Receipt}
title="No shopping list yet"
description="Generate a meal plan first to see your aisle-organized shopping list with sale tracking."
/>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
<ShoppingCart className="w-5 h-5 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-surface-900">
Shopping List
</h1>
<p className="text-sm text-surface-500">
Week of {new Date(shoppingList.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{progress > 0 && (
<span className="text-sm text-surface-500">{progress}% complete</span>
)}
{checked.size > 0 && (
<Button
variant="ghost"
size="sm"
icon={<RotateCcw className="w-4 h-4" />}
onClick={clearAll}
>
Reset
</Button>
)}
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
Print List
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<Card className="bg-gradient-to-br from-primary-50 to-primary-100/50 border-primary-200">
<CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-primary-100 flex items-center justify-center flex-shrink-0">
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-primary-600" />
</div>
<div className="min-w-0">
<div className="text-base sm:text-2xl font-bold text-surface-900 truncate">
${shoppingList.total_estimated_cost.toFixed(2)}
</div>
<div className="text-[10px] sm:text-sm text-surface-500 truncate">Estimated</div>
</div>
</div>
</CardBody>
</Card>
<Card>
<CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-surface-100 flex items-center justify-center flex-shrink-0">
<Package className="w-4 h-4 sm:w-5 sm:h-5 text-surface-600" />
</div>
<div className="min-w-0">
<div className="text-base sm:text-2xl font-bold text-surface-900 truncate">{shoppingList.items.length}</div>
<div className="text-[10px] sm:text-sm text-surface-500 truncate">Items</div>
</div>
</div>
</CardBody>
</Card>
<Card className="bg-gradient-to-br from-success-50 to-success-100/50 border-success-200">
<CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-success-100 flex items-center justify-center flex-shrink-0">
<Tag className="w-4 h-4 sm:w-5 sm:h-5 text-success-600" />
</div>
<div className="min-w-0">
<div className="text-base sm:text-2xl font-bold text-success-700 truncate">{shoppingList.sale_items_count}</div>
<div className="text-[10px] sm:text-sm text-surface-500 truncate">On Sale</div>
</div>
</div>
</CardBody>
</Card>
</div>
{/* Aisle Groups */}
<div className="space-y-4">
{Object.entries(shoppingList.by_aisle).map(([aisle, items]) => (
<Card key={aisle} className="overflow-hidden">
<div className="px-5 py-3 border-b border-surface-200 bg-surface-50 flex items-center justify-between">
<h3 className="text-sm font-semibold text-surface-900">{aisleDisplay(aisle)}</h3>
<Badge variant="neutral">{items.length} items</Badge>
</div>
<ul className="divide-y divide-surface-200">
{items.map((item, idx) => {
const isChecked = item.ingredient_id ? checked.has(item.ingredient_id) : false
return (
<li
key={idx}
className={`px-5 py-3.5 flex items-center justify-between hover:bg-surface-50 transition-colors ${isChecked ? 'opacity-60' : ''}`}
>
<div className="flex items-center gap-3">
<input
type="checkbox"
className="w-5 h-5 rounded border-2 border-surface-300 text-primary-600 focus:ring-primary-500 cursor-pointer flex-shrink-0"
checked={isChecked}
onChange={() => item.ingredient_id && toggle(item.ingredient_id)}
/>
<div>
<span className={`text-sm font-medium text-surface-900 ${isChecked ? 'line-through text-surface-400' : ''}`}>
{item.name}
</span>
<div className="flex gap-1.5 mt-0.5">
{item.in_pantry && <Badge variant="success">In Pantry</Badge>}
{item.is_on_sale && <Badge variant="danger">SALE</Badge>}
</div>
</div>
</div>
<div className="text-right flex items-center gap-3">
{item.quantity && (
<span className={`text-sm text-surface-500 ${isChecked ? 'line-through' : ''}`}>
{item.quantity} {item.unit || ''}
</span>
)}
{item.sale_price ? (
<div className="flex flex-col items-end">
<span className={`text-sm font-semibold text-danger-600 ${isChecked ? 'line-through' : ''}`}>${item.sale_price.toFixed(2)}</span>
{item.estimated_price && item.estimated_price > item.sale_price && (
<span className="text-xs text-surface-400 line-through">
${item.estimated_price.toFixed(2)}
</span>
)}
</div>
) : item.estimated_price ? (
<span className={`text-sm font-medium text-surface-700 ${isChecked ? 'line-through' : ''}`}>
${item.estimated_price.toFixed(2)}
</span>
) : null}
</div>
</li>
)
})}
</ul>
</Card>
))}
</div>
</div>
)
}