diff --git a/README.md b/README.md index 88cdbbd..a7ef2ca 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,35 @@ Then open [http://localhost:5173](http://localhost:5173) in your browser. --- +## 🍽️ Nutrition (Recipes) — optional + +Estimate nutrition for a recipe's ingredients. Works with RapidAPI or native Edamam; falls back to a local mock if no keys are set. + +Setup +1. Create `.env` from the example: + - `cp env.example .env` +2. Choose ONE provider: + - RapidAPI (recommended for quick start): + - `VITE_RAPIDAPI_KEY=your_rapidapi_key` + - `VITE_RAPIDAPI_HOST=` + (e.g. `edamam-nutrition-analysis.p.rapidapi.com` — value may vary) + - Native Edamam: + - `VITE_EDAMAM_APP_ID=your_edamam_app_id` + - `VITE_EDAMAM_APP_KEY=your_edamam_app_key` +3. Restart dev server. + +Use +- Open Recipes → click “More Nutrition Info”. +- Shows: Calories (kcal), Carbs (g), Protein (g), Fat (g), Fiber (g), Sugar (g), Sodium (mg). +- If no keys are set, the button shows “Demo Nutrition (mock)”. + +Notes & troubleshooting +- Do not commit real keys. `.env` is local only. +- RapidAPI: ensure Host matches your snippet exactly; we support common endpoints. +- If you see 401/404, double‑check Host/key and quota, then restart the dev server. + +--- + ## 🤝 Contributing We welcome contributions of all levels — from design tweaks to feature enhancements! diff --git a/env.example b/env.example new file mode 100644 index 0000000..c275270 --- /dev/null +++ b/env.example @@ -0,0 +1,7 @@ +VITE_EDAMAM_APP_ID=your_edamam_app_id +VITE_EDAMAM_APP_KEY=your_edamam_app_key + +# RapidAPI (Edamam Nutrition Analysis via RapidAPI) +VITE_RAPIDAPI_KEY=your_rapidapi_key +VITE_RAPIDAPI_HOST=edamam-edamam-nutrition-analysis.p.rapidapi.com + diff --git a/public/mock/nutrition-sample.json b/public/mock/nutrition-sample.json new file mode 100644 index 0000000..a5694a4 --- /dev/null +++ b/public/mock/nutrition-sample.json @@ -0,0 +1,14 @@ +{ + "uri": "http://example", + "calories": 520, + "totalWeight": 450, + "totalNutrients": { + "PROCNT": { "label": "Protein", "quantity": 32.4, "unit": "g" }, + "CHOCDF": { "label": "Carbs", "quantity": 58.2, "unit": "g" }, + "FAT": { "label": "Fat", "quantity": 18.1, "unit": "g" }, + "SUGAR": { "label": "Sugars", "quantity": 12.7, "unit": "g" }, + "NA": { "label": "Sodium", "quantity": 940, "unit": "mg" }, + "FIBTG": { "label": "Fiber", "quantity": 7.6, "unit": "g" } + } +} + diff --git a/src/pages/Recipes.jsx b/src/pages/Recipes.jsx index ee8d5a1..bc0fdea 100644 --- a/src/pages/Recipes.jsx +++ b/src/pages/Recipes.jsx @@ -18,6 +18,7 @@ * - [ ] Extract service + hook (useMealsSearch) */ import { useEffect, useState } from 'react'; +import { estimateNutritionForIngredients, buildIngredientsFromMealDetail } from '../services/nutrition.js'; import Loading from '../components/Loading.jsx'; import ErrorMessage from '../components/ErrorMessage.jsx'; import Card from '../components/Card.jsx'; @@ -28,6 +29,13 @@ export default function Recipes() { const [randomMeal, setRandomMeal] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [open, setOpen] = useState({}); + const [nutrition, setNutrition] = useState({}); + const [nLoading, setNLoading] = useState({}); + const [nError, setNError] = useState({}); + const hasRapid = Boolean(import.meta.env.VITE_RAPIDAPI_KEY && import.meta.env.VITE_RAPIDAPI_HOST); + const hasEdamam = Boolean(import.meta.env.VITE_EDAMAM_APP_ID && import.meta.env.VITE_EDAMAM_APP_KEY); + const hasProvider = hasRapid || hasEdamam; useEffect(() => { search(); }, []); @@ -51,6 +59,34 @@ export default function Recipes() { } catch (e) { setError(e); } finally { setLoading(false); } } + async function fetchMealDetail(id) { + const res = await fetch(`https://www.themealdb.com/api/json/v1/1/lookup.php?i=${id}`); + if (!res.ok) throw new Error('Failed to fetch'); + const json = await res.json(); + return json.meals?.[0] || null; + } + + async function toggleNutrition(mealId) { + setOpen((o) => ({ ...o, [mealId]: !o[mealId] })); + const isOpening = !open[mealId]; + if (!isOpening) return; + if (nutrition[mealId] || nLoading[mealId]) return; + setNError((e) => ({ ...e, [mealId]: null })); + setNLoading((l) => ({ ...l, [mealId]: true })); + try { + const detail = await fetchMealDetail(mealId); + if (!detail) throw new Error('Meal not found'); + const ingr = buildIngredientsFromMealDetail(detail); + if (ingr.length === 0) throw new Error('No ingredients found'); + const est = await estimateNutritionForIngredients(ingr); + setNutrition((n) => ({ ...n, [mealId]: est })); + } catch (e) { + setNError((er) => ({ ...er, [mealId]: e })); + } finally { + setNLoading((l) => ({ ...l, [mealId]: false })); + } + } + return (

Recipe Finder

@@ -68,11 +104,39 @@ export default function Recipes() { )}
- {meals.map(m => ( - - - - ))} + {meals.map(m => { + const isOpen = !!open[m.idMeal]; + const data = nutrition[m.idMeal]; + const isNL = !!nLoading[m.idMeal]; + const err = nError[m.idMeal]; + return ( + + + + {isOpen && ( +
+ {isNL && } + + {data && !isNL && !err && ( + + + + + + + + + + +
Calories (kcal){data.calories?.toLocaleString?.() || 0}
Carbs (g){data.carbsG}
Protein (g){data.proteinG}
Fat (g){data.fatG}
Fiber (g){data.fiberG}
Sugar (g){data.sugarG}
Sodium (mg){data.sodiumMg}
+ )} +
+ )} +
+ ); + })}
); diff --git a/src/services/nutrition.js b/src/services/nutrition.js new file mode 100644 index 0000000..4189fcc --- /dev/null +++ b/src/services/nutrition.js @@ -0,0 +1,111 @@ +const cache = new Map(); + +const getEnv = () => ({ + appId: import.meta.env.VITE_EDAMAM_APP_ID, + appKey: import.meta.env.VITE_EDAMAM_APP_KEY, +}); + +const getRapidEnv = () => ({ + rapidKey: import.meta.env.VITE_RAPIDAPI_KEY, + rapidHost: import.meta.env.VITE_RAPIDAPI_HOST, +}); + +const normalize = (apiResponse) => { + if (!apiResponse) return null; + const n = apiResponse.totalNutrients || {}; + return { + calories: Math.round(apiResponse.calories || 0), + weight: Math.round(apiResponse.totalWeight || 0), + proteinG: Math.round((n.PROCNT?.quantity || 0)), + carbsG: Math.round((n.CHOCDF?.quantity || 0)), + fatG: Math.round((n.FAT?.quantity || 0)), + sugarG: Math.round((n.SUGAR?.quantity || 0)), + sodiumMg: Math.round((n.NA?.quantity || 0)), + fiberG: Math.round((n.FIBTG?.quantity || 0)), + raw: apiResponse, + }; +}; + +const ingredientsKey = (ingredients) => ingredients.map((s) => s.trim().toLowerCase()).sort().join('\n'); + +export async function estimateNutritionForIngredients(ingredients) { + const { appId, appKey } = getEnv(); + const { rapidKey, rapidHost } = getRapidEnv(); + if (!Array.isArray(ingredients) || ingredients.length === 0) return null; + + const key = ingredientsKey(ingredients); + if (cache.has(key)) return cache.get(key); + + // Prefer RapidAPI if configured + if (rapidKey && rapidHost) { + const isMulti = ingredients.length > 1; + const nutritionType = isMulti ? 'cooking' : 'logging'; + const ingrString = isMulti ? ingredients.join(', ') : String(ingredients[0]); + const base = `https://${rapidHost}`; + const paths = [ + `/api/nutrition-details?ingr=${encodeURIComponent(ingrString)}&nutrition-type=${nutritionType}`, + `/api/nutrition-data?ingr=${encodeURIComponent(ingrString)}&nutrition-type=${nutritionType}`, + ]; + for (const p of paths) { + const res = await fetch(`${base}${p}`, { + method: 'GET', + headers: { + 'x-rapidapi-key': rapidKey, + 'x-rapidapi-host': rapidHost, + } + }); + if (res.ok) { + const json = await res.json(); + const normalized = normalize(json); + cache.set(key, normalized); + return normalized; + } + if (res.status !== 404) { + throw new Error('Failed to fetch nutrition'); + } + } + throw new Error('Nutrition endpoint not found on RapidAPI host'); + } + + // Fallback to native Edamam if configured + if (appId && appKey) { + const url = `/edamam/api/nutrition-details?app_id=${encodeURIComponent(appId)}&app_key=${encodeURIComponent(appKey)}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Recipe', ingr: ingredients }), + }); + if (!res.ok) throw new Error('Failed to fetch nutrition'); + const json = await res.json(); + const normalized = normalize(json); + cache.set(key, normalized); + return normalized; + } + + // Final fallback to mock + const res = await fetch('/mock/nutrition-sample.json'); + if (!res.ok) return null; + const json = await res.json(); + const normalized = normalize(json); + cache.set(key, normalized); + return normalized; +} + +export function buildIngredientsFromMealDetail(mealDetail) { + const list = []; + for (let i = 1; i <= 20; i++) { + const ing = mealDetail[`strIngredient${i}`]; + const meas = mealDetail[`strMeasure${i}`]; + if (ing && ing.trim()) { + const line = [meas, ing].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); + list.push(line); + } + } + return list; +} + +export function getCacheSize() { + return cache.size; +} + + diff --git a/vite.config.js b/vite.config.js index 68c1aef..58e5d03 100644 --- a/vite.config.js +++ b/vite.config.js @@ -9,6 +9,11 @@ export default defineConfig({ target: 'http://api.open-notify.org', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') + }, + '/edamam': { + target: 'https://api.edamam.com', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/edamam/, '') } } }