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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ REACT_APP_ACTIVITY_ID=hackrplay
REACT_APP_DADJOKES_URL=https://jokeapi-v2.p.rapidapi.com/joke/
REACT_APP_DADJOKES_APIKEY=your_rapidapi_key_here
REACT_APP_DADJOKES_APIHOST='jokeapi-v2.p.rapidapi.com'
REACT_APP_APP_EDAMAM_KEY=your_edamam_api_key
REACT_APP_APP_EDAMAM_ID=your_edamam_app_id

# Add your API keys below:
# REACT_APP_SEARCH_APIKEY=your_search_api_key
Expand Down
248 changes: 248 additions & 0 deletions src/plays/pantry-alchemy/IngredientSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { useEffect, useState } from 'react';
import { Autocomplete, TextField, Chip, CircularProgress, Box, Stack, Button } from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import RecipeSlider from './RecipeSlider';

export default function IngredientAutocomplete({ onSubmit }) {
const [options, setOptions] = useState([]);
const [value, setValue] = useState([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [recipeLoading, setRecipeLoading] = useState(false);
const [recipes, setRecipes] = useState([]);
const appId = process.env.REACT_APP_APP_EDAMAM_ID;
const appKey = process.env.REACT_APP_APP_EDAMAM_KEY;

async function searchRecipe(ingredientsArray) {
// Join array of ingredients: ['chicken', 'garlic', 'spinach'] -> "chicken garlic spinach"
const searchQuery = ingredientsArray
.map((item) => (item.includes(' ') ? `"${item}"` : item))
.join(' ');

const params = new URLSearchParams({
type: 'public',
q: searchQuery,
app_id: appId,
app_key: appKey
});

const url = `https://api.edamam.com/api/recipes/v2?${params.toString()}`;

try {
setRecipeLoading(true);
const response = await fetch(url);
const data = await response.json();
setRecipes(data.hits);
} catch (error) {
console.error('Error:', error);
} finally {
setRecipeLoading(false);
}
}

async function searchIngredients(query) {
if (!query.trim()) return [];

const url =
`https://world.openfoodfacts.org/api/v3/taxonomy_suggestions` +
`?tagtype=ingredients` +
`&lc=en` +
`&string=${encodeURIComponent(query)}` +
`&limit=10`;

const response = await fetch(url);
const data = await response.json();

return data.suggestions || [];
}
useEffect(() => {
const query = inputValue.trim();

// Don't search for very short queries
if (query.length < 2) {
setOptions([]);

return;
}

const timeout = setTimeout(async () => {
try {
setLoading(true);

const data = await searchIngredients(query);

setOptions(data.map((item) => ({ id: item, label: item })));
} catch (error) {
console.error('Failed to search ingredients:', error);
setOptions([]);
} finally {
setLoading(false);
}
}, 400); // debounce

return () => clearTimeout(timeout);
}, [inputValue]);

return (
<Box sx={{ width: '100%' }}>
<Stack gap={2}>
<Stack alignItems="center" flexDirection="row" gap={2}>
<Autocomplete
filterSelectedOptions
fullWidth
multiple
getOptionLabel={(option) => option.label || ''}
inputValue={inputValue}
isOptionEqualToValue={(option, value) => option.id === value.id}
loading={loading}
loadingText="Searching..."
noOptionsText={inputValue.length < 2 ? 'Search Ingredients' : 'No ingredients found'}
options={options}
renderInput={(params) => (
<TextField
{...params}
placeholder={value.length ? 'Add another ingredient...' : 'Search ingredients...'}
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{loading && <CircularProgress color="success" size={20} />}
{params.InputProps.endAdornment}
</>
)
}
}}
sx={{
'& .MuiAutocomplete-input': {
border: 'none !important'
},
'& .MuiOutlinedInput-root': {
minHeight: 56,
borderRadius: '14px',
backgroundColor: '#fff',
border: 'none',
// Remove black/default border
'& fieldset': {
border: '1px solid #E0E0E0'
},

'&:hover fieldset': {
border: '1px solid #BDBDBD'
},

'&.Mui-focused fieldset': {
border: '2px solid #4CAF50'
},

// Remove black focus outline
'&.Mui-focused': {
outline: 'none',
boxShadow: '0 0 0 3px rgba(76, 175, 80, 0.12)'
}
},

'& .MuiInputBase-input': {
outline: 'none !important',
boxShadow: 'none !important'
}
}}
/>
)}
renderTags={(selected, getTagProps) =>
selected.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option.id}
label={option.label}
size="small"
sx={{
borderRadius: '8px',
fontWeight: 500,
backgroundColor: '#E8F5E9',
color: '#2E7D32',
'& .MuiChip-deleteIcon': {
color: '#4CAF50',

'&:hover': {
color: '#1B5E20'
}
}
}}
/>
))
}
slotProps={{
paper: {
sx: {
mt: 1,
borderRadius: '14px',
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.10)',
overflow: 'hidden'
}
},
listbox: {
sx: {
p: '6px',

'& .MuiAutocomplete-option': {
borderRadius: '9px',
padding: '10px 12px',
marginBottom: '2px',

'&:hover': {
backgroundColor: '#F1F8F2'
},

"&[aria-selected='true']": {
backgroundColor: '#E8F5E9',
color: '#2E7D32'
}
}
}
}
}}
value={value}
onChange={(_, newValue) => {
setValue(newValue);
}}
onInputChange={(_, newInputValue) => {
setInputValue(newInputValue);
}}
/>
<Button
fullWidth
disabled={recipeLoading || value.length === 0}
endIcon={!recipeLoading && <SearchIcon sx={{ fontSize: '1rem !important' }} />}
sx={{
width: '6rem',
borderRadius: '9px',

textTransform: 'none',
fontSize: '0.78rem',
fontWeight: 700,

backgroundColor: '#2E7D32',
boxShadow: 'none',

'&:hover': {
backgroundColor: '#1B5E20',
boxShadow: 'none'
}
}}
variant="contained"
onClick={() => {
searchRecipe(value.map((v) => v.id));
}}
>
{recipeLoading ? <CircularProgress size={23} sx={{ color: '#9E9E9E' }} /> : 'Search'}
</Button>
</Stack>

<Stack>
<RecipeSlider recipes={recipes} />
</Stack>
</Stack>
</Box>
);
}
70 changes: 70 additions & 0 deletions src/plays/pantry-alchemy/PantryAlchemy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import PlayHeader from 'common/playlists/PlayHeader';
import './styles.css';
import { Stack, Typography } from '@mui/material';
import IngredientSearch from './IngredientSearch';

// WARNING: Do not change the entry componenet name
function PantryAlchemy(props) {
// Example usage: Search for recipes with chicken, garlic, and sweet potato
// searchByIngredients(['chicken', 'garlic', 'sweet potato']);
// Your Code Start below.

return (
<>
<div className="play-details">
<PlayHeader play={props} />
<div className="play-details-body">
{/* Your Code Starts Here */}
<div>
<Stack gap={2} mt={2} width="40rem">
<Stack textAlign="center">
<Typography
component="h1"
sx={{
fontSize: {
xs: '2rem',
sm: '2.5rem',
md: '3rem'
},
fontWeight: 800,
letterSpacing: '-0.04em',
lineHeight: 1.1,
mb: 1,

background: 'linear-gradient(135deg, #1B5E20 0%, #388E3C 45%, #66BB6A 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
}}
>
Pantry Alchemy
</Typography>

<Typography
component="h1"
sx={{
color: '#6B7D6C',
fontSize: { xs: '0.95rem', sm: '1.05rem' },
fontWeight: 400,
maxWidth: 520,
lineHeight: 1.6,
marginLeft: 'auto',
marginRight: 'auto'
}}
textAlign="center"
>
Turn the ingredients in your pantry into something delicious.
</Typography>
</Stack>
<IngredientSearch />
<Stack />
</Stack>
</div>
{/* Your Code Ends Here */}
</div>
</div>
</>
);
}

export default PantryAlchemy;
36 changes: 36 additions & 0 deletions src/plays/pantry-alchemy/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Pantry Alchemy

An intelligent culinary engine that transforms everyday ingredients into tailored, restaurant-quality dish concepts.

## Play Demographic

- Language: js
- Level: Beginner

## Creator Information

- User: Farhaan
- Gihub Link: https://github.com/Farhaan
- Blog:
- Video:

## Implementation Details

- React 18 application built with **Material UI**, React Hooks, and the browser `fetch` API.
- Ingredient autocomplete uses **Open Food Facts**, with 400ms debounced searches and multi-selection.
- Selected ingredients are submitted to the **Edamam Recipe API** to retrieve matching recipes.
- Recipes are displayed in responsive horizontal cards with images, ingredient summaries, expandable health labels, and source links.

## Consideration

- Edamam API credentials are currently hardcoded; move them to environment variables or a backend before production.
- API failures are currently logged to the console rather than displayed to users.
- `sample.json` contains potentially expiring Edamam image URLs and exposed API credentials.
- The application depends on external API availability and browser CORS permissions.

## Resources

- **Edamam Recipe API** — recipe search and recipe data.
- **Open Food Facts** — ingredient autocomplete and taxonomy suggestions.
- **Material UI** — UI components, responsive styling, cards, chips, and controls.
- **React** — component architecture, state management, effects, and API interactions.
Loading
Loading