Public Access
- 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.
301 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|