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}

+
+ +
+ + {/* 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 ? ( +
+ {recipe.name} +
+ ) : ( +
+ +
+ )} +
+ + +

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) => ( +
  1. + + {i + 1} + + {step} +
  2. + ))} +
+
+
+
+ ) +} 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 + + +
+
+ + {/* Search */} +
+ + handleSearch(e.target.value)} + placeholder="Search recipes..." + className="pl-10" + /> +
+ + {/* Filters */} + {showFilters && ( + + +
+ setProtein(e.target.value)} + /> + setDietary(e.target.value)} + placeholder="e.g., gluten-free" + /> + setIngredient(e.target.value)} + placeholder="e.g., chicken" + /> + setMaxTime(e.target.value)} + placeholder="e.g., 45" + /> + setSpiceMax(e.target.value)} + placeholder="e.g., 2" + /> + setCalorieMax(e.target.value)} + placeholder="e.g., 600" + /> +
+
+
+ )} + + {/* 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.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.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