Public Access
Sprint 1 of the UI/UX audit (Review/ui-nielsen-audit.md).
- RecipeDetail: drop .trim() on ingredient line so unit and name no longer fuse
('2 canBlack Beans' -> '2 can Black Beans').
- MealDetail: align ingredient field name to backend ('qty' not 'quantity'),
add 'ingredient.name' fallback for the missing nested name from API.
- MealDetail: '$N/A per serving' -> '$X.XX' or 'No estimate'.
- App: add /recommended alias to /recipes/recommended, plus a catch-all
NotFound page so unrecognised URLs no longer render blank.
- Dashboard: remove 'hidden md:*' on empty meal slots so mobile users can
tap Generate. Bump empty-slot button to 44px min-height (a11y).
- EmptyState: accept an optional 'to' prop for Link-wrapped actions.
- types: extend RecipeIngredient with optional notes and nested ingredient.
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { useParams, Link } from 'react-router-dom'
|
|
import {
|
|
Clock, Users, ChefHat, ArrowLeft, Flame, BookOpen,
|
|
ShoppingBasket, ExternalLink, CookingPot
|
|
} from 'lucide-react'
|
|
import { mealPlannerApi } from '../api'
|
|
import type { Recipe } 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 { showToast } from '../lib/toast'
|
|
|
|
function RecipeDetailSkeleton() {
|
|
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 RecipeDetail() {
|
|
const { id } = useParams<{ id: string }>()
|
|
|
|
const { data: recipe, isLoading } = useQuery<Recipe>({
|
|
queryKey: ['recipe', id],
|
|
queryFn: () => mealPlannerApi.recipes.get(id!).then(r => r.data),
|
|
enabled: !!id,
|
|
})
|
|
|
|
if (isLoading || !recipe) {
|
|
return <RecipeDetailSkeleton />
|
|
}
|
|
|
|
const totalTime = recipe.total_time_minutes ??
|
|
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
|
|
|
|
const handleAddToPlan = () => {
|
|
showToast.success('Coming soon: add this recipe to the current meal plan.')
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
{/* Top bar */}
|
|
<div className="flex items-center gap-4">
|
|
<Link to="/recipes" className="p-2 rounded-lg hover:bg-surface-100 transition-colors">
|
|
<ArrowLeft className="w-5 h-5 text-surface-600" />
|
|
</Link>
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-2xl font-bold text-surface-900 truncate">{recipe.name}</h1>
|
|
<p className="text-sm text-surface-500 truncate">{recipe.description}</p>
|
|
</div>
|
|
<Button variant="primary" icon={<ShoppingBasket className="w-4 h-4" />} onClick={handleAddToPlan}>
|
|
Add to Plan
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Meta row */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{recipe.cuisine_tags?.map(t => (
|
|
<Badge key={t} variant="primary" className="text-xs px-2 py-1">{t}</Badge>
|
|
))}
|
|
{recipe.dietary_tags?.map(t => (
|
|
<Badge key={t} variant="success" className="text-xs px-2 py-1">{t}</Badge>
|
|
))}
|
|
{recipe.protein_type && (
|
|
<Badge variant="neutral" className="text-xs px-2 py-1">{recipe.protein_type}</Badge>
|
|
)}
|
|
{recipe.spice_level != null && (
|
|
<Badge variant="warning" className="text-xs px-2 py-1">Spice {recipe.spice_level}/5</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Image + Quick stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="md:col-span-2">
|
|
{recipe.image_url ? (
|
|
<div className="aspect-[16/9] rounded-xl overflow-hidden bg-surface-100">
|
|
<img src={recipe.image_url} alt={recipe.name} className="w-full h-full object-cover" />
|
|
</div>
|
|
) : (
|
|
<div className="aspect-[16/9] rounded-xl bg-surface-100 flex items-center justify-center">
|
|
<CookingPot className="w-16 h-16 text-surface-300" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Card className="h-full">
|
|
<CardHeader className="pb-2">
|
|
<h3 className="text-sm font-semibold text-surface-700">Quick Stats</h3>
|
|
</CardHeader>
|
|
<CardBody className="space-y-3">
|
|
<div className="flex items-center gap-2 text-sm text-surface-600">
|
|
<Clock className="w-4 h-4 text-surface-400" />
|
|
{totalTime > 0 ? `${totalTime} min total` : 'Time not set'}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-surface-600">
|
|
<ChefHat className="w-4 h-4 text-surface-400" />
|
|
{recipe.prep_time_minutes ?? 0} min prep · {recipe.cook_time_minutes ?? 0} min cook
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-surface-600">
|
|
<Users className="w-4 h-4 text-surface-400" />
|
|
{recipe.servings} servings
|
|
</div>
|
|
{recipe.calories_per_serving && (
|
|
<div className="flex items-center gap-2 text-sm text-surface-600">
|
|
<Flame className="w-4 h-4 text-surface-400" />
|
|
{recipe.calories_per_serving} kcal/serving
|
|
</div>
|
|
)}
|
|
{recipe.source_url && (
|
|
<a
|
|
href={recipe.source_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-sm text-primary-600 hover:underline"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
Source
|
|
</a>
|
|
)}
|
|
{recipe.discovery_reason && (
|
|
<p className="text-[11px] text-warning-600 italic">{recipe.discovery_reason}</p>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Ingredients */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<ShoppingBasket className="w-5 h-5 text-surface-500" />
|
|
<h2 className="text-lg font-semibold text-surface-800">Ingredients</h2>
|
|
</div>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<ul className="space-y-1.5">
|
|
{recipe.ingredients?.map((ing, i) => (
|
|
<li key={i} className="flex items-start gap-2 text-sm text-surface-700">
|
|
<span className="w-2 h-2 rounded-full bg-primary-400 mt-1.5 flex-shrink-0" />
|
|
<span>
|
|
{ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''} `}
|
|
{ing.name || 'Unknown ingredient'}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* Instructions */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<BookOpen className="w-5 h-5 text-surface-500" />
|
|
<h2 className="text-lg font-semibold text-surface-800">Instructions</h2>
|
|
</div>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<ol className="space-y-3">
|
|
{recipe.instructions?.map((step, i) => (
|
|
<li key={i} className="flex items-start gap-3">
|
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 text-primary-700 text-xs font-bold flex items-center justify-center mt-0.5">
|
|
{i + 1}
|
|
</span>
|
|
<span className="text-sm text-surface-700 leading-relaxed">{step}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|