Vibed it... :(
This commit is contained in:
172
frontend/src/components/RecipeCard.css
Normal file
172
frontend/src/components/RecipeCard.css
Normal file
@@ -0,0 +1,172 @@
|
||||
.recipe-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.recipe-card.selected {
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.recipe-image-container {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.recipe-badges {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.difficulty-badge,
|
||||
.category-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.recipe-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.recipe-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.recipe-description {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 16px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recipe-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recipe-meta {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.recipe-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
106
frontend/src/components/RecipeCard.tsx
Normal file
106
frontend/src/components/RecipeCard.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { Recipe } from '../services/api';
|
||||
import './RecipeCard.css';
|
||||
|
||||
interface RecipeCardProps {
|
||||
recipe: Recipe;
|
||||
onAddToSelection: (recipeId: string) => void;
|
||||
onViewDetails: (recipe: Recipe) => void;
|
||||
isSelected?: boolean;
|
||||
selectedQuantity?: number;
|
||||
}
|
||||
|
||||
const RecipeCard: React.FC<RecipeCardProps> = ({
|
||||
recipe,
|
||||
onAddToSelection,
|
||||
onViewDetails,
|
||||
isSelected = false,
|
||||
selectedQuantity = 0,
|
||||
}) => {
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'easy': return '#4CAF50';
|
||||
case 'medium': return '#FF9800';
|
||||
case 'hard': return '#F44336';
|
||||
default: return '#757575';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'breakfast': return '#FFE082';
|
||||
case 'lunch': return '#81C784';
|
||||
case 'dinner': return '#64B5F6';
|
||||
case 'dessert': return '#F48FB1';
|
||||
case 'snack': return '#FFB74D';
|
||||
case 'appetizer': return '#A1C181';
|
||||
default: return '#E0E0E0';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`recipe-card ${isSelected ? 'selected' : ''}`}>
|
||||
<div className="recipe-image-container">
|
||||
<img
|
||||
src={recipe.imageUrl || '/placeholder-recipe.jpg'}
|
||||
alt={recipe.title}
|
||||
className="recipe-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-recipe.jpg';
|
||||
}}
|
||||
/>
|
||||
<div className="recipe-badges">
|
||||
<span
|
||||
className="difficulty-badge"
|
||||
style={{ backgroundColor: getDifficultyColor(recipe.difficulty) }}
|
||||
>
|
||||
{recipe.difficulty}
|
||||
</span>
|
||||
<span
|
||||
className="category-badge"
|
||||
style={{ backgroundColor: getCategoryColor(recipe.category) }}
|
||||
>
|
||||
{recipe.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="recipe-content">
|
||||
<h3 className="recipe-title">{recipe.title}</h3>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
|
||||
<div className="recipe-meta">
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Prep:</span>
|
||||
<span className="meta-value">{recipe.prepTime}min</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Cook:</span>
|
||||
<span className="meta-value">{recipe.cookTime}min</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Serves:</span>
|
||||
<span className="meta-value">{recipe.servings}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="recipe-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onViewDetails(recipe)}
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${isSelected ? 'btn-success' : 'btn-primary'}`}
|
||||
onClick={() => onAddToSelection(recipe._id)}
|
||||
>
|
||||
{isSelected ? `Added (${selectedQuantity})` : 'Add to Menu'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeCard;
|
||||
133
frontend/src/components/RecipeList.css
Normal file
133
frontend/src/components/RecipeList.css
Normal file
@@ -0,0 +1,133 @@
|
||||
.recipe-list-container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filters-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-input,
|
||||
.filter-select {
|
||||
padding: 10px 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-input:focus,
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #2196F3;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #2196F3;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
background: #fff5f5;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #fed7d7;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e53e3e;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-recipes {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.no-recipes p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filters-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recipe-list-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.filters-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
194
frontend/src/components/RecipeList.tsx
Normal file
194
frontend/src/components/RecipeList.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Recipe, recipesAPI, selectionsAPI, UserSelection } from '../services/api';
|
||||
import RecipeCard from './RecipeCard';
|
||||
import RecipeModal from './RecipeModal';
|
||||
import './RecipeList.css';
|
||||
|
||||
interface RecipeListProps {
|
||||
onSelectionUpdate?: (selection: UserSelection) => void;
|
||||
}
|
||||
|
||||
const RecipeList: React.FC<RecipeListProps> = ({ onSelectionUpdate }) => {
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [userSelection, setUserSelection] = useState<UserSelection | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
category: '',
|
||||
difficulty: '',
|
||||
search: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecipes();
|
||||
fetchUserSelection();
|
||||
}, [filters]);
|
||||
|
||||
const fetchRecipes = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await recipesAPI.getAll(filters);
|
||||
setRecipes(response.data as Recipe[]);
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.error || 'Failed to fetch recipes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserSelection = async () => {
|
||||
try {
|
||||
const response = await selectionsAPI.get();
|
||||
setUserSelection(response.data as UserSelection);
|
||||
if (onSelectionUpdate) {
|
||||
onSelectionUpdate(response.data as UserSelection);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// User might not have any selections yet, which is fine
|
||||
console.log('No user selections found');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToSelection = async (recipeId: string) => {
|
||||
try {
|
||||
const response = await selectionsAPI.addRecipe(recipeId, 1);
|
||||
setUserSelection(response.data as UserSelection);
|
||||
if (onSelectionUpdate) {
|
||||
onSelectionUpdate(response.data as UserSelection);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.error || 'Failed to add recipe to selection');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (recipe: Recipe) => {
|
||||
setSelectedRecipe(recipe);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSelectedRecipe(null);
|
||||
};
|
||||
|
||||
const handleFilterChange = (filterType: string, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[filterType]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const isRecipeSelected = (recipeId: string) => {
|
||||
return userSelection?.selectedRecipes.some(
|
||||
selection => selection.recipeId._id === recipeId
|
||||
) || false;
|
||||
};
|
||||
|
||||
const getSelectedQuantity = (recipeId: string) => {
|
||||
const selection = userSelection?.selectedRecipes.find(
|
||||
selection => selection.recipeId._id === recipeId
|
||||
);
|
||||
return selection?.quantity || 0;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading recipes...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<p className="error-message">{error}</p>
|
||||
<button className="btn btn-primary" onClick={fetchRecipes}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipe-list-container">
|
||||
<div className="filters-container">
|
||||
<div className="filter-group">
|
||||
<label htmlFor="search">Search Recipes:</label>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
placeholder="Search by title or description..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="filter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label htmlFor="category">Category:</label>
|
||||
<select
|
||||
id="category"
|
||||
value={filters.category}
|
||||
onChange={(e) => handleFilterChange('category', e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="breakfast">Breakfast</option>
|
||||
<option value="lunch">Lunch</option>
|
||||
<option value="dinner">Dinner</option>
|
||||
<option value="dessert">Dessert</option>
|
||||
<option value="snack">Snack</option>
|
||||
<option value="appetizer">Appetizer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label htmlFor="difficulty">Difficulty:</label>
|
||||
<select
|
||||
id="difficulty"
|
||||
value={filters.difficulty}
|
||||
onChange={(e) => handleFilterChange('difficulty', e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">All Difficulties</option>
|
||||
<option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="hard">Hard</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="recipes-grid">
|
||||
{recipes.length === 0 ? (
|
||||
<div className="no-recipes">
|
||||
<p>No recipes found matching your criteria.</p>
|
||||
</div>
|
||||
) : (
|
||||
recipes.map((recipe) => (
|
||||
<RecipeCard
|
||||
key={recipe._id}
|
||||
recipe={recipe}
|
||||
onAddToSelection={handleAddToSelection}
|
||||
onViewDetails={handleViewDetails}
|
||||
isSelected={isRecipeSelected(recipe._id)}
|
||||
selectedQuantity={getSelectedQuantity(recipe._id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRecipe && (
|
||||
<RecipeModal
|
||||
recipe={selectedRecipe}
|
||||
onClose={handleCloseModal}
|
||||
onAddToSelection={handleAddToSelection}
|
||||
isSelected={isRecipeSelected(selectedRecipe._id)}
|
||||
selectedQuantity={getSelectedQuantity(selectedRecipe._id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeList;
|
||||
274
frontend/src/components/RecipeModal.css
Normal file
274
frontend/src/components/RecipeModal.css
Normal file
@@ -0,0 +1,274 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px 0 20px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
|
||||
.recipe-image-section {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-recipe-image {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-badges {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.difficulty-badge,
|
||||
.category-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.recipe-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.modal-recipe-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.modal-recipe-description {
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recipe-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
background: #f8f9fa;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ingredients-section h3,
|
||||
.instructions-section h3 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.ingredients-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ingredient-item {
|
||||
background: #f8f9fa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-left: 4px solid #2196F3;
|
||||
}
|
||||
|
||||
.ingredient-amount {
|
||||
font-weight: 600;
|
||||
color: #2196F3;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ingredient-name {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instructions-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.instruction-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.instruction-step {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-backdrop {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.modal-recipe-image {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.modal-recipe-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.recipe-meta-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.ingredients-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.instruction-item {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.recipe-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
146
frontend/src/components/RecipeModal.tsx
Normal file
146
frontend/src/components/RecipeModal.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Recipe } from '../services/api';
|
||||
import './RecipeModal.css';
|
||||
|
||||
interface RecipeModalProps {
|
||||
recipe: Recipe;
|
||||
onClose: () => void;
|
||||
onAddToSelection: (recipeId: string) => void;
|
||||
isSelected: boolean;
|
||||
selectedQuantity: number;
|
||||
}
|
||||
|
||||
const RecipeModal: React.FC<RecipeModalProps> = ({
|
||||
recipe,
|
||||
onClose,
|
||||
onAddToSelection,
|
||||
isSelected,
|
||||
selectedQuantity,
|
||||
}) => {
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'easy': return '#4CAF50';
|
||||
case 'medium': return '#FF9800';
|
||||
case 'hard': return '#F44336';
|
||||
default: return '#757575';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'breakfast': return '#FFE082';
|
||||
case 'lunch': return '#81C784';
|
||||
case 'dinner': return '#64B5F6';
|
||||
case 'dessert': return '#F48FB1';
|
||||
case 'snack': return '#FFB74D';
|
||||
case 'appetizer': return '#A1C181';
|
||||
default: return '#E0E0E0';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<button className="close-button" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="recipe-image-section">
|
||||
<img
|
||||
src={recipe.imageUrl || '/placeholder-recipe.jpg'}
|
||||
alt={recipe.title}
|
||||
className="modal-recipe-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-recipe.jpg';
|
||||
}}
|
||||
/>
|
||||
<div className="modal-badges">
|
||||
<span
|
||||
className="difficulty-badge"
|
||||
style={{ backgroundColor: getDifficultyColor(recipe.difficulty) }}
|
||||
>
|
||||
{recipe.difficulty}
|
||||
</span>
|
||||
<span
|
||||
className="category-badge"
|
||||
style={{ backgroundColor: getCategoryColor(recipe.category) }}
|
||||
>
|
||||
{recipe.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="recipe-details">
|
||||
<h2 className="modal-recipe-title">{recipe.title}</h2>
|
||||
<p className="modal-recipe-description">{recipe.description}</p>
|
||||
|
||||
<div className="recipe-meta-grid">
|
||||
<div className="meta-card">
|
||||
<span className="meta-label">Prep Time</span>
|
||||
<span className="meta-value">{recipe.prepTime} min</span>
|
||||
</div>
|
||||
<div className="meta-card">
|
||||
<span className="meta-label">Cook Time</span>
|
||||
<span className="meta-value">{recipe.cookTime} min</span>
|
||||
</div>
|
||||
<div className="meta-card">
|
||||
<span className="meta-label">Total Time</span>
|
||||
<span className="meta-value">{recipe.prepTime + recipe.cookTime} min</span>
|
||||
</div>
|
||||
<div className="meta-card">
|
||||
<span className="meta-label">Servings</span>
|
||||
<span className="meta-value">{recipe.servings}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ingredients-section">
|
||||
<h3>Ingredients</h3>
|
||||
<ul className="ingredients-list">
|
||||
{recipe.ingredients.map((ingredient, index) => (
|
||||
<li key={index} className="ingredient-item">
|
||||
<span className="ingredient-amount">
|
||||
{ingredient.amount} {ingredient.unit}
|
||||
</span>
|
||||
<span className="ingredient-name">{ingredient.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="instructions-section">
|
||||
<h3>Instructions</h3>
|
||||
<ol className="instructions-list">
|
||||
{recipe.instructions.map((instruction) => (
|
||||
<li key={instruction.step} className="instruction-item">
|
||||
<div className="instruction-step">{instruction.step}</div>
|
||||
<div className="instruction-text">{instruction.description}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className={`btn ${isSelected ? 'btn-success' : 'btn-primary'} btn-large`}
|
||||
onClick={() => onAddToSelection(recipe._id)}
|
||||
>
|
||||
{isSelected ? `Added to Menu (${selectedQuantity})` : 'Add to Menu'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeModal;
|
||||
364
frontend/src/components/ShoppingList.css
Normal file
364
frontend/src/components/ShoppingList.css
Normal file
@@ -0,0 +1,364 @@
|
||||
.shopping-list-container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
color: #c33;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-banner button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c33;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.shopping-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.header-info h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.header-info p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.shopping-list-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.selected-recipes-section h3,
|
||||
.aggregated-ingredients-section h3 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #333;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.selected-recipes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.selected-recipe-item {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recipe-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recipe-thumbnail {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipe-details h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recipe-details p {
|
||||
margin: 0 0 4px 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recipe-meta {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.quantity-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quantity-controls label {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quantity-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.quantity-btn {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.quantity-btn:hover:not(:disabled) {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.quantity-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.quantity-display {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.remove-btn:hover:not(:disabled) {
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.remove-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.no-ingredients {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 40px 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ingredients-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ingredient-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-left: 4px solid #4CAF50;
|
||||
}
|
||||
|
||||
.ingredient-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ingredient-name {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.ingredient-total {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #4CAF50;
|
||||
background: #e8f5e8;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.ingredient-breakdown {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.recipe-breakdown {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.recipe-breakdown-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.recipe-name {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recipe-amount {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shopping-list-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.shopping-list-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.selected-recipe-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recipe-info {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.quantity-controls {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shopping-list-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ingredient-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recipe-breakdown-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
193
frontend/src/components/ShoppingList.tsx
Normal file
193
frontend/src/components/ShoppingList.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UserSelection, selectionsAPI } from '../services/api';
|
||||
import './ShoppingList.css';
|
||||
|
||||
interface ShoppingListProps {
|
||||
userSelection: UserSelection | null;
|
||||
onSelectionUpdate: (selection: UserSelection) => void;
|
||||
}
|
||||
|
||||
const ShoppingList: React.FC<ShoppingListProps> = ({ userSelection, onSelectionUpdate }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleQuantityChange = async (recipeId: string, newQuantity: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (newQuantity <= 0) {
|
||||
const response = await selectionsAPI.removeRecipe(recipeId);
|
||||
onSelectionUpdate(response.data);
|
||||
} else {
|
||||
const response = await selectionsAPI.updateQuantity(recipeId, newQuantity);
|
||||
onSelectionUpdate(response.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.error || 'Failed to update quantity');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRecipe = async (recipeId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await selectionsAPI.removeRecipe(recipeId);
|
||||
onSelectionUpdate(response.data);
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.error || 'Failed to remove recipe');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await selectionsAPI.clear();
|
||||
onSelectionUpdate(response.data);
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.error || 'Failed to clear selections');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalRecipes = () => {
|
||||
return userSelection?.selectedRecipes.reduce((total, recipe) => total + recipe.quantity, 0) || 0;
|
||||
};
|
||||
|
||||
if (!userSelection || userSelection.selectedRecipes.length === 0) {
|
||||
return (
|
||||
<div className="shopping-list-container">
|
||||
<div className="empty-state">
|
||||
<h2>Your Menu is Empty</h2>
|
||||
<p>Start by selecting some recipes to create your shopping list!</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shopping-list-container">
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<p>{error}</p>
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shopping-list-header">
|
||||
<div className="header-info">
|
||||
<h2>Your Menu & Shopping List</h2>
|
||||
<p>{userSelection.selectedRecipes.length} recipes • {getTotalRecipes()} total servings</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleClearAll}
|
||||
disabled={loading}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shopping-list-content">
|
||||
<div className="selected-recipes-section">
|
||||
<h3>Selected Recipes</h3>
|
||||
<div className="selected-recipes-list">
|
||||
{userSelection.selectedRecipes.map((selection) => (
|
||||
<div key={selection.recipeId._id} className="selected-recipe-item">
|
||||
<div className="recipe-info">
|
||||
<img
|
||||
src={selection.recipeId.imageUrl || '/placeholder-recipe.jpg'}
|
||||
alt={selection.recipeId.title}
|
||||
className="recipe-thumbnail"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-recipe.jpg';
|
||||
}}
|
||||
/>
|
||||
<div className="recipe-details">
|
||||
<h4>{selection.recipeId.title}</h4>
|
||||
<p>{selection.recipeId.description}</p>
|
||||
<span className="recipe-meta">
|
||||
{selection.recipeId.category} • {selection.recipeId.difficulty} •
|
||||
{selection.recipeId.prepTime + selection.recipeId.cookTime} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quantity-controls">
|
||||
<label>Quantity:</label>
|
||||
<div className="quantity-input-group">
|
||||
<button
|
||||
className="quantity-btn"
|
||||
onClick={() => handleQuantityChange(selection.recipeId._id, selection.quantity - 1)}
|
||||
disabled={loading || selection.quantity <= 1}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="quantity-display">{selection.quantity}</span>
|
||||
<button
|
||||
className="quantity-btn"
|
||||
onClick={() => handleQuantityChange(selection.recipeId._id, selection.quantity + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="remove-btn"
|
||||
onClick={() => handleRemoveRecipe(selection.recipeId._id)}
|
||||
disabled={loading}
|
||||
title="Remove recipe"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aggregated-ingredients-section">
|
||||
<h3>Shopping List</h3>
|
||||
{userSelection.aggregatedIngredients.length === 0 ? (
|
||||
<p className="no-ingredients">No ingredients to show.</p>
|
||||
) : (
|
||||
<div className="ingredients-grid">
|
||||
{userSelection.aggregatedIngredients.map((ingredient, index) => (
|
||||
<div key={index} className="ingredient-card">
|
||||
<div className="ingredient-header">
|
||||
<h4 className="ingredient-name">{ingredient.name}</h4>
|
||||
<span className="ingredient-total">
|
||||
{ingredient.totalAmount} {ingredient.unit}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ingredient-breakdown">
|
||||
<span className="breakdown-label">Used in:</span>
|
||||
<ul className="recipe-breakdown">
|
||||
{ingredient.recipes.map((recipe, recipeIndex) => (
|
||||
<li key={recipeIndex} className="recipe-breakdown-item">
|
||||
<span className="recipe-name">{recipe.recipeTitle}</span>
|
||||
<span className="recipe-amount">
|
||||
{recipe.amount} {ingredient.unit} × {recipe.quantity}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingList;
|
||||
Reference in New Issue
Block a user