Public Access
- Dashboard: stack days vertically on mobile and suppress empty meal slots - Nav: allow wrapping on narrow screens - MealDetail: responsive hero sizing and padding
389 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|