Files
Meal-Planner/frontend/src/pages/MealDetail.tsx
T
admin 986968b93d fix: improve mobile layout on meals page and navigation
- Dashboard: stack days vertically on mobile and suppress empty meal slots
- Nav: allow wrapping on narrow screens
- MealDetail: responsive hero sizing and padding
2026-05-17 21:22:37 -07:00

389 lines
14 KiB
TypeScript

import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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...' },
{ value: 'too_expensive', label: 'Too expensive' },
{ value: 'boring', label: 'Boring / not interesting' },
{ value: 'disliked_ingredient', label: 'Disliked ingredient' },
{ value: 'cultural', label: 'Cultural / dietary preference' },
{ value: 'other', label: 'Other' },
]
function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
return (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
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()
const { data: item, isLoading } = useQuery<MealPlanItem>({
queryKey: ['mealItem', id],
queryFn: () => mealPlannerApi.meals.getItem(id!).then(r => r.data),
enabled: !!id,
})
const { data: existingFeedback } = useQuery<Feedback | null>({
queryKey: ['feedback', id],
queryFn: () => mealPlannerApi.feedback.get(id!).then(r => r.data),
enabled: !!id,
})
const [rating, setRating] = useState(0)
const [neverSuggest, setNeverSuggest] = useState(false)
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 <MealDetailSkeleton />
}
if (!item?.recipe) {
return (
<div className="space-y-6">
<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>
)
}
const recipe = item.recipe
const feedback = existingFeedback
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!id) return
submitMutation.mutate({
meal_plan_item_id: id,
rating: rating || null,
never_suggest: neverSuggest,
denial_reason: reason || null,
feedback_text: text || null,
})
}
const totalTime = recipe.total_time_minutes ??
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
return (
<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>
{/* 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-48 sm:h-72 object-cover opacity-90"
/>
) : (
<div className="h-48 sm: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-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div>
<h1 className="text-xl sm:text-3xl font-bold text-white">{recipe.name}</h1>
{recipe.description && (
<p className="text-white/80 mt-1 max-w-xl text-xs sm:text-sm">{recipe.description}</p>
)}
</div>
<div className="text-left sm:text-right flex-shrink-0">
<div className="text-xl sm:text-2xl font-bold text-white">
${item.estimated_cost?.toFixed(2) || 'N/A'}
</div>
<div className="text-xs sm:text-sm text-white/70">per serving</div>
</div>
</div>
</div>
</div>
{/* Metadata Row */}
<div className="flex flex-wrap items-center gap-3">
{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)}
/>
<Textarea
label="Additional comments"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Anything else you'd like to share..."
/>
<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>
{/* Actions */}
<div className="flex gap-3">
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
Print Recipe
</Button>
<Link to="/">
<Button>Back to Meal Plan</Button>
</Link>
</div>
</div>
)
}