diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 07fa5f1..6411ad1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,17 +4,21 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import Dashboard from './pages/Dashboard'
import MealDetail from './pages/MealDetail'
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'
const queryClient = new QueryClient()
function Navigation() {
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 ${
- isActive(path)
+ isActive(prefix)
? 'text-primary-700 bg-primary-50'
: 'text-surface-600 hover:bg-surface-100'
}`
@@ -24,6 +28,7 @@ function Navigation() {
MealPlanner
+ Recipes
Pantry
Shopping List
@@ -43,6 +48,9 @@ function App() {
} />
} />
+ } />
+ } />
+ } />
} />
} />
@@ -54,4 +62,4 @@ function App() {
)
}
-export default App
\ No newline at end of file
+export default App
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 201722f..828871a 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -26,6 +26,8 @@ export const mealPlannerApi = {
recipes: {
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}`),
create: (data: any) => api.post('/recipes', data),
delete: (id: string) => api.delete(`/recipes/${id}`),
@@ -78,4 +80,4 @@ export const mealPlannerApi = {
},
}
-export default api
\ No newline at end of file
+export default api
diff --git a/frontend/src/pages/RecipeDetail.tsx b/frontend/src/pages/RecipeDetail.tsx
new file mode 100644
index 0000000..1bbfdea
--- /dev/null
+++ b/frontend/src/pages/RecipeDetail.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function RecipeDetail() {
+ const { id } = useParams<{ id: string }>()
+
+ const { data: recipe, isLoading } = useQuery
({
+ queryKey: ['recipe', id],
+ queryFn: () => mealPlannerApi.recipes.get(id!).then(r => r.data),
+ enabled: !!id,
+ })
+
+ if (isLoading || !recipe) {
+ return
+ }
+
+ 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 (
+
+ {/* Top bar */}
+
+
+
+
+
+
{recipe.name}
+
{recipe.description}
+
+
} onClick={handleAddToPlan}>
+ Add to Plan
+
+
+
+ {/* Meta row */}
+
+ {recipe.cuisine_tags?.map(t => (
+ {t}
+ ))}
+ {recipe.dietary_tags?.map(t => (
+ {t}
+ ))}
+ {recipe.protein_type && (
+ {recipe.protein_type}
+ )}
+ {recipe.spice_level != null && (
+ Spice {recipe.spice_level}/5
+ )}
+
+
+ {/* Image + Quick stats */}
+
+
+ {recipe.image_url ? (
+
+

+
+ ) : (
+
+
+
+ )}
+
+
+
+ Quick Stats
+
+
+
+
+ {totalTime > 0 ? `${totalTime} min total` : 'Time not set'}
+
+
+
+ {recipe.prep_time_minutes ?? 0} min prep ยท {recipe.cook_time_minutes ?? 0} min cook
+
+
+
+ {recipe.servings} servings
+
+ {recipe.calories_per_serving && (
+
+
+ {recipe.calories_per_serving} kcal/serving
+
+ )}
+ {recipe.source_url && (
+
+
+ Source
+
+ )}
+ {recipe.discovery_reason && (
+ {recipe.discovery_reason}
+ )}
+
+
+
+
+ {/* Ingredients */}
+
+
+
+
+
Ingredients
+
+
+
+
+ {recipe.ingredients?.map((ing, i) => (
+ -
+
+
+ {ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()}
+ {ing.name || 'Unknown ingredient'}
+
+
+ ))}
+
+
+
+
+ {/* Instructions */}
+
+
+
+
+
Instructions
+
+
+
+
+ {recipe.instructions?.map((step, i) => (
+ -
+
+ {i + 1}
+
+ {step}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Recipes.tsx b/frontend/src/pages/Recipes.tsx
new file mode 100644
index 0000000..964ace9
--- /dev/null
+++ b/frontend/src/pages/Recipes.tsx
@@ -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({
+ queryKey: ['recipes', params],
+ queryFn: () => mealPlannerApi.recipes.list(params).then(r => r.data),
+ })
+
+ if (isLoading && !data) {
+ return
+ }
+
+ const recipes = data || []
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Recipes
+
{recipes.length} recipes
+
+
+
+
+
+ Recommended
+
+ }
+ onClick={() => setShowFilters(!showFilters)}
+ >
+ Filters
+
+
+
+
+ {/* Search */}
+
+
+ handleSearch(e.target.value)}
+ placeholder="Search recipes..."
+ className="pl-10"
+ />
+
+
+ {/* Filters */}
+ {showFilters && (
+
+
+
+
+
+
+ )}
+
+ {/* Grid */}
+ {recipes.length === 0 ? (
+
+ ) : (
+
+ {recipes.map(recipe => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function RecipeCard({ recipe }: { recipe: Recipe }) {
+ const totalTime = recipe.total_time_minutes ??
+ (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
+ return (
+
+
+ {recipe.image_url ? (
+
+

+
+ ) : (
+
+
+
+ )}
+
+
+ {recipe.name}
+
+ {recipe.description}
+
+ {recipe.cuisine_tags?.map(t => (
+ {t}
+ ))}
+ {recipe.dietary_tags?.map(t => (
+ {t}
+ ))}
+ {recipe.protein_type && (
+ {recipe.protein_type}
+ )}
+
+
+ {totalTime > 0 && (
+
+
+ {totalTime} min
+
+ )}
+
+
+ {recipe.servings}
+
+
+
+
+
+ )
+}
+
+function RecipesSkeleton() {
+ return (
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/pages/Recommended.tsx b/frontend/src/pages/Recommended.tsx
new file mode 100644
index 0000000..67b0dca
--- /dev/null
+++ b/frontend/src/pages/Recommended.tsx
@@ -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({
+ 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
+ }
+
+ const recipes = data || []
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
Recommended for You
+
+ Based on your family's feedback and preferences
+
+
+
+
+ {/* Grid */}
+ {recipes.length === 0 ? (
+
+ ) : (
+
+ {recipes.map(recipe => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function RecipeCard({ recipe }: { recipe: Recipe }) {
+ const totalTime = recipe.total_time_minutes ??
+ (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
+ return (
+
+
+ {recipe.image_url ? (
+
+

+
+ ) : (
+
+
+
+ )}
+
+
+ {recipe.name}
+
+ {recipe.description}
+
+ {recipe.cuisine_tags?.map(t => (
+ {t}
+ ))}
+ {recipe.protein_type && (
+ {recipe.protein_type}
+ )}
+
+
+ {totalTime > 0 && (
+
+
+ {totalTime} min
+
+ )}
+
+
+ {recipe.servings}
+
+
+ {recipe.discovery_reason && (
+
+ {recipe.discovery_reason}
+
+ )}
+
+
+
+ )
+}
+
+function RecommendedSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 3a44e69..16c9204 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -37,10 +37,14 @@ export interface Recipe {
dietary_tags: string[]
protein_type?: string
spice_level?: number
+ calories_per_serving?: number
ingredients: RecipeIngredient[]
instructions: string[]
source_url?: string
is_manually_added: boolean
+ external_source?: string
+ external_id?: string
+ discovery_reason?: string
scraped_at?: string
created_at?: string
updated_at?: string
@@ -50,6 +54,7 @@ export interface Recipe {
export interface RecipeIngredient {
ingredient_id?: string
name: string
+ qty?: number
quantity?: number
unit?: string
is_optional: boolean