Public Access
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:
+200
-102
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user