feat(frontend): add recipe browser + recommended + detail pages

- Recipes.tsx: search, tag/protein/cuisine filters, ingredient search, family blocklist
- Recommended.tsx: feedback-driven recipe recommendations
- RecipeDetail.tsx: recipe display with ingredients, instructions, quick stats
- App.tsx: add /recipes, /recipes/recommended, /recipes/:id routes
- API client: list, recommended, get recipe methods
- Types: add Recipe fields (external_source, external_id, discovery_reason, calories_per_serving, qty)
This commit is contained in:
2026-05-24 21:44:16 -07:00
parent e22eae1ecd
commit 86164e6dd3
6 changed files with 649 additions and 5 deletions
+193
View File
@@ -0,0 +1,193 @@
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 || ''} `.trim()}
{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>
)
}
+281
View File
@@ -0,0 +1,281 @@
import { useState, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
Search, SlidersHorizontal, CookingPot, Clock, Users, Sparkles
} from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { Recipe } from '../types'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
import { Select } from '../components/ui/Select'
const CUISINE_OPTIONS = [
{ value: '', label: 'All cuisines' },
{ value: 'american', label: 'American' },
{ value: 'chinese', label: 'Chinese' },
{ value: 'french', label: 'French' },
{ value: 'greek', label: 'Greek' },
{ value: 'indian', label: 'Indian' },
{ value: 'italian', label: 'Italian' },
{ value: 'japanese', label: 'Japanese' },
{ value: 'korean', label: 'Korean' },
{ value: 'mediterranean', label: 'Mediterranean' },
{ value: 'mexican', label: 'Mexican' },
{ value: 'thai', label: 'Thai' },
]
const PROTEIN_OPTIONS = [
{ value: '', label: 'All proteins' },
{ value: 'beef', label: 'Beef' },
{ value: 'chicken', label: 'Chicken' },
{ value: 'lamb', label: 'Lamb' },
{ value: 'pork', label: 'Pork' },
{ value: 'shrimp', label: 'Shrimp' },
{ value: 'salmon', label: 'Salmon' },
{ value: 'tofu', label: 'Tofu' },
{ value: 'vegetarian', label: 'Vegetarian' },
]
export default function RecipesPage() {
const [q, setQ] = useState('')
const [debouncedQ, setDebouncedQ] = useState('')
const [showFilters, setShowFilters] = useState(false)
const [cuisine, setCuisine] = useState('')
const [protein, setProtein] = useState('')
const [dietary, setDietary] = useState('')
const [maxTime, setMaxTime] = useState('')
const [spiceMax, setSpiceMax] = useState('')
const [calorieMax, setCalorieMax] = useState('')
const [ingredient, setIngredient] = useState('')
// Debounce search
const handleSearch = useCallback((value: string) => {
setQ(value)
const id = setTimeout(() => setDebouncedQ(value), 300)
return () => clearTimeout(id)
}, [])
const params: any = { limit: 120 }
if (debouncedQ) params.q = debouncedQ
if (cuisine) params.cuisine = cuisine
if (protein) params.protein = protein
if (dietary) params.dietary = dietary
if (ingredient) params.ingredient = ingredient
if (maxTime) params.max_time = parseInt(maxTime, 10)
if (spiceMax) params.spice_max = parseInt(spiceMax, 10)
if (calorieMax) params.calorie_max = parseInt(calorieMax, 10)
const { data, isLoading } = useQuery<Recipe[]>({
queryKey: ['recipes', params],
queryFn: () => mealPlannerApi.recipes.list(params).then(r => r.data),
})
if (isLoading && !data) {
return <RecipesSkeleton />
}
const recipes = data || []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
<CookingPot className="w-5 h-5 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-surface-900">Recipes</h1>
<p className="text-sm text-surface-500">{recipes.length} recipes</p>
</div>
</div>
<div className="flex gap-2">
<Link
to="/recipes/recommended"
className="inline-flex items-center gap-2 rounded-lg bg-warning-50 text-warning-700 text-sm font-medium px-4 py-2 hover:bg-warning-100 transition-colors"
>
<Sparkles className="w-4 h-4" />
Recommended
</Link>
<Button
variant={showFilters ? 'primary' : 'secondary'}
size="sm"
icon={<SlidersHorizontal className="w-4 h-4" />}
onClick={() => setShowFilters(!showFilters)}
>
Filters
</Button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
<Input
value={q}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search recipes..."
className="pl-10"
/>
</div>
{/* Filters */}
{showFilters && (
<Card className="overflow-hidden">
<CardBody>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<Select
label="Cuisine"
options={CUISINE_OPTIONS}
value={cuisine}
onChange={(e) => setCuisine(e.target.value)}
/>
<Select
label="Protein"
options={PROTEIN_OPTIONS}
value={protein}
onChange={(e) => setProtein(e.target.value)}
/>
<Input
label="Dietary tag"
value={dietary}
onChange={(e) => setDietary(e.target.value)}
placeholder="e.g., gluten-free"
/>
<Input
label="Contains ingredient"
value={ingredient}
onChange={(e) => setIngredient(e.target.value)}
placeholder="e.g., chicken"
/>
<Input
label="Max time (min)"
type="number"
value={maxTime}
onChange={(e) => setMaxTime(e.target.value)}
placeholder="e.g., 45"
/>
<Input
label="Max spice (1-5)"
type="number"
value={spiceMax}
onChange={(e) => setSpiceMax(e.target.value)}
placeholder="e.g., 2"
/>
<Input
label="Max calories"
type="number"
value={calorieMax}
onChange={(e) => setCalorieMax(e.target.value)}
placeholder="e.g., 600"
/>
</div>
</CardBody>
</Card>
)}
{/* Grid */}
{recipes.length === 0 ? (
<EmptyState
icon={CookingPot}
title="No recipes found"
description="Try adjusting filters or search terms."
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{recipes.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
)
}
function RecipeCard({ recipe }: { recipe: Recipe }) {
const totalTime = recipe.total_time_minutes ??
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
return (
<Link to={`/recipes/${recipe.id}`} className="group block">
<Card className="h-full overflow-hidden hover:shadow-md transition-shadow border-surface-200 hover:border-primary-200">
{recipe.image_url ? (
<div className="aspect-[4/3] overflow-hidden bg-surface-100">
<img
src={recipe.image_url}
alt={recipe.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
) : (
<div className="aspect-[4/3] bg-surface-100 flex items-center justify-center">
<CookingPot className="w-12 h-12 text-surface-300" />
</div>
)}
<CardBody className="space-y-2">
<h3 className="font-semibold text-surface-900 group-hover:text-primary-700 transition-colors line-clamp-2">
{recipe.name}
</h3>
<p className="text-xs text-surface-500 line-clamp-2">{recipe.description}</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{recipe.cuisine_tags?.map(t => (
<Badge key={t} variant="neutral" className="text-[10px] px-1.5 py-0">{t}</Badge>
))}
{recipe.dietary_tags?.map(t => (
<Badge key={t} variant="success" className="text-[10px] px-1.5 py-0">{t}</Badge>
))}
{recipe.protein_type && (
<Badge variant="primary" className="text-[10px] px-1.5 py-0">{recipe.protein_type}</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-surface-500 pt-1">
{totalTime > 0 && (
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{totalTime} min
</span>
)}
<span className="flex items-center gap-1">
<Users className="w-3.5 h-3.5" />
{recipe.servings}
</span>
</div>
</CardBody>
</Card>
</Link>
)
}
function RecipesSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-xl" />
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-20 mt-1" />
</div>
</div>
<Skeleton className="h-9 w-24" />
</div>
<Skeleton className="h-10 w-full" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i}>
<Skeleton className="aspect-[4/3]" />
<CardBody>
<Skeleton className="h-5 w-3/4 mb-2" />
<SkeletonText lines={2} />
<Skeleton className="h-4 w-24 mt-2" />
</CardBody>
</Card>
))}
</div>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
Sparkles, CookingPot, Clock, Users, ArrowLeft
} from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { Recipe } from '../types'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
export default function RecommendedRecipesPage() {
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => mealPlannerApi.profile.get().then(r => r.data),
})
const familyId = profile?.id
const { data, isLoading } = useQuery<Recipe[]>({
queryKey: ['recommendedRecipes', familyId],
queryFn: async () => {
if (!familyId) return []
const res = await mealPlannerApi.recipes.recommended(familyId, 20)
return res.data
},
enabled: !!familyId,
})
if (!familyId || isLoading) {
return <RecommendedSkeleton />
}
const recipes = data || []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<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="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-warning-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-surface-900">Recommended for You</h1>
<p className="text-sm text-surface-500">
Based on your family's feedback and preferences
</p>
</div>
</div>
{/* Grid */}
{recipes.length === 0 ? (
<EmptyState
icon={Sparkles}
title="No recommendations yet"
description="Vote and rate more meals so we can learn your family's preferences."
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{recipes.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
)
}
function RecipeCard({ recipe }: { recipe: Recipe }) {
const totalTime = recipe.total_time_minutes ??
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
return (
<Link to={`/recipes/${recipe.id}`} className="group block">
<Card className="h-full overflow-hidden hover:shadow-md transition-shadow border-surface-200 hover:border-warning-200">
{recipe.image_url ? (
<div className="aspect-[4/3] overflow-hidden bg-surface-100">
<img
src={recipe.image_url}
alt={recipe.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
) : (
<div className="aspect-[4/3] bg-surface-100 flex items-center justify-center">
<CookingPot className="w-12 h-12 text-surface-300" />
</div>
)}
<CardBody className="space-y-2">
<h3 className="font-semibold text-surface-900 group-hover:text-warning-700 transition-colors line-clamp-2">
{recipe.name}
</h3>
<p className="text-xs text-surface-500 line-clamp-2">{recipe.description}</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{recipe.cuisine_tags?.map(t => (
<Badge key={t} variant="neutral" className="text-[10px] px-1.5 py-0">{t}</Badge>
))}
{recipe.protein_type && (
<Badge variant="primary" className="text-[10px] px-1.5 py-0">{recipe.protein_type}</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-surface-500 pt-1">
{totalTime > 0 && (
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{totalTime} min
</span>
)}
<span className="flex items-center gap-1">
<Users className="w-3.5 h-3.5" />
{recipe.servings}
</span>
</div>
{recipe.discovery_reason && (
<p className="text-[11px] text-warning-600 italic pt-1">
{recipe.discovery_reason}
</p>
)}
</CardBody>
</Card>
</Link>
)
}
function RecommendedSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-xl" />
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64 mt-1" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<Skeleton className="aspect-[4/3]" />
<CardBody>
<Skeleton className="h-5 w-3/4 mb-2" />
<SkeletonText lines={2} />
<Skeleton className="h-4 w-24 mt-2" />
</CardBody>
</Card>
))}
</div>
</div>
)
}