diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8537cfe..dd837dd 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Dashboard from './pages/Dashboard'
import MealDetail from './pages/MealDetail'
import Pantry from './pages/Pantry'
+import ShoppingList from './pages/ShoppingList'
const queryClient = new QueryClient()
@@ -21,6 +22,9 @@ function App() {
Pantry
+
+ Shopping List
+
@@ -30,6 +34,7 @@ function App() {
} />
} />
} />
+ } />
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
new file mode 100644
index 0000000..4cecfd9
--- /dev/null
+++ b/frontend/src/api/index.ts
@@ -0,0 +1,64 @@
+import axios from 'axios'
+
+const API_BASE = import.meta.env.VITE_API_URL || '/api'
+
+const api = axios.create({
+ baseURL: API_BASE,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+})
+
+export const mealPlannerApi = {
+ profile: {
+ get: () => api.get('/profile'),
+ update: (data: any) => api.put('/profile', data),
+ getMembers: () => api.get('/profile/members'),
+ addMember: (data: any) => api.post('/profile/members', data),
+ deleteMember: (id: string) => api.delete(`/profile/members/${id}`),
+ },
+
+ recipes: {
+ list: (params?: any) => api.get('/recipes', { params }),
+ get: (id: string) => api.get(`/recipes/${id}`),
+ create: (data: any) => api.post('/recipes', data),
+ delete: (id: string) => api.delete(`/recipes/${id}`),
+ listIngredients: () => api.get('/recipes/ingredients/list'),
+ createIngredient: (data: any) => api.post('/recipes/ingredients', data),
+ },
+
+ meals: {
+ getPlanned: () => api.get('/meals/planned'),
+ get: (id: string) => api.get(`/meals/${id}`),
+ getItem: (id: string) => api.get(`/meals/items/${id}`),
+ create: (data: any) => api.post('/meals', data),
+ lock: (id: string) => api.post(`/meals/${id}/lock`),
+ getVotePage: (itemId: string, token: string) => api.get(`/meals/items/${itemId}/vote/${token}`),
+ submitVote: (itemId: string, token: string, data: any) => api.post(`/meals/items/${itemId}/vote/${token}`, data),
+ swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`),
+ },
+
+ pantry: {
+ list: () => api.get('/pantry'),
+ add: (data: any) => api.post('/pantry', data),
+ update: (id: string, data: any) => api.put(`/pantry/${id}`, data),
+ remove: (id: string) => api.delete(`/pantry/${id}`),
+ },
+
+ shoppingList: {
+ get: () => api.get('/shopping-list'),
+ getPrint: () => api.get('/shopping-list/print'),
+ },
+
+ admin: {
+ triggerScrape: (source?: string, type?: string) => api.post('/admin/scrape', null, { params: { source, scrape_type: type } }),
+ getLogs: (params?: any) => api.get('/admin/logs', { params }),
+ getLog: (id: string) => api.get(`/admin/logs/${id}`),
+ getEmailLogs: (params?: any) => api.get('/admin/email-logs', { params }),
+ getMealPlans: (params?: any) => api.get('/admin/meal-plans', { params }),
+ getStats: () => api.get('/admin/stats'),
+ testEmail: (email: string) => api.post('/admin/test-email', null, { params: { email } }),
+ },
+}
+
+export default api
\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 57b3dfc..8b677e2 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -1,8 +1,153 @@
-export default function Dashboard() {
+import { useQuery } from '@tanstack/react-query'
+import { mealPlannerApi } from '../api'
+import type { MealPlan, MealPlanItem } from '../types'
+
+const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
+const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
+
+function MealCard({ item }: { item: MealPlanItem }) {
return (
-
-
This Week's Meal Plan
-
Meal planning UI coming soon...
+
+ {item.recipe?.image_url && (
+

+ )}
+
+
{item.recipe?.name || 'Unknown Recipe'}
+ {item.recipe && (
+
+ {item.recipe.total_time_minutes || (item.recipe.prep_time_minutes || 0) + (item.recipe.cook_time_minutes || 0)} min
+ {' ยท '}
+ {item.recipe.servings} servings
+
+ )}
+
+
+ {item.approval_status}
+
+ {item.estimated_cost && (
+ ${item.estimated_cost.toFixed(2)}
+ )}
+
+
)
}
+
+function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) {
+ return (
+
+
+ {DAY_NAMES[dayIndex]}
+
+
+ {MEAL_TYPES.map(mealType => {
+ const item = items.find(i => i.meal_type === mealType)
+ return (
+
+ {mealType}
+ {item ?
:
}
+
+ )
+ })}
+
+
+ )
+}
+
+export default function Dashboard() {
+ const { data: mealPlan, isLoading } = useQuery
({
+ queryKey: ['mealPlan'],
+ queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
+ })
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (!mealPlan) {
+ return (
+
+
This Week's Meal Plan
+
+
No meal plan generated yet
+
+
+
+ )
+ }
+
+ const itemsByDay = Array.from({ length: 7 }, (_, i) =>
+ mealPlan.items.filter(item => item.day_of_week === i + 1)
+ )
+
+ return (
+
+
+
+ Week of {new Date(mealPlan.week_start_date).toLocaleDateString()}
+
+
+
+ {mealPlan.status.replace('_', ' ')}
+
+ {mealPlan.total_estimated_cost && (
+
+ ${mealPlan.total_estimated_cost.toFixed(2)} total
+
+ )}
+
+
+
+
+
Weekly Overview
+
+
+ {itemsByDay.map((items, index) => (
+
+ ))}
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/MealDetail.tsx b/frontend/src/pages/MealDetail.tsx
index 69546c3..3aa0fa0 100644
--- a/frontend/src/pages/MealDetail.tsx
+++ b/frontend/src/pages/MealDetail.tsx
@@ -1,11 +1,119 @@
+import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
+import { mealPlannerApi } from '../api'
+import type { MealPlanItem } from '../types'
export default function MealDetail() {
- const { id } = useParams()
+ const { id } = useParams<{ id: string }>()
+
+ const { data: item, isLoading } = useQuery({
+ queryKey: ['mealItem', id],
+ queryFn: () => mealPlannerApi.meals.getItem(id!).then(r => r.data),
+ enabled: !!id,
+ })
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (!item?.recipe) {
+ return (
+
+
Recipe Not Found
+
This meal item doesn't have an associated recipe.
+
+ )
+ }
+
+ const recipe = item.recipe
+
return (
-
Meal Detail
-
Meal {id} details coming soon...
+ {recipe.image_url && (
+

+ )}
+
+
+
+
{recipe.name}
+ {recipe.description && (
+
{recipe.description}
+ )}
+
+
+
+ ${item.estimated_cost?.toFixed(2) || 'N/A'}
+
+
per serving
+
+
+
+
+ {recipe.prep_time_minutes && (
+ Prep: {recipe.prep_time_minutes} min
+ )}
+ {recipe.cook_time_minutes && (
+ Cook: {recipe.cook_time_minutes} min
+ )}
+ Serves: {recipe.servings}
+ {recipe.cuisine_tags?.length > 0 && (
+ {recipe.cuisine_tags.join(', ')}
+ )}
+
+
+
+
Ingredients
+
+ {recipe.ingredients?.map((ing, idx) => (
+ -
+
+
+ {ing.quantity && `${ing.quantity} `}
+ {ing.unit && `${ing.unit} `}
+ {ing.name}
+ {ing.is_optional && ' (optional)'}
+
+
+ ))}
+
+
+
+
+
Instructions
+
+ {recipe.instructions?.map((step, idx) => (
+ -
+
+ {idx + 1}
+
+
{step}
+
+ ))}
+
+
+
+
+
+
+ Back to Meal Plan
+
+
)
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Pantry.tsx b/frontend/src/pages/Pantry.tsx
index 6a4e58d..78e7411 100644
--- a/frontend/src/pages/Pantry.tsx
+++ b/frontend/src/pages/Pantry.tsx
@@ -1,8 +1,179 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { mealPlannerApi } from '../api'
+import type { HomePantryItem, Ingredient } from '../types'
+import { useState } from 'react'
+
export default function Pantry() {
+ const queryClient = useQueryClient()
+ const [showAddForm, setShowAddForm] = useState(false)
+ const [selectedIngredient, setSelectedIngredient] = useState('')
+ const [quantity, setQuantity] = useState('')
+ const [unit, setUnit] = useState('')
+
+ const { data: pantryItems, isLoading } = useQuery({
+ queryKey: ['pantry'],
+ queryFn: () => mealPlannerApi.pantry.list().then(r => r.data),
+ })
+
+ const { data: ingredients } = useQuery({
+ queryKey: ['ingredients'],
+ queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data),
+ })
+
+ const addMutation = useMutation({
+ mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) =>
+ mealPlannerApi.pantry.add(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['pantry'] })
+ setShowAddForm(false)
+ setSelectedIngredient('')
+ setQuantity('')
+ setUnit('')
+ },
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: (id: string) => mealPlannerApi.pantry.remove(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['pantry'] })
+ },
+ })
+
+ const handleAdd = () => {
+ if (!selectedIngredient) return
+ addMutation.mutate({
+ ingredient_id: selectedIngredient,
+ quantity: quantity ? parseFloat(quantity) : undefined,
+ unit: unit || undefined,
+ })
+ }
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
return (
-
Home Pantry
-
Manage your pantry items...
+
+
Home Pantry
+
+
+
+ {showAddForm && (
+
+
Add Pantry Item
+
+
+
+
+
+
+
+ setQuantity(e.target.value)}
+ placeholder="e.g., 5"
+ className="w-full border border-gray-300 rounded-lg px-3 py-2"
+ />
+
+
+
+ setUnit(e.target.value)}
+ placeholder="e.g., cans, lbs"
+ className="w-full border border-gray-300 rounded-lg px-3 py-2"
+ />
+
+
+
+
+
+
+ )}
+
+ {pantryItems && pantryItems.length > 0 ? (
+
+
+
+
+ | Item |
+ Aisle |
+ Quantity |
+ Expires |
+ Actions |
+
+
+
+ {pantryItems.map((item) => (
+
+ |
+
+ {item.ingredient?.name || 'Unknown'}
+
+ |
+
+ {item.ingredient?.aisle || '-'}
+ |
+
+ {item.quantity} {item.unit || ''}
+ |
+
+ {item.expires_at ? new Date(item.expires_at).toLocaleDateString() : '-'}
+ |
+
+
+ |
+
+ ))}
+
+
+
+ ) : (
+
+
Your pantry is empty
+
+
+ )}
)
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/ShoppingList.tsx b/frontend/src/pages/ShoppingList.tsx
new file mode 100644
index 0000000..5662b20
--- /dev/null
+++ b/frontend/src/pages/ShoppingList.tsx
@@ -0,0 +1,106 @@
+import { useQuery } from '@tanstack/react-query'
+import { mealPlannerApi } from '../api'
+import type { ShoppingList } from '../types'
+
+export default function ShoppingListPage() {
+ const { data: shoppingList, isLoading } = useQuery({
+ queryKey: ['shoppingList'],
+ queryFn: () => mealPlannerApi.shoppingList.get().then(r => r.data),
+ })
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (!shoppingList || shoppingList.items.length === 0) {
+ return (
+
+
Shopping List
+
+
No shopping list available
+
Generate a meal plan first to see your shopping list.
+
+
+ )
+ }
+
+ return (
+
+
+
+ Shopping List - Week of {new Date(shoppingList.week_start_date).toLocaleDateString()}
+
+
+
+
+
+
+
+ ${shoppingList.total_estimated_cost.toFixed(2)}
+
+
Estimated Total
+
+
+
{shoppingList.items.length}
+
Total Items
+
+
+
{shoppingList.sale_items_count}
+
Items on Sale
+
+
+
+ {Object.entries(shoppingList.by_aisle).map(([aisle, items]) => (
+
+
+
{aisle}
+ {items.length} items
+
+
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..75ded7d
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,154 @@
+export interface FamilyProfile {
+ id: string
+ name: string
+ household_size: number
+ adult_count: number
+ child_count: number
+ dietary_notes?: string
+ budget_per_meal: number
+ created_at?: string
+ updated_at?: string
+ members: FamilyMember[]
+}
+
+export interface FamilyMember {
+ id: string
+ family_profile_id: string
+ name: string
+ email?: string
+ role: 'adult' | 'child'
+ likes_mushrooms: boolean
+ created_at?: string
+ updated_at?: string
+}
+
+export interface Recipe {
+ id: string
+ family_profile_id?: string
+ name: string
+ description?: string
+ image_url?: string
+ image_source?: string
+ prep_time_minutes?: number
+ cook_time_minutes?: number
+ servings: number
+ servings_scaled?: number
+ cuisine_tags: string[]
+ dietary_tags: string[]
+ protein_type?: string
+ spice_level?: number
+ ingredients: RecipeIngredient[]
+ instructions: string[]
+ source_url?: string
+ is_manually_added: boolean
+ scraped_at?: string
+ created_at?: string
+ updated_at?: string
+ total_time_minutes?: number
+}
+
+export interface RecipeIngredient {
+ ingredient_id?: string
+ name: string
+ quantity?: number
+ unit?: string
+ is_optional: boolean
+}
+
+export interface MealPlan {
+ id: string
+ family_profile_id: string
+ week_start_date: string
+ status: 'draft' | 'pending_approval' | 'approved' | 'locked'
+ approval_deadline?: string
+ total_estimated_cost?: number
+ notes?: string
+ created_at?: string
+ updated_at?: string
+ items: MealPlanItem[]
+}
+
+export interface MealPlanItem {
+ id: string
+ meal_plan_id: string
+ recipe_id: string
+ day_of_week: number
+ meal_type: 'breakfast' | 'lunch' | 'dinner'
+ approval_status: 'pending' | 'approved' | 'denied' | 'swapped'
+ denial_reason?: string
+ denial_details?: string
+ estimated_cost?: number
+ used_pantry_items: string[]
+ recipe?: Recipe
+ created_at?: string
+ updated_at?: string
+}
+
+export interface HomePantryItem {
+ id: string
+ family_profile_id: string
+ ingredient_id: string
+ quantity?: number
+ unit?: string
+ expires_at?: string
+ added_at?: string
+ created_at?: string
+ ingredient?: Ingredient
+}
+
+export interface Ingredient {
+ id: string
+ name: string
+ name_lower: string
+ plural_name?: string
+ aisle?: string
+ typical_price?: number
+ unit?: string
+ season_months?: number[]
+ created_at?: string
+}
+
+export interface ShoppingListItem {
+ ingredient_id?: string
+ name: string
+ quantity?: number
+ unit?: string
+ aisle?: string
+ estimated_price?: number
+ is_on_sale: boolean
+ sale_price?: number
+ in_season: boolean
+ in_pantry: boolean
+}
+
+export interface ShoppingList {
+ week_start_date: string
+ items: ShoppingListItem[]
+ total_estimated_cost: number
+ sale_items_count: number
+ by_aisle: Record
+}
+
+export interface VoteRequest {
+ vote: boolean
+ denial_reason?: string
+ denial_details?: string
+}
+
+export interface ScrapeLog {
+ id: string
+ source: string
+ scrape_type: string
+ status: string
+ items_scraped?: number
+ error_message?: string
+ started_at?: string
+ completed_at?: string
+ duration_seconds?: number
+}
+
+export interface SystemStats {
+ recipes: number
+ ingredients: number
+ meal_plans: number
+}
\ No newline at end of file
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..c6bbcbc
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
\ No newline at end of file