Public Access
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.
This commit is contained in:
@@ -75,16 +75,16 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
|
||||
<img
|
||||
src={item.recipe.image_url}
|
||||
alt={item.recipe.name}
|
||||
className="w-14 h-14 object-cover rounded-lg flex-shrink-0"
|
||||
className="w-10 h-10 md:w-14 md:h-14 object-cover rounded-lg flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
||||
<CookingPot className="w-6 h-6 text-surface-400" />
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
||||
<CookingPot className="w-5 h-5 md:w-6 md:h-6 text-surface-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 pr-7">
|
||||
<Link to={`/meals/${item.id}`} className="block">
|
||||
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
|
||||
<h4 className="font-semibold text-sm text-surface-900 leading-tight line-clamp-2 group-hover:text-primary-700 transition-colors">
|
||||
{item.recipe?.name || 'Unknown Recipe'}
|
||||
</h4>
|
||||
</Link>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||
import { Select } from '../components/ui/Select'
|
||||
import { Textarea } from '../components/ui/Textarea'
|
||||
import { showToast } from '../lib/toast'
|
||||
import { cleanDescription } from '../lib/utils'
|
||||
|
||||
const DENIAL_REASONS = [
|
||||
{ value: '', label: 'Select a reason...' },
|
||||
@@ -102,6 +103,7 @@ export default function MealDetail() {
|
||||
const [text, setText] = useState('')
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [editingFeedback, setEditingFeedback] = useState(false)
|
||||
const [showFullDescription, setShowFullDescription] = useState(false)
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload),
|
||||
@@ -142,6 +144,9 @@ export default function MealDetail() {
|
||||
const recipe = item.recipe
|
||||
const feedback = existingFeedback
|
||||
|
||||
const cleanDesc = cleanDescription(recipe.description)
|
||||
const hasRawDescription = !!recipe.description && recipe.description.trim().length > 0
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!id) return
|
||||
@@ -170,20 +175,22 @@ export default function MealDetail() {
|
||||
<img
|
||||
src={recipe.image_url}
|
||||
alt={recipe.name}
|
||||
className="w-full h-48 sm:h-72 object-cover opacity-90"
|
||||
className="w-full h-48 sm:h-64 object-cover opacity-90"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-48 sm:h-72 flex items-center justify-center">
|
||||
<div className="h-48 sm:h-64 flex items-center justify-center">
|
||||
<ChefHat className="w-16 h-16 text-surface-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 sm:p-6">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
|
||||
<div className="relative p-4 sm:p-6 -mt-16 sm:-mt-20">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-3xl font-bold text-white">{recipe.name}</h1>
|
||||
{recipe.description && (
|
||||
<p className="text-white/80 mt-1 max-w-xl text-xs sm:text-sm">{recipe.description}</p>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-3xl font-bold text-white drop-shadow">{recipe.name}</h1>
|
||||
{cleanDesc && (
|
||||
<p className="text-white/85 mt-1 max-w-2xl text-xs sm:text-sm line-clamp-2">
|
||||
{cleanDesc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left sm:text-right flex-shrink-0">
|
||||
@@ -280,6 +287,30 @@ export default function MealDetail() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Notes from source — disclosure of full original marketing description */}
|
||||
{hasRawDescription && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullDescription(s => !s)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
aria-expanded={showFullDescription}
|
||||
>
|
||||
<span className="text-sm font-semibold text-surface-700">Notes from source</span>
|
||||
<span className="text-xs text-primary-600">{showFullDescription ? 'Hide' : 'Show'}</span>
|
||||
</button>
|
||||
</CardHeader>
|
||||
{showFullDescription && (
|
||||
<CardBody>
|
||||
<p className="text-sm text-surface-600 leading-relaxed whitespace-pre-line">
|
||||
{recipe.description}
|
||||
</p>
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -2,14 +2,20 @@ import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react'
|
||||
import { mealPlannerApi } from '../api'
|
||||
import type { HomePantryItem, Ingredient } from '../types'
|
||||
import { PANTRY_AISLES, type HomePantryItem, type Ingredient } from '../types'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card, CardBody } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Select } from '../components/ui/Select'
|
||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||
import { EmptyState } from '../components/ui/EmptyState'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
const AISLE_OPTIONS = [
|
||||
{ value: '', label: 'Select aisle…' },
|
||||
...PANTRY_AISLES.map(a => ({ value: a, label: a })),
|
||||
]
|
||||
|
||||
export default function Pantry() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -174,10 +180,11 @@ export default function Pantry() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label="Ingredient name"
|
||||
label="Ingredient name *"
|
||||
value={ingredientName}
|
||||
onChange={(e) => setIngredientName(e.target.value)}
|
||||
placeholder="e.g., Avocado"
|
||||
required
|
||||
/>
|
||||
{matchedIngredient && (
|
||||
<p className="mt-1 text-xs text-success-600">Matched existing ingredient ✓</p>
|
||||
@@ -190,17 +197,32 @@ export default function Pantry() {
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
placeholder="e.g., 5"
|
||||
/>
|
||||
<Input
|
||||
<Select
|
||||
label="Unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder="cans, lbs, etc."
|
||||
options={[
|
||||
{ value: '', label: 'Select unit…' },
|
||||
{ value: 'each', label: 'each' },
|
||||
{ value: 'g', label: 'g' },
|
||||
{ value: 'kg', label: 'kg' },
|
||||
{ value: 'oz', label: 'oz' },
|
||||
{ value: 'lb', label: 'lb' },
|
||||
{ value: 'ml', label: 'ml' },
|
||||
{ value: 'l', label: 'l' },
|
||||
{ value: 'cup', label: 'cup' },
|
||||
{ value: 'tbsp', label: 'tbsp' },
|
||||
{ value: 'tsp', label: 'tsp' },
|
||||
{ value: 'can', label: 'can' },
|
||||
{ value: 'bunch', label: 'bunch' },
|
||||
{ value: 'clove', label: 'clove' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
<Select
|
||||
label="Aisle"
|
||||
value={aisle}
|
||||
onChange={(e) => setAisle(e.target.value)}
|
||||
placeholder="e.g., Produce"
|
||||
options={AISLE_OPTIONS}
|
||||
/>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
@@ -233,17 +255,22 @@ export default function Pantry() {
|
||||
{/* Items List */}
|
||||
{filteredItems && filteredItems.length > 0 ? (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-200 bg-surface-50">
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Item</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Aisle</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Quantity</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Expires</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-surface-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="overflow-x-auto"
|
||||
role="region"
|
||||
aria-label="Pantry items, scroll horizontally to see all columns"
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-200 bg-surface-50">
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Item</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Aisle</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Quantity</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Expires</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-surface-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-200">
|
||||
{filteredItems.map((item) => {
|
||||
const isExpiringSoon = item.expires_at &&
|
||||
@@ -302,6 +329,11 @@ export default function Pantry() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent md:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -45,13 +45,29 @@ export default function RecipesPage() {
|
||||
const [q, setQ] = useState('')
|
||||
const [debouncedQ, setDebouncedQ] = useState('')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [cuisine, setCuisine] = useState('')
|
||||
const [protein, setProtein] = useState('')
|
||||
const [dietary, setDietary] = useState('')
|
||||
const [maxTime, setMaxTime] = useState('')
|
||||
const [spiceMax, setSpiceMax] = useState('')
|
||||
const [calorieMax, setCalorieMax] = useState('')
|
||||
const [ingredient, setIngredient] = useState('')
|
||||
|
||||
// Pending (form) vs applied (query) state so users can stage changes
|
||||
// and commit them with Apply, with Reset clearing pending back to applied.
|
||||
const [applied, setApplied] = useState({
|
||||
cuisine: '', protein: '', dietary: '', ingredient: '',
|
||||
maxTime: '', spiceMax: '', calorieMax: '',
|
||||
})
|
||||
const [pending, setPending] = useState(applied)
|
||||
|
||||
const setPendingField = (key: keyof typeof pending, value: string) =>
|
||||
setPending(p => ({ ...p, [key]: value }))
|
||||
|
||||
const activeCount = Object.values(applied).filter(v => v && v.length > 0).length
|
||||
|
||||
const applyFilters = () => setApplied(pending)
|
||||
const resetFilters = () => {
|
||||
const empty = {
|
||||
cuisine: '', protein: '', dietary: '', ingredient: '',
|
||||
maxTime: '', spiceMax: '', calorieMax: '',
|
||||
}
|
||||
setPending(empty)
|
||||
setApplied(empty)
|
||||
}
|
||||
|
||||
// Debounce search
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
@@ -62,13 +78,13 @@ export default function RecipesPage() {
|
||||
|
||||
const params: any = { limit: 120 }
|
||||
if (debouncedQ) params.q = debouncedQ
|
||||
if (cuisine) params.cuisine = cuisine
|
||||
if (protein) params.protein = protein
|
||||
if (dietary) params.dietary = dietary
|
||||
if (ingredient) params.ingredient = ingredient
|
||||
if (maxTime) params.max_time = parseInt(maxTime, 10)
|
||||
if (spiceMax) params.spice_max = parseInt(spiceMax, 10)
|
||||
if (calorieMax) params.calorie_max = parseInt(calorieMax, 10)
|
||||
if (applied.cuisine) params.cuisine = applied.cuisine
|
||||
if (applied.protein) params.protein = applied.protein
|
||||
if (applied.dietary) params.dietary = applied.dietary
|
||||
if (applied.ingredient) params.ingredient = applied.ingredient
|
||||
if (applied.maxTime) params.max_time = parseInt(applied.maxTime, 10)
|
||||
if (applied.spiceMax) params.spice_max = parseInt(applied.spiceMax, 10)
|
||||
if (applied.calorieMax) params.calorie_max = parseInt(applied.calorieMax, 10)
|
||||
|
||||
const { data, isLoading } = useQuery<Recipe[]>({
|
||||
queryKey: ['recipes', params],
|
||||
@@ -107,8 +123,17 @@ export default function RecipesPage() {
|
||||
size="sm"
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
aria-expanded={showFilters}
|
||||
>
|
||||
Filters
|
||||
{activeCount > 0 && (
|
||||
<span
|
||||
className="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 text-[10px] font-bold rounded-full bg-primary-600 text-white"
|
||||
aria-label={`${activeCount} filters active`}
|
||||
>
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,57 +151,67 @@ export default function RecipesPage() {
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div role="region" aria-label="Filters">
|
||||
<Card className="overflow-hidden">
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<Select
|
||||
label="Cuisine"
|
||||
options={CUISINE_OPTIONS}
|
||||
value={cuisine}
|
||||
onChange={(e) => setCuisine(e.target.value)}
|
||||
value={pending.cuisine}
|
||||
onChange={(e) => setPendingField('cuisine', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Protein"
|
||||
options={PROTEIN_OPTIONS}
|
||||
value={protein}
|
||||
onChange={(e) => setProtein(e.target.value)}
|
||||
value={pending.protein}
|
||||
onChange={(e) => setPendingField('protein', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Dietary tag"
|
||||
value={dietary}
|
||||
onChange={(e) => setDietary(e.target.value)}
|
||||
value={pending.dietary}
|
||||
onChange={(e) => setPendingField('dietary', e.target.value)}
|
||||
placeholder="e.g., gluten-free"
|
||||
/>
|
||||
<Input
|
||||
label="Contains ingredient"
|
||||
value={ingredient}
|
||||
onChange={(e) => setIngredient(e.target.value)}
|
||||
value={pending.ingredient}
|
||||
onChange={(e) => setPendingField('ingredient', e.target.value)}
|
||||
placeholder="e.g., chicken"
|
||||
/>
|
||||
<Input
|
||||
label="Max time (min)"
|
||||
type="number"
|
||||
value={maxTime}
|
||||
onChange={(e) => setMaxTime(e.target.value)}
|
||||
value={pending.maxTime}
|
||||
onChange={(e) => setPendingField('maxTime', e.target.value)}
|
||||
placeholder="e.g., 45"
|
||||
/>
|
||||
<Input
|
||||
label="Max spice (1-5)"
|
||||
type="number"
|
||||
value={spiceMax}
|
||||
onChange={(e) => setSpiceMax(e.target.value)}
|
||||
value={pending.spiceMax}
|
||||
onChange={(e) => setPendingField('spiceMax', e.target.value)}
|
||||
placeholder="e.g., 2"
|
||||
/>
|
||||
<Input
|
||||
label="Max calories"
|
||||
type="number"
|
||||
value={calorieMax}
|
||||
onChange={(e) => setCalorieMax(e.target.value)}
|
||||
value={pending.calorieMax}
|
||||
onChange={(e) => setPendingField('calorieMax', e.target.value)}
|
||||
placeholder="e.g., 600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t border-surface-200">
|
||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" onClick={applyFilters}>
|
||||
Apply filters
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
|
||||
@@ -9,6 +9,38 @@ 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">
|
||||
@@ -156,46 +188,46 @@ export default function ShoppingListPage() {
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-100 flex items-center justify-center">
|
||||
<Receipt className="w-5 h-5 text-primary-600" />
|
||||
<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>
|
||||
<div className="text-2xl font-bold text-surface-900">
|
||||
<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-sm text-surface-500">Estimated Total</div>
|
||||
<div className="text-[10px] sm:text-sm text-surface-500 truncate">Estimated</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-100 flex items-center justify-center">
|
||||
<Package className="w-5 h-5 text-surface-600" />
|
||||
<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>
|
||||
<div className="text-2xl font-bold text-surface-900">{shoppingList.items.length}</div>
|
||||
<div className="text-sm text-surface-500">Total Items</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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-100 flex items-center justify-center">
|
||||
<Tag className="w-5 h-5 text-success-600" />
|
||||
<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>
|
||||
<div className="text-2xl font-bold text-success-700">{shoppingList.sale_items_count}</div>
|
||||
<div className="text-sm text-surface-500">Items on Sale</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>
|
||||
@@ -207,7 +239,7 @@ export default function ShoppingListPage() {
|
||||
{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">{aisle}</h3>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user