Skip to content

Commit 9e99cb0

Browse files
feat(pantry-alchemy): add ingredient search and recipe discovery UI
1 parent a26e419 commit 9e99cb0

5 files changed

Lines changed: 640 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { useEffect, useState } from 'react';
2+
import { Autocomplete, TextField, Chip, CircularProgress, Box, Stack, Button } from '@mui/material';
3+
import SearchIcon from '@mui/icons-material/Search';
4+
import RecipeSlider from './RecipeSlider';
5+
6+
export default function IngredientAutocomplete({ onSubmit }) {
7+
const [options, setOptions] = useState([]);
8+
const [value, setValue] = useState([]);
9+
const [inputValue, setInputValue] = useState('');
10+
const [loading, setLoading] = useState(false);
11+
const [recipeLoading, setRecipeLoading] = useState(false);
12+
const [recipes, setRecipes] = useState([]);
13+
const appId = '65a07d9b';
14+
const appKey = '614cce709d8bbd26d92c35013e5d7861';
15+
16+
async function searchRecipe(ingredientsArray) {
17+
// Join array of ingredients: ['chicken', 'garlic', 'spinach'] -> "chicken garlic spinach"
18+
const searchQuery = ingredientsArray
19+
.map((item) => (item.includes(' ') ? `"${item}"` : item))
20+
.join(' ');
21+
22+
const params = new URLSearchParams({
23+
type: 'public',
24+
q: searchQuery,
25+
app_id: appId,
26+
app_key: appKey
27+
});
28+
29+
const url = `https://api.edamam.com/api/recipes/v2?${params.toString()}`;
30+
31+
try {
32+
setRecipeLoading(true);
33+
const response = await fetch(url);
34+
const data = await response.json();
35+
setRecipes(data.hits);
36+
} catch (error) {
37+
console.error('Error:', error);
38+
} finally {
39+
setRecipeLoading(false);
40+
}
41+
}
42+
43+
async function searchIngredients(query) {
44+
if (!query.trim()) return [];
45+
46+
const url =
47+
`https://world.openfoodfacts.org/api/v3/taxonomy_suggestions` +
48+
`?tagtype=ingredients` +
49+
`&lc=en` +
50+
`&string=${encodeURIComponent(query)}` +
51+
`&limit=10`;
52+
53+
const response = await fetch(url);
54+
const data = await response.json();
55+
56+
return data.suggestions || [];
57+
}
58+
useEffect(() => {
59+
const query = inputValue.trim();
60+
61+
// Don't search for very short queries
62+
if (query.length < 2) {
63+
setOptions([]);
64+
65+
return;
66+
}
67+
68+
const timeout = setTimeout(async () => {
69+
try {
70+
setLoading(true);
71+
72+
const data = await searchIngredients(query);
73+
74+
setOptions(data.map((item) => ({ id: item, label: item })));
75+
} catch (error) {
76+
console.error('Failed to search ingredients:', error);
77+
setOptions([]);
78+
} finally {
79+
setLoading(false);
80+
}
81+
}, 400); // debounce
82+
83+
return () => clearTimeout(timeout);
84+
}, [inputValue]);
85+
86+
return (
87+
<Box sx={{ width: '100%' }}>
88+
<Stack gap={2}>
89+
<Stack alignItems="center" flexDirection="row" gap={2}>
90+
<Autocomplete
91+
filterSelectedOptions
92+
fullWidth
93+
multiple
94+
getOptionLabel={(option) => option.label || ''}
95+
inputValue={inputValue}
96+
isOptionEqualToValue={(option, value) => option.id === value.id}
97+
loading={loading}
98+
loadingText="Searching..."
99+
noOptionsText={inputValue.length < 2 ? 'Search Ingredients' : 'No ingredients found'}
100+
options={options}
101+
renderInput={(params) => (
102+
<TextField
103+
{...params}
104+
placeholder={value.length ? 'Add another ingredient...' : 'Search ingredients...'}
105+
slotProps={{
106+
input: {
107+
...params.InputProps,
108+
endAdornment: (
109+
<>
110+
{loading && <CircularProgress color="success" size={20} />}
111+
{params.InputProps.endAdornment}
112+
</>
113+
)
114+
}
115+
}}
116+
sx={{
117+
'& .MuiAutocomplete-input': {
118+
border: 'none !important'
119+
},
120+
'& .MuiOutlinedInput-root': {
121+
minHeight: 56,
122+
borderRadius: '14px',
123+
backgroundColor: '#fff',
124+
border: 'none',
125+
// Remove black/default border
126+
'& fieldset': {
127+
border: '1px solid #E0E0E0'
128+
},
129+
130+
'&:hover fieldset': {
131+
border: '1px solid #BDBDBD'
132+
},
133+
134+
'&.Mui-focused fieldset': {
135+
border: '2px solid #4CAF50'
136+
},
137+
138+
// Remove black focus outline
139+
'&.Mui-focused': {
140+
outline: 'none',
141+
boxShadow: '0 0 0 3px rgba(76, 175, 80, 0.12)'
142+
}
143+
},
144+
145+
'& .MuiInputBase-input': {
146+
outline: 'none !important',
147+
boxShadow: 'none !important'
148+
}
149+
}}
150+
/>
151+
)}
152+
renderTags={(selected, getTagProps) =>
153+
selected.map((option, index) => (
154+
<Chip
155+
{...getTagProps({ index })}
156+
key={option.id}
157+
label={option.label}
158+
size="small"
159+
sx={{
160+
borderRadius: '8px',
161+
fontWeight: 500,
162+
backgroundColor: '#E8F5E9',
163+
color: '#2E7D32',
164+
'& .MuiChip-deleteIcon': {
165+
color: '#4CAF50',
166+
167+
'&:hover': {
168+
color: '#1B5E20'
169+
}
170+
}
171+
}}
172+
/>
173+
))
174+
}
175+
slotProps={{
176+
paper: {
177+
sx: {
178+
mt: 1,
179+
borderRadius: '14px',
180+
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.10)',
181+
overflow: 'hidden'
182+
}
183+
},
184+
listbox: {
185+
sx: {
186+
p: '6px',
187+
188+
'& .MuiAutocomplete-option': {
189+
borderRadius: '9px',
190+
padding: '10px 12px',
191+
marginBottom: '2px',
192+
193+
'&:hover': {
194+
backgroundColor: '#F1F8F2'
195+
},
196+
197+
"&[aria-selected='true']": {
198+
backgroundColor: '#E8F5E9',
199+
color: '#2E7D32'
200+
}
201+
}
202+
}
203+
}
204+
}}
205+
value={value}
206+
onChange={(_, newValue) => {
207+
setValue(newValue);
208+
}}
209+
onInputChange={(_, newInputValue) => {
210+
setInputValue(newInputValue);
211+
}}
212+
/>
213+
<Button
214+
fullWidth
215+
disabled={recipeLoading || value.length === 0}
216+
endIcon={!recipeLoading && <SearchIcon sx={{ fontSize: '1rem !important' }} />}
217+
sx={{
218+
width: '6rem',
219+
borderRadius: '9px',
220+
221+
textTransform: 'none',
222+
fontSize: '0.78rem',
223+
fontWeight: 700,
224+
225+
backgroundColor: '#2E7D32',
226+
boxShadow: 'none',
227+
228+
'&:hover': {
229+
backgroundColor: '#1B5E20',
230+
boxShadow: 'none'
231+
}
232+
}}
233+
variant="contained"
234+
onClick={() => {
235+
searchRecipe(value.map((v) => v.id));
236+
}}
237+
>
238+
{recipeLoading ? <CircularProgress size={23} sx={{ color: '#9E9E9E' }} /> : 'Search'}
239+
</Button>
240+
</Stack>
241+
242+
<Stack>
243+
<RecipeSlider recipes={recipes} />
244+
</Stack>
245+
</Stack>
246+
</Box>
247+
);
248+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import PlayHeader from 'common/playlists/PlayHeader';
2+
import './styles.css';
3+
import { Stack, Typography } from '@mui/material';
4+
import IngredientSearch from './IngredientSearch';
5+
6+
// WARNING: Do not change the entry componenet name
7+
function PantryAlchemy(props) {
8+
// Example usage: Search for recipes with chicken, garlic, and sweet potato
9+
// searchByIngredients(['chicken', 'garlic', 'sweet potato']);
10+
// Your Code Start below.
11+
12+
return (
13+
<>
14+
<div className="play-details">
15+
<PlayHeader play={props} />
16+
<div className="play-details-body">
17+
{/* Your Code Starts Here */}
18+
<div>
19+
<Stack gap={2} mt={2} width="40rem">
20+
<Stack textAlign="center">
21+
<Typography
22+
component="h1"
23+
sx={{
24+
fontSize: {
25+
xs: '2rem',
26+
sm: '2.5rem',
27+
md: '3rem'
28+
},
29+
fontWeight: 800,
30+
letterSpacing: '-0.04em',
31+
lineHeight: 1.1,
32+
mb: 1,
33+
34+
background: 'linear-gradient(135deg, #1B5E20 0%, #388E3C 45%, #66BB6A 100%)',
35+
WebkitBackgroundClip: 'text',
36+
WebkitTextFillColor: 'transparent',
37+
backgroundClip: 'text'
38+
}}
39+
>
40+
Pantry Alchemy
41+
</Typography>
42+
43+
<Typography
44+
component="h1"
45+
sx={{
46+
color: '#6B7D6C',
47+
fontSize: { xs: '0.95rem', sm: '1.05rem' },
48+
fontWeight: 400,
49+
maxWidth: 520,
50+
lineHeight: 1.6,
51+
marginLeft: 'auto',
52+
marginRight: 'auto'
53+
}}
54+
textAlign="center"
55+
>
56+
Turn the ingredients in your pantry into something delicious.
57+
</Typography>
58+
</Stack>
59+
<IngredientSearch />
60+
<Stack />
61+
</Stack>
62+
</div>
63+
{/* Your Code Ends Here */}
64+
</div>
65+
</div>
66+
</>
67+
);
68+
}
69+
70+
export default PantryAlchemy;

src/plays/pantry-alchemy/Readme.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Pantry Alchemy
2+
3+
An intelligent culinary engine that transforms everyday ingredients into tailored, restaurant-quality dish concepts.
4+
5+
## Play Demographic
6+
7+
- Language: js
8+
- Level: Beginner
9+
10+
## Creator Information
11+
12+
- User: Farhaan
13+
- Gihub Link: https://github.com/Farhaan
14+
- Blog:
15+
- Video:
16+
17+
## Implementation Details
18+
19+
- React 18 application built with **Material UI**, React Hooks, and the browser `fetch` API.
20+
- Ingredient autocomplete uses **Open Food Facts**, with 400ms debounced searches and multi-selection.
21+
- Selected ingredients are submitted to the **Edamam Recipe API** to retrieve matching recipes.
22+
- Recipes are displayed in responsive horizontal cards with images, ingredient summaries, expandable health labels, and source links.
23+
24+
## Consideration
25+
26+
- Edamam API credentials are currently hardcoded; move them to environment variables or a backend before production.
27+
- API failures are currently logged to the console rather than displayed to users.
28+
- `sample.json` contains potentially expiring Edamam image URLs and exposed API credentials.
29+
- The application depends on external API availability and browser CORS permissions.
30+
31+
## Resources
32+
33+
- **Edamam Recipe API** — recipe search and recipe data.
34+
- **Open Food Facts** — ingredient autocomplete and taxonomy suggestions.
35+
- **Material UI** — UI components, responsive styling, cards, chips, and controls.
36+
- **React** — component architecture, state management, effects, and API interactions.

0 commit comments

Comments
 (0)