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:
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user