fix: shopping list ingredient UUID parsing for prices/aisles; feat: interactive checkboxes with localStorage persistence

This commit is contained in:
2026-05-17 13:12:18 -07:00
parent 2f6ab006de
commit 879768f72b
3 changed files with 133 additions and 52 deletions
+115 -40
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Printer, ShoppingCart, Package, Tag, Receipt } from 'lucide-react'
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'
@@ -40,12 +41,61 @@ function SkeletonCard() {
)
}
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 />
}
@@ -85,9 +135,24 @@ export default function ShoppingListPage() {
</p>
</div>
</div>
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
Print List
</Button>
<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 */}
@@ -146,44 +211,54 @@ export default function ShoppingListPage() {
<Badge variant="neutral">{items.length} items</Badge>
</div>
<ul className="divide-y divide-surface-200">
{items.map((item, idx) => (
<li
key={idx}
className="px-5 py-3.5 flex items-center justify-between hover:bg-surface-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-surface-300 flex-shrink-0" />
<div>
<span className="text-sm font-medium text-surface-900">{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>}
{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>
<div className="text-right flex items-center gap-3">
{item.quantity && (
<span className="text-sm text-surface-500">
{item.quantity} {item.unit || ''}
</span>
)}
{item.sale_price ? (
<div className="flex flex-col items-end">
<span className="text-sm font-semibold text-danger-600">${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">
${item.estimated_price.toFixed(2)}
</span>
) : null}
</div>
</li>
))}
<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>
))}