Public Access
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:
+12
-4
@@ -4,17 +4,21 @@ import { ErrorBoundary } from './components/ErrorBoundary'
|
|||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import MealDetail from './pages/MealDetail'
|
import MealDetail from './pages/MealDetail'
|
||||||
import Pantry from './pages/Pantry'
|
import Pantry from './pages/Pantry'
|
||||||
|
import Recipes from './pages/Recipes'
|
||||||
|
import Recommended from './pages/Recommended'
|
||||||
|
import RecipeDetail from './pages/RecipeDetail'
|
||||||
import ShoppingList from './pages/ShoppingList'
|
import ShoppingList from './pages/ShoppingList'
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
function Navigation() {
|
function Navigation() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isActive = (path: string) => location.pathname === path
|
const path = location.pathname
|
||||||
|
const isActive = (prefix: string) => path === prefix || path.startsWith(prefix + '/')
|
||||||
|
|
||||||
const linkClass = (path: string) =>
|
const linkClass = (prefix: string) =>
|
||||||
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
isActive(path)
|
isActive(prefix)
|
||||||
? 'text-primary-700 bg-primary-50'
|
? 'text-primary-700 bg-primary-50'
|
||||||
: 'text-surface-600 hover:bg-surface-100'
|
: 'text-surface-600 hover:bg-surface-100'
|
||||||
}`
|
}`
|
||||||
@@ -24,6 +28,7 @@ function Navigation() {
|
|||||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center gap-1 min-h-14 py-2">
|
<div className="flex items-center gap-1 min-h-14 py-2">
|
||||||
<Link to="/" className={linkClass('/')}>MealPlanner</Link>
|
<Link to="/" className={linkClass('/')}>MealPlanner</Link>
|
||||||
|
<Link to="/recipes" className={linkClass('/recipes')}>Recipes</Link>
|
||||||
<Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link>
|
<Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link>
|
||||||
<Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
|
<Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +48,9 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/meals/:id" element={<MealDetail />} />
|
<Route path="/meals/:id" element={<MealDetail />} />
|
||||||
|
<Route path="/recipes" element={<Recipes />} />
|
||||||
|
<Route path="/recipes/recommended" element={<Recommended />} />
|
||||||
|
<Route path="/recipes/:id" element={<RecipeDetail />} />
|
||||||
<Route path="/pantry" element={<Pantry />} />
|
<Route path="/pantry" element={<Pantry />} />
|
||||||
<Route path="/shopping-list" element={<ShoppingList />} />
|
<Route path="/shopping-list" element={<ShoppingList />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -54,4 +62,4 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const mealPlannerApi = {
|
|||||||
|
|
||||||
recipes: {
|
recipes: {
|
||||||
list: (params?: any) => api.get('/recipes', { params }),
|
list: (params?: any) => api.get('/recipes', { params }),
|
||||||
|
recommended: (familyProfileId: string, limit?: number) =>
|
||||||
|
api.get('/recipes/recommended', { params: { family_profile_id: familyProfileId, limit } }),
|
||||||
get: (id: string) => api.get(`/recipes/${id}`),
|
get: (id: string) => api.get(`/recipes/${id}`),
|
||||||
create: (data: any) => api.post('/recipes', data),
|
create: (data: any) => api.post('/recipes', data),
|
||||||
delete: (id: string) => api.delete(`/recipes/${id}`),
|
delete: (id: string) => api.delete(`/recipes/${id}`),
|
||||||
@@ -78,4 +80,4 @@ export const mealPlannerApi = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,10 +37,14 @@ export interface Recipe {
|
|||||||
dietary_tags: string[]
|
dietary_tags: string[]
|
||||||
protein_type?: string
|
protein_type?: string
|
||||||
spice_level?: number
|
spice_level?: number
|
||||||
|
calories_per_serving?: number
|
||||||
ingredients: RecipeIngredient[]
|
ingredients: RecipeIngredient[]
|
||||||
instructions: string[]
|
instructions: string[]
|
||||||
source_url?: string
|
source_url?: string
|
||||||
is_manually_added: boolean
|
is_manually_added: boolean
|
||||||
|
external_source?: string
|
||||||
|
external_id?: string
|
||||||
|
discovery_reason?: string
|
||||||
scraped_at?: string
|
scraped_at?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
@@ -50,6 +54,7 @@ export interface Recipe {
|
|||||||
export interface RecipeIngredient {
|
export interface RecipeIngredient {
|
||||||
ingredient_id?: string
|
ingredient_id?: string
|
||||||
name: string
|
name: string
|
||||||
|
qty?: number
|
||||||
quantity?: number
|
quantity?: number
|
||||||
unit?: string
|
unit?: string
|
||||||
is_optional: boolean
|
is_optional: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user