feat: full UI redesign with design system, Nielsen heuristics compliance

- Install lucide-react, framer-motion, react-hot-toast, clsx, tailwind-merge
- Custom Tailwind config: semantic color tokens, Inter font, shadow scale,
  border radius scale, custom animations (fadeIn, slideUp, shimmer)
- Shared component library: Button, Badge, Card, Input, Select, Textarea,
  EmptyState, Skeleton, LoadingSpinner
- Global CSS with @layer components (.btn, .card, .input, .badge, .skeleton)
- Toast notification system via react-hot-toast + showToast utility
- ErrorBoundary wrapper for graceful error recovery
- Redesigned navigation: sticky, active state indicators, Lucide icons
- Dashboard: hero header, today highlighting, scrollable week grid,
  redesigned meal cards, empty states, skeleton loading
- Meal Detail: hero image with gradient overlay, metadata row with icons,
  Lucide star rating, edit-existing-feedback flow
- Pantry: inline add form, search/filter, visual quantity badges,
  expiry warnings, confirmation dialogs
- Shopping List: gradient summary cards, aisle grouping with badges,
  sale strikethrough pricing, empty state
- Login: centered card with icon, Input component, Button component
- All old gray/blue utility classes migrated to new surface/primary tokens
- TypeScript clean, production build passes
This commit is contained in:
2026-05-14 10:26:53 -07:00
parent f7ed10651b
commit 6a0c9d0c4e
23 changed files with 1772 additions and 528 deletions
+207 -95
View File
@@ -1,63 +1,129 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles } from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { MealPlan, MealPlanItem } from '../types'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
function MealCard({ item }: { item: MealPlanItem }) {
const totalTime = item.recipe?.total_time_minutes ??
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
const statusVariant =
item.approval_status === 'approved' ? 'success' :
item.approval_status === 'denied' ? 'danger' :
item.approval_status === 'swapped' ? 'warning' :
'neutral'
return (
<div className="bg-white rounded-lg shadow p-4 flex gap-4">
{item.recipe?.image_url && (
<img
src={item.recipe.image_url}
alt={item.recipe.name}
className="w-24 h-24 object-cover rounded-lg"
/>
)}
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{item.recipe?.name || 'Unknown Recipe'}</h3>
{item.recipe && (
<p className="text-sm text-gray-500">
{item.recipe.total_time_minutes || (item.recipe.prep_time_minutes || 0) + (item.recipe.cook_time_minutes || 0)} min
{' · '}
{item.recipe.servings} servings
</p>
<Link
to={`/meals/${item.id}`}
className="group block bg-surface-0 rounded-xl border border-surface-200 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200"
>
<div className="flex gap-3 p-3">
{item.recipe?.image_url ? (
<img
src={item.recipe.image_url}
alt={item.recipe.name}
className="w-20 h-20 object-cover rounded-lg flex-shrink-0"
/>
) : (
<div className="w-20 h-20 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
<CookingPot className="w-8 h-8 text-surface-400" />
</div>
)}
<div className="mt-2 flex items-center gap-2">
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
item.approval_status === 'approved' ? 'bg-green-100 text-green-800' :
item.approval_status === 'denied' ? 'bg-red-100 text-red-800' :
item.approval_status === 'swapped' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{item.approval_status}
</span>
{item.estimated_cost && (
<span className="text-sm text-gray-600">${item.estimated_cost.toFixed(2)}</span>
)}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
{item.recipe?.name || 'Unknown Recipe'}
</h4>
<p className="text-xs text-surface-500 mt-0.5">
{totalTime > 0 && `${totalTime} min · `}
{item.recipe?.servings} servings
</p>
<div className="flex items-center gap-2 mt-2">
<Badge variant={statusVariant}>
{item.approval_status}
</Badge>
{item.estimated_cost && (
<span className="text-xs font-medium text-surface-600">
${item.estimated_cost.toFixed(2)}
</span>
)}
</div>
</div>
</div>
</Link>
)
}
function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) {
const today = new Date().getDay()
const isToday = today === (dayIndex + 1) % 7
return (
<div className="flex-shrink-0 w-[260px]">
<div className={`px-3 py-2 rounded-lg mb-2 ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
<div className="flex items-center justify-between">
<span className={`text-sm font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
{FULL_DAY_NAMES[dayIndex]}
</span>
{isToday && <Badge variant="primary">Today</Badge>}
</div>
<span className="text-xs text-surface-500">{DAY_NAMES[dayIndex]}</span>
</div>
<div className="space-y-2">
{MEAL_TYPES.map(mealType => {
const item = items.find(i => i.meal_type === mealType)
return (
<div key={mealType}>
<span className="text-[10px] font-medium text-surface-400 uppercase tracking-wider px-1">
{mealType}
</span>
{item ? (
<MealCard item={item} />
) : (
<div className="h-20 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
<span className="text-xs text-surface-400"></span>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) {
function DashboardSkeleton() {
return (
<div className="flex-1 min-w-[200px]">
<div className="bg-gray-100 rounded-t-lg px-4 py-2 font-semibold text-gray-700">
{DAY_NAMES[dayIndex]}
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<div className="flex gap-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-20" />
</div>
</div>
<div className="space-y-3 p-2">
{MEAL_TYPES.map(mealType => {
const item = items.find(i => i.meal_type === mealType)
return (
<div key={mealType} className="text-xs text-gray-500 uppercase">
{mealType}
{item ? <MealCard item={item} /> : <div className="bg-gray-50 border-2 border-dashed rounded-lg h-24" />}
</div>
)
})}
<div className="flex gap-4 overflow-x-auto pb-4">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-[260px] space-y-2">
<Skeleton className="h-10 w-full" />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<SkeletonCard />
<SkeletonCard />
</div>
</div>
)
@@ -70,23 +136,22 @@ export default function Dashboard() {
})
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
)
return <DashboardSkeleton />
}
if (!mealPlan) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">This Week's Meal Plan</h1>
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500 mb-4">No meal plan generated yet</p>
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
Generate Meal Plan
</button>
<div className="flex items-center gap-3">
<CalendarDays className="w-6 h-6 text-primary-600" />
<h1 className="text-2xl font-bold text-surface-900">This Week's Meal Plan</h1>
</div>
<EmptyState
icon={Sparkles}
title="No meal plan yet"
description="Generate your first weekly meal plan to get started with smart shopping lists and vote emails."
action={{ label: 'Generate Meal Plan', onClick: () => {} }}
/>
</div>
)
}
@@ -95,59 +160,106 @@ export default function Dashboard() {
mealPlan.items.filter(item => item.day_of_week === i + 1)
)
const statusVariant =
mealPlan.status === 'approved' ? 'success' :
mealPlan.status === 'locked' ? 'primary' :
mealPlan.status === 'pending_approval' ? 'warning' :
'neutral'
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">
Week of {new Date(mealPlan.week_start_date).toLocaleDateString()}
</h1>
<div className="flex gap-2">
<span className={`inline-flex px-3 py-1 text-sm rounded-full ${
mealPlan.status === 'approved' ? 'bg-green-100 text-green-800' :
mealPlan.status === 'locked' ? 'bg-blue-100 text-blue-800' :
mealPlan.status === 'pending_approval' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{mealPlan.status.replace('_', ' ')}
</span>
{mealPlan.total_estimated_cost && (
<span className="px-3 py-1 bg-gray-100 text-gray-800 rounded-full">
${mealPlan.total_estimated_cost.toFixed(2)} total
</span>
{/* 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">
<CalendarDays className="w-5 h-5 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-surface-900">
Week of {new Date(mealPlan.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</h1>
<p className="text-sm text-surface-500">
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant={statusVariant}>
{mealPlan.status.replace(/_/g, ' ')}
</Badge>
{mealPlan.total_estimated_cost !== undefined && (
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 rounded-lg">
<span className="text-sm font-semibold text-surface-900">
${mealPlan.total_estimated_cost.toFixed(2)}
</span>
<span className="text-xs text-surface-500">total</span>
</div>
)}
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Weekly Overview</h2>
<div className="overflow-x-auto">
<div className="flex gap-4 min-w-[1400px]">
{/* Weekly Grid */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
</CardHeader>
<CardBody>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
{itemsByDay.map((items, index) => (
<DayColumn key={index} dayIndex={index} items={items} />
))}
</div>
</div>
</div>
</CardBody>
</Card>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Shopping List</h2>
<a href="/shopping-list" className="text-blue-600 hover:text-blue-800">
View shopping list
</a>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<div className="space-y-2">
<a href="/pantry" className="block text-blue-600 hover:text-blue-800">
Manage Pantry
</a>
<a href="/recipes" className="block text-blue-600 hover:text-blue-800">
Browse Recipes
</a>
</div>
</div>
<Card className="hover:shadow-md transition-shadow">
<CardBody>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-success-50 flex items-center justify-center flex-shrink-0">
<ShoppingCart className="w-5 h-5 text-success-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-surface-900">Shopping List</h3>
<p className="text-sm text-surface-500 mt-1">
View your aisle-organized shopping list with sale tracking.
</p>
<Link
to="/shopping-list"
className="inline-flex items-center gap-1 mt-3 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
>
View shopping list <ChevronRight className="w-4 h-4" />
</Link>
</div>
</div>
</CardBody>
</Card>
<Card className="hover:shadow-md transition-shadow">
<CardBody>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center flex-shrink-0">
<Sparkles className="w-5 h-5 text-warning-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-surface-900">Quick Actions</h3>
<div className="flex flex-col gap-2 mt-2">
<Link
to="/pantry"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
>
Manage Pantry <ChevronRight className="w-4 h-4" />
</Link>
<button className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors text-left">
Send Vote Email <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
</CardBody>
</Card>
</div>
</div>
)
}
}
+38 -26
View File
@@ -1,5 +1,9 @@
import { useState } from 'react'
import { Lock, ArrowRight } from 'lucide-react'
import { mealPlannerApi } from '../api'
import { Button } from '../components/ui/Button'
import { Card, CardBody } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
export default function Login() {
const [password, setPassword] = useState('')
@@ -21,33 +25,41 @@ export default function Login() {
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white rounded-lg shadow p-8 w-full max-w-sm">
<h1 className="text-2xl font-bold text-gray-900 mb-2 text-center">MealPlanner</h1>
<p className="text-sm text-gray-500 text-center mb-6">Family login</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
required
/>
<div className="min-h-[60vh] flex items-center justify-center">
<div className="w-full max-w-sm animate-scale-in">
<div className="text-center mb-8">
<div className="w-12 h-12 rounded-2xl bg-primary-100 flex items-center justify-center mx-auto mb-4">
<Lock className="w-6 h-6 text-primary-600" />
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 font-medium"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
<h1 className="text-2xl font-bold text-surface-900">Welcome back</h1>
<p className="text-sm text-surface-500 mt-1">Enter your family password to continue</p>
</div>
<Card>
<CardBody>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoFocus
required
error={error || undefined}
/>
<Button
type="submit"
loading={loading}
disabled={loading}
className="w-full"
icon={<ArrowRight className="w-4 h-4" />}
>
Sign in
</Button>
</form>
</CardBody>
</Card>
</div>
</div>
)
+282 -172
View File
@@ -1,8 +1,16 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
import { useParams, Link } from 'react-router-dom'
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { MealPlanItem, Feedback } from '../types'
import { Button } from '../components/ui/Button'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { Select } from '../components/ui/Select'
import { Textarea } from '../components/ui/Textarea'
import { showToast } from '../lib/toast'
const DENIAL_REASONS = [
{ value: '', label: 'Select a reason...' },
@@ -21,16 +29,57 @@ function StarRating({ value, onChange }: { value: number; onChange: (n: number)
key={n}
type="button"
onClick={() => onChange(n)}
className={`text-2xl ${n <= value ? 'text-yellow-400' : 'text-gray-300'} hover:text-yellow-400`}
className={`p-0.5 transition-colors ${
n <= value ? 'text-warning-400' : 'text-surface-300'
} hover:text-warning-400 focus-visible:rounded-md`}
aria-label={`Rate ${n} stars`}
>
<Star className="w-7 h-7 fill-current" />
</button>
))}
</div>
)
}
function SavedRating({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((n) => (
<Star
key={n}
className={`w-5 h-5 ${n <= rating ? 'text-warning-400 fill-current' : 'text-surface-300'}`}
/>
))}
</div>
)
}
function MealDetailSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
<Skeleton className="h-64 w-full rounded-xl" />
<div className="flex justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-12 w-24" />
</div>
<div className="flex gap-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-24" />
</div>
<Card>
<SkeletonText lines={6} />
</Card>
<Card>
<SkeletonText lines={8} />
</Card>
</div>
)
}
export default function MealDetail() {
const { id } = useParams<{ id: string }>()
const queryClient = useQueryClient()
@@ -52,28 +101,40 @@ export default function MealDetail() {
const [reason, setReason] = useState('')
const [text, setText] = useState('')
const [submitted, setSubmitted] = useState(false)
const [editingFeedback, setEditingFeedback] = useState(false)
const submitMutation = useMutation({
mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feedback', id] })
setSubmitted(true)
setEditingFeedback(false)
showToast.success('Feedback saved!')
},
onError: () => {
showToast.error('Failed to save feedback. Please try again.')
},
})
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
)
return <MealDetailSkeleton />
}
if (!item?.recipe) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Recipe Not Found</h1>
<p className="text-gray-500">This meal item doesn't have an associated recipe.</p>
<Link to="/" className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700">
<ArrowLeft className="w-4 h-4" /> Back to meal plan
</Link>
<Card>
<CardBody>
<div className="text-center py-12">
<ChefHat className="w-12 h-12 text-surface-400 mx-auto mb-4" />
<h1 className="text-xl font-bold text-surface-900">Recipe Not Found</h1>
<p className="text-sm text-surface-500 mt-2">This meal item doesn't have an associated recipe.</p>
</div>
</CardBody>
</Card>
</div>
)
}
@@ -93,185 +154,234 @@ export default function MealDetail() {
})
}
const totalTime = recipe.total_time_minutes ??
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
return (
<div className="space-y-6">
{recipe.image_url && (
<img
src={recipe.image_url}
alt={recipe.name}
className="w-full h-64 object-cover rounded-lg"
/>
)}
<div className="space-y-6 max-w-5xl mx-auto">
{/* Back Link */}
<Link to="/" className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 hover:text-surface-900 transition-colors">
<ArrowLeft className="w-4 h-4" /> Back to meal plan
</Link>
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900">{recipe.name}</h1>
{recipe.description && (
<p className="mt-2 text-gray-600">{recipe.description}</p>
)}
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">
${item.estimated_cost?.toFixed(2) || 'N/A'}
</div>
<div className="text-sm text-gray-500">per serving</div>
</div>
</div>
<div className="flex gap-4 text-sm text-gray-600">
{recipe.prep_time_minutes && (
<span>Prep: {recipe.prep_time_minutes} min</span>
)}
{recipe.cook_time_minutes && (
<span>Cook: {recipe.cook_time_minutes} min</span>
)}
<span>Serves: {recipe.servings}</span>
{recipe.cuisine_tags?.length > 0 && (
<span>{recipe.cuisine_tags.join(', ')}</span>
)}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Ingredients</h2>
<ul className="space-y-2">
{recipe.ingredients?.map((ing, idx) => (
<li key={idx} className="flex items-center gap-2">
<span className="w-2 h-2 bg-gray-400 rounded-full" />
<span>
{ing.quantity && `${ing.quantity} `}
{ing.unit && `${ing.unit} `}
{ing.name}
{ing.is_optional && ' (optional)'}
</span>
</li>
))}
</ul>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Instructions</h2>
<ol className="space-y-4">
{recipe.instructions?.map((step, idx) => (
<li key={idx} className="flex gap-4">
<span className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-800 rounded-full flex items-center justify-center font-semibold">
{idx + 1}
</span>
<p className="pt-1">{step}</p>
</li>
))}
</ol>
</div>
{/* Feedback Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Feedback</h2>
{feedback?.rating ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Your rating:</span>
<div className="flex text-yellow-400">
{[1, 2, 3, 4, 5].map(n => (
<span key={n} className={n <= (feedback.rating || 0) ? 'text-yellow-400' : 'text-gray-300'}>★</span>
))}
</div>
</div>
{feedback.never_suggest && (
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800">
Never suggest this recipe again
</div>
)}
{feedback.denial_reason && (
<p className="text-sm text-gray-600 capitalize">
Reason: {feedback.denial_reason.replace('_', ' ')}
</p>
)}
{feedback.feedback_text && (
<p className="text-sm text-gray-700 italic">"{feedback.feedback_text}"</p>
)}
<button
onClick={() => setSubmitted(false)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Edit feedback
</button>
</div>
) : submitted ? (
<div className="text-green-700 bg-green-50 rounded-lg p-4">
Thanks for your feedback!
</div>
{/* Hero */}
<div className="relative rounded-2xl overflow-hidden bg-surface-900">
{recipe.image_url ? (
<img
src={recipe.image_url}
alt={recipe.name}
className="w-full h-72 object-cover opacity-90"
/>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="h-72 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-6">
<div className="flex items-end justify-between gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">How was this meal?</label>
<StarRating value={rating} onChange={setRating} />
<h1 className="text-3xl font-bold text-white">{recipe.name}</h1>
{recipe.description && (
<p className="text-white/80 mt-1 max-w-xl text-sm">{recipe.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="neverSuggest"
checked={neverSuggest}
onChange={(e) => setNeverSuggest(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="neverSuggest" className="text-sm text-gray-700">
Never suggest this recipe again
</label>
<div className="text-right flex-shrink-0">
<div className="text-2xl font-bold text-white">
${item.estimated_cost?.toFixed(2) || 'N/A'}
</div>
<div className="text-sm text-white/70">per serving</div>
</div>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Why not? (optional)</label>
<select
{/* Metadata Row */}
<div className="flex flex-wrap items-center gap-4">
{totalTime > 0 && (
<div className="flex items-center gap-1.5 text-sm text-surface-600 bg-surface-100 px-3 py-1.5 rounded-lg">
<Clock className="w-4 h-4 text-surface-500" />
<span>{totalTime} min total</span>
</div>
)}
{recipe.prep_time_minutes != null && recipe.prep_time_minutes > 0 && (
<div className="text-sm text-surface-500">
Prep {recipe.prep_time_minutes} min
</div>
)}
{recipe.cook_time_minutes != null && recipe.cook_time_minutes > 0 && (
<div className="text-sm text-surface-500">
Cook {recipe.cook_time_minutes} min
</div>
)}
<div className="flex items-center gap-1.5 text-sm text-surface-600 bg-surface-100 px-3 py-1.5 rounded-lg">
<Users className="w-4 h-4 text-surface-500" />
<span>{recipe.servings} servings</span>
</div>
{recipe.cuisine_tags?.length > 0 && (
<div className="flex gap-1.5">
{recipe.cuisine_tags.map(tag => (
<Badge key={tag} variant="info">{tag}</Badge>
))}
</div>
)}
{recipe.dietary_tags?.length > 0 && (
<div className="flex gap-1.5">
{recipe.dietary_tags.map(tag => (
<Badge key={tag} variant="success">{tag}</Badge>
))}
</div>
)}
</div>
{/* Ingredients */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Ingredients</h2>
</CardHeader>
<CardBody>
<ul className="space-y-2">
{recipe.ingredients?.map((ing, idx) => (
<li key={idx} className="flex items-center gap-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-primary-400 flex-shrink-0" />
<span className="text-sm text-surface-700">
<span className="font-medium">
{ing.quantity && `${ing.quantity} `}
{ing.unit && `${ing.unit} `}
</span>
{ing.name}
{ing.is_optional && (
<span className="text-surface-400 ml-1">(optional)</span>
)}
</span>
</li>
))}
</ul>
</CardBody>
</Card>
{/* Instructions */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Instructions</h2>
</CardHeader>
<CardBody>
<ol className="space-y-4">
{recipe.instructions?.map((step, idx) => (
<li key={idx} className="flex gap-4">
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-sm font-bold">
{idx + 1}
</span>
<p className="text-sm text-surface-700 pt-1.5 leading-relaxed">{step}</p>
</li>
))}
</ol>
</CardBody>
</Card>
{/* Feedback */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-primary-600" />
<h2 className="text-lg font-semibold text-surface-900">Feedback</h2>
</div>
</CardHeader>
<CardBody>
{feedback?.rating && !editingFeedback ? (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-surface-600">Your rating:</span>
<SavedRating rating={feedback.rating} />
</div>
{feedback.never_suggest && (
<Badge variant="danger">
<AlertTriangle className="w-3 h-3" /> Never suggest this recipe again
</Badge>
)}
{feedback.denial_reason && (
<p className="text-sm text-surface-600">
<span className="font-medium">Reason:</span>{' '}
{feedback.denial_reason.replace(/_/g, ' ')}
</p>
)}
{feedback.feedback_text && (
<blockquote className="text-sm text-surface-700 italic border-l-2 border-primary-200 pl-3">
"{feedback.feedback_text}"
</blockquote>
)}
<Button variant="ghost" size="sm" onClick={() => setEditingFeedback(true)}>
Edit feedback
</Button>
</div>
) : submitted && !editingFeedback ? (
<div className="bg-success-50 border border-success-200 rounded-xl p-4 text-center animate-fade-in">
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center mx-auto mb-2">
<Star className="w-5 h-5 text-success-600 fill-current" />
</div>
<p className="text-sm font-medium text-success-700">Thanks for your feedback!</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="label">How was this meal?</label>
<StarRating value={rating} onChange={setRating} />
</div>
<div className="flex items-center gap-3 p-3 bg-danger-50 rounded-xl border border-danger-100">
<input
type="checkbox"
id="neverSuggest"
checked={neverSuggest}
onChange={(e) => setNeverSuggest(e.target.checked)}
className="w-4 h-4 rounded border-danger-300 text-danger-600 focus:ring-danger-500"
/>
<label htmlFor="neverSuggest" className="text-sm text-danger-700 font-medium">
Never suggest this recipe again
</label>
</div>
<Select
label="Why not? (optional)"
options={DENIAL_REASONS}
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{DENIAL_REASONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
</div>
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Additional comments</label>
<textarea
<Textarea
label="Additional comments"
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Anything else you'd like to share..."
/>
</div>
<button
type="submit"
disabled={submitMutation.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{submitMutation.isPending ? 'Saving...' : 'Submit Feedback'}
</button>
<div className="flex items-center gap-3">
<Button
type="submit"
loading={submitMutation.isPending}
disabled={submitMutation.isPending}
>
Submit Feedback
</Button>
{editingFeedback && (
<Button variant="ghost" onClick={() => setEditingFeedback(false)}>
Cancel
</Button>
)}
</div>
</form>
)}
</CardBody>
</Card>
{submitMutation.isError && (
<p className="text-sm text-red-600">Failed to save feedback. Please try again.</p>
)}
</form>
)}
</div>
<div className="flex gap-4">
<button
onClick={() => window.print()}
className="bg-gray-100 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-200"
>
{/* Actions */}
<div className="flex gap-3">
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
Print Recipe
</button>
<a
href="/"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
Back to Meal Plan
</a>
</Button>
<Link to="/">
<Button>Back to Meal Plan</Button>
</Link>
</div>
</div>
)
+200 -102
View File
@@ -1,14 +1,23 @@
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 { useState } from 'react'
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'
export default function Pantry() {
const queryClient = useQueryClient()
const [showAddForm, setShowAddForm] = useState(false)
const [selectedIngredient, setSelectedIngredient] = useState<string>('')
const [quantity, setQuantity] = useState<string>('')
const [unit, setUnit] = useState<string>('')
const [selectedIngredient, setSelectedIngredient] = useState('')
const [quantity, setQuantity] = useState('')
const [unit, setUnit] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
queryKey: ['pantry'],
@@ -29,6 +38,10 @@ export default function Pantry() {
setSelectedIngredient('')
setQuantity('')
setUnit('')
showToast.success('Item added to pantry')
},
onError: () => {
showToast.error('Failed to add item')
},
})
@@ -36,6 +49,10 @@ export default function Pantry() {
mutationFn: (id: string) => mealPlannerApi.pantry.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pantry'] })
showToast.success('Item removed')
},
onError: () => {
showToast.error('Failed to remove item')
},
})
@@ -48,132 +65,213 @@ export default function Pantry() {
})
}
const filteredItems = pantryItems?.filter(item =>
item.ingredient?.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase())
)
const ingredientOptions = ingredients?.map(ing => ({
value: ing.id,
label: ing.name + (ing.aisle ? ` (${ing.aisle})` : ''),
})) || []
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-9 w-28" />
</div>
<Card>
<CardBody>
<SkeletonText lines={3} />
</CardBody>
</Card>
<Card>
<CardBody>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-20" />
</div>
))}
</div>
</CardBody>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Home Pantry</h1>
<button
{/* 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-warning-50 flex items-center justify-center">
<Package className="w-5 h-5 text-warning-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-surface-900">Home Pantry</h1>
<p className="text-sm text-surface-500">
{pantryItems?.length || 0} items
</p>
</div>
</div>
<Button
icon={<Plus className="w-4 h-4" />}
onClick={() => setShowAddForm(!showAddForm)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
variant={showAddForm ? 'secondary' : 'primary'}
>
{showAddForm ? 'Cancel' : 'Add Item'}
</button>
</Button>
</div>
{/* Add Form */}
{showAddForm && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Add Pantry Item</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ingredient</label>
<select
value={selectedIngredient}
onChange={(e) => setSelectedIngredient(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
>
<option value="">Select ingredient...</option>
{ingredients?.map(ing => (
<option key={ing.id} value={ing.id}>
{ing.name} {ing.aisle ? `(${ing.aisle})` : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
<input
<Card className="animate-slide-down">
<CardBody>
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2">
<Select
label="Ingredient"
options={[{ value: '', label: 'Select ingredient...' }, ...ingredientOptions]}
value={selectedIngredient}
onChange={(e) => setSelectedIngredient(e.target.value)}
/>
</div>
<Input
label="Quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
placeholder="e.g., 5"
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
<input
type="text"
<Input
label="Unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder="e.g., cans, lbs"
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="cans, lbs, etc."
/>
<div className="flex items-end">
<Button
onClick={handleAdd}
loading={addMutation.isPending}
disabled={!selectedIngredient || addMutation.isPending}
className="w-full"
>
Add
</Button>
</div>
</div>
<div className="flex items-end">
<button
onClick={handleAdd}
disabled={!selectedIngredient || addMutation.isPending}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{addMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</div>
</CardBody>
</Card>
)}
{/* Search */}
{pantryItems && pantryItems.length > 0 && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search pantry items..."
className="pl-10"
/>
</div>
)}
{pantryItems && pantryItems.length > 0 ? (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Item</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aisle</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quantity</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{pantryItems.map((item) => (
<tr key={item.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-gray-900">
{item.ingredient?.name || 'Unknown'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.ingredient?.aisle || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.quantity} {item.unit || ''}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.expires_at ? new Date(item.expires_at).toLocaleDateString() : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => removeMutation.mutate(item.id)}
className="text-red-600 hover:text-red-800"
disabled={removeMutation.isPending}
>
Remove
</button>
</td>
{/* 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>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-surface-200">
{filteredItems.map((item) => {
const isExpiringSoon = item.expires_at &&
new Date(item.expires_at).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000
return (
<tr key={item.id} className="hover:bg-surface-50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center flex-shrink-0">
<Package className="w-4 h-4 text-primary-600" />
</div>
<span className="font-medium text-sm text-surface-900">
{item.ingredient?.name || 'Unknown'}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-surface-500">
{item.ingredient?.aisle || <span className="text-surface-400"></span>}
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-surface-100 text-surface-700">
{item.quantity} {item.unit || ''}
</span>
</td>
<td className="px-6 py-4">
{item.expires_at ? (
<div className="flex items-center gap-1.5">
{isExpiringSoon && <AlertTriangle className="w-4 h-4 text-warning-500" />}
<span className={`text-sm ${isExpiringSoon ? 'text-warning-600 font-medium' : 'text-surface-500'}`}>
{new Date(item.expires_at).toLocaleDateString()}
</span>
</div>
) : (
<span className="text-sm text-surface-400"></span>
)}
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4" />}
onClick={() => {
if (confirm(`Remove ${item.ingredient?.name || 'this item'} from pantry?`)) {
removeMutation.mutate(item.id)
}
}}
loading={removeMutation.isPending}
disabled={removeMutation.isPending}
>
Remove
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
) : (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500 mb-4">Your pantry is empty</p>
<button
onClick={() => setShowAddForm(true)}
className="text-blue-600 hover:text-blue-800"
>
Add your first item
</button>
</div>
<EmptyState
icon={Package}
title={searchQuery ? 'No matches found' : 'Your pantry is empty'}
description={
searchQuery
? "Try adjusting your search."
: "Add ingredients you already have at home to avoid buying duplicates."
}
action={
!searchQuery
? { label: 'Add your first item', onClick: () => setShowAddForm(true) }
: undefined
}
/>
)}
</div>
)
}
}
+162 -75
View File
@@ -1,6 +1,44 @@
import { useQuery } from '@tanstack/react-query'
import { Printer, ShoppingCart, Package, Tag, Receipt } 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'
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>
)
}
export default function ShoppingListPage() {
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
@@ -9,98 +47,147 @@ export default function ShoppingListPage() {
})
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
)
return <ShoppingListSkeleton />
}
if (!shoppingList || shoppingList.items.length === 0) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Shopping List</h1>
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500 mb-4">No shopping list available</p>
<p className="text-sm text-gray-400">Generate a meal plan first to see your shopping list.</p>
<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">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">
Shopping List - Week of {new Date(shoppingList.week_start_date).toLocaleDateString()}
</h1>
<button
onClick={() => window.print()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
{/* 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>
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
Print List
</button>
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-3xl font-bold text-gray-900">
${shoppingList.total_estimated_cost.toFixed(2)}
</div>
<div className="text-sm text-gray-500">Estimated Total</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-3xl font-bold text-gray-900">{shoppingList.items.length}</div>
<div className="text-sm text-gray-500">Total Items</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-3xl font-bold text-green-600">{shoppingList.sale_items_count}</div>
<div className="text-sm text-gray-500">Items on Sale</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 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" />
</div>
<div>
<div className="text-2xl font-bold text-surface-900">
${shoppingList.total_estimated_cost.toFixed(2)}
</div>
<div className="text-sm text-surface-500">Estimated Total</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" />
</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>
</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" />
</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>
</div>
</CardBody>
</Card>
</div>
{Object.entries(shoppingList.by_aisle).map(([aisle, items]) => (
<div key={aisle} className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h2 className="text-lg font-semibold text-gray-900">{aisle}</h2>
<span className="text-sm text-gray-500">{items.length} items</span>
</div>
<ul className="divide-y divide-gray-200">
{items.map((item, idx) => (
<li key={idx} className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-5 h-5 border-2 border-gray-300 rounded flex-shrink-0" />
<div>
<span className="font-medium text-gray-900">{item.name}</span>
{item.in_pantry && (
<span className="ml-2 text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded">
In Pantry
</span>
)}
{item.is_on_sale && (
<span className="ml-2 text-xs bg-red-100 text-red-800 px-2 py-0.5 rounded">
SALE
</span>
)}
{/* 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">{aisle}</h3>
<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>}
</div>
</div>
</div>
</div>
<div className="text-right">
{item.quantity && (
<span className="text-sm text-gray-500 mr-4">
{item.quantity} {item.unit || ''}
</span>
)}
{item.sale_price ? (
<span className="font-semibold text-red-600">${item.sale_price.toFixed(2)}</span>
) : item.estimated_price ? (
<span className="text-gray-600">${item.estimated_price.toFixed(2)}</span>
) : null}
</div>
</li>
))}
</ul>
</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>
))}
</ul>
</Card>
))}
</div>
</div>
)
}
}