Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,50 @@ Then open [http://localhost:5173](http://localhost:5173) in your browser.

---

## 🍽️ Nutrition Estimation (Recipes page)

This project optionally integrates the Edamam Nutrition Analysis API to estimate nutrition for a recipe's ingredients on demand.

Setup (native Edamam):

1. Create a free Edamam account and an app for the Nutrition Analysis API.
2. Copy keys into a local env file based on `env.example`:

```
cp env.example .env
```

3. Fill in values:

```
VITE_EDAMAM_APP_ID=your_edamam_app_id
VITE_EDAMAM_APP_KEY=your_edamam_app_key
```

How it works:
- `src/services/nutrition.js` posts ingredients to `/edamam/api/nutrition-details` through the dev proxy configured in `vite.config.js`.
- Results are cached in-memory per unique ingredients list.
- On `Recipes` cards, click “More Nutrition Info” to fetch and show calories, carbs, protein, fat, fiber, sugar, sodium.

Notes:
- Keys are only needed locally. Do not commit real keys. The feature gracefully no-ops if keys are missing.

Using RapidAPI instead of native keys:
1. Subscribe to the Edamam Nutrition Analysis API on RapidAPI.
2. Copy your RapidAPI key and host, then add to `.env` (see `env.example`):

```
VITE_RAPIDAPI_KEY=your_rapidapi_key
VITE_RAPIDAPI_HOST=edamam-edamam-nutrition-analysis.p.rapidapi.com
```

How selection works:
- If RapidAPI vars are set, the app calls RapidAPI with `x-rapidapi-key`/`x-rapidapi-host`.
- Else if native Edamam vars are set, it uses the native API via the Vite proxy.
- Else it falls back to a local mock JSON for demo/screenshots.

---

## 🤝 Contributing

We welcome contributions of all levels — from design tweaks to feature enhancements!
Expand Down
7 changes: 7 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
@@ -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

14 changes: 14 additions & 0 deletions public/mock/nutrition-sample.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}

74 changes: 69 additions & 5 deletions src/pages/Recipes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(); }, []);

Expand All @@ -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 (
<div>
<h2>Recipe Finder</h2>
Expand All @@ -68,11 +104,39 @@ export default function Recipes() {
</Card>
)}
<div className="grid">
{meals.map(m => (
<Card key={m.idMeal} title={m.strMeal}>
<img src={m.strMealThumb} alt="" width="100" />
</Card>
))}
{meals.map(m => {
const isOpen = !!open[m.idMeal];
const data = nutrition[m.idMeal];
const isNL = !!nLoading[m.idMeal];
const err = nError[m.idMeal];
return (
<Card key={m.idMeal} title={m.strMeal}>
<img src={m.strMealThumb} alt="" width="100" />
<button type="button" onClick={() => toggleNutrition(m.idMeal)}>
{isOpen ? 'Hide Nutrition' : (hasProvider ? 'More Nutrition Info' : 'Demo Nutrition (mock)')}
</button>
{isOpen && (
<div style={{ marginTop: '0.5rem' }}>
{isNL && <Loading />}
<ErrorMessage error={err} />
{data && !isNL && !err && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<tr><td>Calories (kcal)</td><td style={{ textAlign: 'right' }}>{data.calories?.toLocaleString?.() || 0}</td></tr>
<tr><td>Carbs (g)</td><td style={{ textAlign: 'right' }}>{data.carbsG}</td></tr>
<tr><td>Protein (g)</td><td style={{ textAlign: 'right' }}>{data.proteinG}</td></tr>
<tr><td>Fat (g)</td><td style={{ textAlign: 'right' }}>{data.fatG}</td></tr>
<tr><td>Fiber (g)</td><td style={{ textAlign: 'right' }}>{data.fiberG}</td></tr>
<tr><td>Sugar (g)</td><td style={{ textAlign: 'right' }}>{data.sugarG}</td></tr>
<tr><td>Sodium (mg)</td><td style={{ textAlign: 'right' }}>{data.sodiumMg}</td></tr>
</tbody>
</table>
)}
</div>
)}
</Card>
);
})}
</div>
</div>
);
Expand Down
111 changes: 111 additions & 0 deletions src/services/nutrition.js
Original file line number Diff line number Diff line change
@@ -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;
}


5 changes: 5 additions & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/, '')
}
}
}
Expand Down