Files
Meal-Planner/frontend/src/pages/RecipeDetail.tsx
T
admin f3e4a446a3 fix(ui): close 5 P0 audit findings (ingredients, cost, routing, mobile slots)
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.
2026-06-02 10:55:53 -07:00

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>
)
}