Skip to content
Open
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<copy the exact X-RapidAPI-Host from RapidAPI>`
(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!
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
Loading