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