Vibed it... :(

This commit is contained in:
2025-08-09 14:34:48 +01:00
commit 5cf478feab
41 changed files with 23512 additions and 0 deletions

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;