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
5 changes: 3 additions & 2 deletions src/components/KitchenandInventory/Recipes/RecipeCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const RecipeCard = ({ recipe, onViewDetails }) => {
</div>

{/* View Button */}
<button className={styles.viewButton} onClick={() => onViewDetails(recipe.id)}>
<button className={styles.viewButton} onClick={() => onViewDetails(recipe._id || recipe.id)}>
View Recipe
</button>
</div>
Expand All @@ -61,7 +61,8 @@ const RecipeCard = ({ recipe, onViewDetails }) => {

RecipeCard.propTypes = {
recipe: PropTypes.shape({
id: PropTypes.number.isRequired,
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
_id: PropTypes.string,
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
Expand Down
61 changes: 56 additions & 5 deletions src/components/KitchenandInventory/Recipes/RecipesLandingPage.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import RecipeCard from './RecipeCard';
import ViewRecipe from './ViewRecipe';
import { mockRecipes } from './mockRecipes';
import styles from './RecipesLandingPage.module.css';

const API_URL = `${window.location.protocol}//${window.location.hostname}:4500/api/kitchenandinventory/recipes`;

Check warning on line 8 in src/components/KitchenandInventory/Recipes/RecipesLandingPage.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ2ezZG13H3A8PlcyV0U&open=AZ2ezZG13H3A8PlcyV0U&pullRequest=5167

Check warning on line 8 in src/components/KitchenandInventory/Recipes/RecipesLandingPage.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ2ezZG03H3A8PlcyV0T&open=AZ2ezZG03H3A8PlcyV0T&pullRequest=5167

const RecipesLandingPage = () => {
const [recipes, setRecipes] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [filteredRecipes, setFilteredRecipes] = useState([]);
const [selectedRecipe, setSelectedRecipe] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
setRecipes(mockRecipes);
setFilteredRecipes(mockRecipes);
const fetchRecipes = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get(API_URL, {
headers: { Authorization: token },
});
if (response.data && response.data.length > 0) {
setRecipes(response.data);
setFilteredRecipes(response.data);
} else {
setRecipes(mockRecipes);
setFilteredRecipes(mockRecipes);
}
} catch (err) {
// Fallback to mock data if API is unavailable
setRecipes(mockRecipes);
setFilteredRecipes(mockRecipes);
} finally {

Check warning on line 35 in src/components/KitchenandInventory/Recipes/RecipesLandingPage.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ2ezZG13H3A8PlcyV0V&open=AZ2ezZG13H3A8PlcyV0V&pullRequest=5167
setLoading(false);
}
};
fetchRecipes();
}, []);

useEffect(() => {
Expand All @@ -31,19 +55,36 @@
}, [searchTerm, recipes]);

const handleViewRecipe = recipeId => {
const recipe = recipes.find(r => r.id === recipeId);
const recipe = recipes.find(r => (r._id || r.id) === recipeId);
setSelectedRecipe(recipe);
};

const handleCloseRecipe = () => {
setSelectedRecipe(null);
};

const handleRecipeUpdate = updatedRecipe => {
const recipeId = updatedRecipe._id || updatedRecipe.id;
setRecipes(prev => prev.map(r => ((r._id || r.id) === recipeId ? updatedRecipe : r)));
setSelectedRecipe(updatedRecipe);
};

const handleAddRecipe = () => {
// eslint-disable-next-line no-console
console.log('Add new recipe');
};

if (loading) {
return (
<div className={styles.recipesContainer}>
<div className={styles.header}>
<h1 className={styles.pageTitle}>Recipes</h1>
</div>
<div className={styles.resultsCount}>Loading recipes...</div>
</div>
);
}

return (
<>
<div className={styles.recipesContainer}>
Expand Down Expand Up @@ -75,7 +116,11 @@
<div className={styles.recipesGrid}>
{filteredRecipes.length > 0 ? (
filteredRecipes.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} onViewDetails={handleViewRecipe} />
<RecipeCard
key={recipe._id || recipe.id}
recipe={recipe}
onViewDetails={handleViewRecipe}
/>
))
) : (
<div className={styles.noResults}>
Expand All @@ -85,7 +130,13 @@
</div>
</div>

{selectedRecipe && <ViewRecipe recipe={selectedRecipe} onClose={handleCloseRecipe} />}
{selectedRecipe && (
<ViewRecipe
recipe={selectedRecipe}
onClose={handleCloseRecipe}
onRecipeUpdate={handleRecipeUpdate}
/>
)}
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import axios from 'axios';
import styles from './SubstituteIngredientModal.module.css';

// Mock inventory data - replace with real inventory API when available
const mockInventoryItems = [
{ id: 'inv1', name: 'White Pepper', category: 'Spices', quantityAvailable: '500g' },
{ id: 'inv2', name: 'Cayenne Pepper', category: 'Spices', quantityAvailable: '200g' },
{ id: 'inv3', name: 'Paprika', category: 'Spices', quantityAvailable: '350g' },
{ id: 'inv4', name: 'Crushed Crackers', category: 'Dry Goods', quantityAvailable: '1kg' },
{ id: 'inv5', name: 'Panko Flakes', category: 'Dry Goods', quantityAvailable: '750g' },
{ id: 'inv6', name: 'Cheddar Cheese', category: 'Dairy', quantityAvailable: '2kg' },
{ id: 'inv7', name: 'Mozzarella Cheese', category: 'Dairy', quantityAvailable: '1.5kg' },
{ id: 'inv8', name: 'Oats', category: 'Dry Goods', quantityAvailable: '3kg' },
{ id: 'inv9', name: 'Chia Seeds', category: 'Dry Goods', quantityAvailable: '400g' },
{ id: 'inv10', name: 'Coconut Flakes', category: 'Dry Goods', quantityAvailable: '600g' },
];

const ENDPOINTS = {
substituteIngredient: recipeId =>
`${window.location.protocol}//${window.location.hostname}:4500/api/kitchenandinventory/recipes/${recipeId}/substitute`,

Check warning on line 22 in src/components/KitchenandInventory/Recipes/SubstituteIngredientModal.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ2ezZHA3H3A8PlcyV0X&open=AZ2ezZHA3H3A8PlcyV0X&pullRequest=5167

Check warning on line 22 in src/components/KitchenandInventory/Recipes/SubstituteIngredientModal.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ2ezZHA3H3A8PlcyV0W&open=AZ2ezZHA3H3A8PlcyV0W&pullRequest=5167
};

const SubstituteIngredientModal = ({ ingredient, recipeId, onConfirm, onClose }) => {
const [selectedItem, setSelectedItem] = useState('');
const [quantity, setQuantity] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const modalRef = useRef(null);
const dropdownRef = useRef(null);

useEffect(() => {
const handleEscape = e => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);

useEffect(() => {
const handleClickOutside = e => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

const filteredItems = mockInventoryItems.filter(
item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.category.toLowerCase().includes(searchTerm.toLowerCase()),
);

const selectedInventoryItem = mockInventoryItems.find(item => item.id === selectedItem);

const isValidObjectId = id => /^[a-fA-F0-9]{24}$/.test(id);

const handleConfirm = async () => {
if (!selectedItem || !quantity) return;

setIsSubmitting(true);
setError('');

const ingredientId = ingredient._id || ingredient.id;
const id = String(recipeId);

// If recipeId is a valid MongoDB ObjectId, call the API
if (isValidObjectId(id)) {
try {
const token = localStorage.getItem('token');
const url = ENDPOINTS.substituteIngredient(id);

const response = await axios.put(
url,
{
ingredientId,
substituteName: selectedInventoryItem.name,
quantity,
},
{
headers: {
Authorization: token,
},
},
);

onConfirm({
ingredientId,
substituteId: selectedItem,
substituteName: selectedInventoryItem.name,
quantity,
updatedRecipe: response.data.recipe,
});
return;
} catch (err) {
const message =
err.response?.data?.message || err.message || 'Failed to substitute ingredient';
setError(message);
setIsSubmitting(false);
return;
}
}

// Fallback: local-only update for mock data
onConfirm({
ingredientId,
substituteId: selectedItem,
substituteName: selectedInventoryItem.name,
quantity,
});
setIsSubmitting(false);
};

const handleSelectItem = itemId => {
setSelectedItem(itemId);
setIsDropdownOpen(false);
setSearchTerm('');
};

return (
<div className={styles.modalOverlay}>
<button
type="button"
className={styles.modalBackdrop}
onClick={onClose}
aria-label="Close substitute modal"
/>
<div className={styles.modal} ref={modalRef}>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close substitute modal"
>
&#10005;
</button>

<h3 className={styles.modalTitle}>Substitute Ingredient</h3>

<div className={styles.currentIngredient}>
<span className={styles.currentLabel}>Replacing</span>
<div className={styles.currentInfo}>
<span className={styles.currentName}>{ingredient.name}</span>
<span className={styles.currentQty}>{ingredient.quantity}</span>
</div>
</div>

<div className={styles.formGroup}>
<label className={styles.label} htmlFor="substitute-dropdown">
Select Substitute
</label>
<div className={styles.dropdownWrapper} ref={dropdownRef}>
<button
type="button"
id="substitute-dropdown"
className={styles.dropdownTrigger}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
{selectedInventoryItem ? (
<span className={styles.selectedText}>
{selectedInventoryItem.name}
<span className={styles.selectedMeta}>
{selectedInventoryItem.category} &middot;{' '}
{selectedInventoryItem.quantityAvailable} available
</span>
</span>
) : (
<span className={styles.placeholder}>Choose an ingredient...</span>
)}
<span className={styles.chevron}>{isDropdownOpen ? '\u25B2' : '\u25BC'}</span>
</button>

{isDropdownOpen && (
<div className={styles.dropdownMenu}>
<input
type="text"
className={styles.searchInput}
placeholder="Search ingredients..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
<div className={styles.dropdownList}>
{filteredItems.length > 0 ? (
filteredItems.map(item => (
<button
type="button"
key={item.id}
className={`${styles.dropdownItem} ${
selectedItem === item.id ? styles.dropdownItemSelected : ''
}`}
onClick={() => handleSelectItem(item.id)}
>
<span className={styles.itemName}>{item.name}</span>
<span className={styles.itemMeta}>
{item.category} &middot; {item.quantityAvailable} available
</span>
</button>
))
) : (
<div className={styles.noResults}>No matching ingredients found</div>
)}
</div>
</div>
)}
</div>
</div>

<div className={styles.formGroup}>
<label className={styles.label} htmlFor="substitute-quantity">
Quantity Required
</label>
<input
type="text"
id="substitute-quantity"
className={styles.quantityInput}
placeholder="e.g. 2 cups, 500g, 1 tablespoon"
value={quantity}
onChange={e => setQuantity(e.target.value)}
/>
</div>

{error && <div className={styles.errorMessage}>{error}</div>}

<div className={styles.modalActions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Cancel
</button>
<button
type="button"
className={styles.confirmBtn}
onClick={handleConfirm}
disabled={!selectedItem || !quantity || isSubmitting}
>
{isSubmitting ? 'Updating...' : 'Confirm Substitute'}
</button>
</div>
</div>
</div>
);
};

SubstituteIngredientModal.propTypes = {
ingredient: PropTypes.shape({
id: PropTypes.string,
_id: PropTypes.string,
name: PropTypes.string.isRequired,
quantity: PropTypes.string.isRequired,
}).isRequired,
recipeId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
onConfirm: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

export default SubstituteIngredientModal;
Loading
Loading