Vibed it... :(
This commit is contained in:
145
frontend/src/App.css
Normal file
145
frontend/src/App.css
Normal file
@@ -0,0 +1,145 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Global loading styles */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
min-height: 100vh;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
/* Global button styles */
|
||||
.btn {
|
||||
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;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
/* Reset some default styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
9
frontend/src/App.test.tsx
Normal file
9
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
79
frontend/src/App.tsx
Normal file
79
frontend/src/App.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import './App.css';
|
||||
|
||||
// Protected Route component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return user ? <>{children}</> : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
// Public Route component (redirect to dashboard if already logged in)
|
||||
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return user ? <Navigate to="/" /> : <>{children}</>;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Register />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
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;
|
||||
106
frontend/src/context/AuthContext.tsx
Normal file
106
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { authAPI, User } from '../services/api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const savedToken = localStorage.getItem('token');
|
||||
const savedUser = localStorage.getItem('user');
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
|
||||
try {
|
||||
// Verify token is still valid
|
||||
await authAPI.getProfile();
|
||||
} catch (error) {
|
||||
// Token is invalid, clear auth data
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
const response = await authAPI.login({ email, password });
|
||||
const { token: newToken, user: newUser } = response.data;
|
||||
|
||||
setToken(newToken);
|
||||
setUser(newUser);
|
||||
|
||||
localStorage.setItem('token', newToken);
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (username: string, email: string, password: string) => {
|
||||
try {
|
||||
const response = await authAPI.register({ username, email, password });
|
||||
const { token: newToken, user: newUser } = response.data;
|
||||
|
||||
setToken(newToken);
|
||||
setUser(newUser);
|
||||
|
||||
localStorage.setItem('token', newToken);
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
loading,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
19
frontend/src/index.tsx
Normal file
19
frontend/src/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
1
frontend/src/logo.svg
Normal file
1
frontend/src/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
146
frontend/src/pages/Auth.css
Normal file
146
frontend/src/pages/Auth.css
Normal file
@@ -0,0 +1,146 @@
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #2196F3;
|
||||
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
color: #c33;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.auth-button:hover:not(:disabled) {
|
||||
background: #1976D2;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-footer p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: #2196F3;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
color: #1976D2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
202
frontend/src/pages/Dashboard.css
Normal file
202
frontend/src/pages/Dashboard.css
Normal file
@@ -0,0 +1,202 @@
|
||||
.dashboard {
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: #d32f2f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tab-navigation {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 16px 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 3px solid transparent;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #333;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #2196F3;
|
||||
border-bottom-color: #2196F3;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
min-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tab-navigation {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
border-bottom-color: #e0e0e0;
|
||||
border-left: 3px solid #2196F3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
85
frontend/src/pages/Dashboard.tsx
Normal file
85
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import RecipeList from '../components/RecipeList';
|
||||
import ShoppingList from '../components/ShoppingList';
|
||||
import { UserSelection } from '../services/api';
|
||||
import './Dashboard.css';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const [userSelection, setUserSelection] = useState<UserSelection | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'recipes' | 'shopping'>('recipes');
|
||||
|
||||
const handleSelectionUpdate = (selection: UserSelection) => {
|
||||
setUserSelection(selection);
|
||||
};
|
||||
|
||||
const getSelectedRecipesCount = () => {
|
||||
return userSelection?.selectedRecipes.length || 0;
|
||||
};
|
||||
|
||||
const getTotalIngredientsCount = () => {
|
||||
return userSelection?.aggregatedIngredients.length || 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
<h1 className="app-title">Recipe Manager</h1>
|
||||
<p className="welcome-text">Welcome back, {user?.username}!</p>
|
||||
</div>
|
||||
|
||||
<div className="header-right">
|
||||
<div className="stats-container">
|
||||
<div className="stat-item">
|
||||
<span className="stat-number">{getSelectedRecipesCount()}</span>
|
||||
<span className="stat-label">Selected Recipes</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-number">{getTotalIngredientsCount()}</span>
|
||||
<span className="stat-label">Ingredients</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="logout-button" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="tab-navigation">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'recipes' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('recipes')}
|
||||
>
|
||||
Browse Recipes
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'shopping' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('shopping')}
|
||||
>
|
||||
My Menu & Shopping List
|
||||
{getSelectedRecipesCount() > 0 && (
|
||||
<span className="tab-badge">{getSelectedRecipesCount()}</span>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="dashboard-content">
|
||||
{activeTab === 'recipes' ? (
|
||||
<RecipeList onSelectionUpdate={handleSelectionUpdate} />
|
||||
) : (
|
||||
<ShoppingList
|
||||
userSelection={userSelection}
|
||||
onSelectionUpdate={handleSelectionUpdate}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
98
frontend/src/pages/Login.tsx
Normal file
98
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import './Auth.css';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email || !password) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (error: any) {
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<div className="auth-header">
|
||||
<h1>Welcome Back</h1>
|
||||
<p>Sign in to your recipe management account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="auth-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Signing In...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="auth-link">
|
||||
Sign up here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
136
frontend/src/pages/Register.tsx
Normal file
136
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import './Auth.css';
|
||||
|
||||
const Register: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username || !email || !password || !confirmPassword) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
await register(username, email, password);
|
||||
navigate('/');
|
||||
} catch (error: any) {
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<div className="auth-header">
|
||||
<h1>Create Account</h1>
|
||||
<p>Join us to start managing your recipes</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a password (min 6 characters)"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="auth-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="auth-link">
|
||||
Sign in here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
143
frontend/src/services/api.ts
Normal file
143
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export interface Recipe {
|
||||
_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
ingredients: Array<{
|
||||
name: string;
|
||||
amount: number;
|
||||
unit: string;
|
||||
}>;
|
||||
instructions: Array<{
|
||||
step: number;
|
||||
description: string;
|
||||
}>;
|
||||
servings: number;
|
||||
prepTime: number;
|
||||
cookTime: number;
|
||||
category: 'breakfast' | 'lunch' | 'dinner' | 'dessert' | 'snack' | 'appetizer';
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
imageUrl: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UserSelection {
|
||||
_id: string;
|
||||
userId: string;
|
||||
selectedRecipes: Array<{
|
||||
recipeId: Recipe;
|
||||
quantity: number;
|
||||
addedAt: string;
|
||||
}>;
|
||||
aggregatedIngredients: Array<{
|
||||
name: string;
|
||||
totalAmount: number;
|
||||
unit: string;
|
||||
recipes: Array<{
|
||||
recipeId: string;
|
||||
recipeTitle: string;
|
||||
amount: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
register: (userData: { username: string; email: string; password: string }) =>
|
||||
api.post<AuthResponse>('/users/register', userData),
|
||||
|
||||
login: (credentials: { email: string; password: string }) =>
|
||||
api.post<AuthResponse>('/users/login', credentials),
|
||||
|
||||
getProfile: () =>
|
||||
api.get<User>('/users/profile'),
|
||||
};
|
||||
|
||||
// Recipes API
|
||||
export const recipesAPI = {
|
||||
getAll: (params?: { category?: string; difficulty?: string; search?: string }) =>
|
||||
api.get('/recipes', { params }),
|
||||
|
||||
getById: (id: string) =>
|
||||
api.get(`/recipes/${id}`),
|
||||
|
||||
create: (recipe: Omit<Recipe, '_id' | 'createdAt'>) =>
|
||||
api.post('/recipes', recipe),
|
||||
|
||||
update: (id: string, recipe: Partial<Recipe>) =>
|
||||
api.put(`/recipes/${id}`, recipe),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/recipes/${id}`),
|
||||
};
|
||||
|
||||
// Selections API
|
||||
export const selectionsAPI = {
|
||||
get: () =>
|
||||
api.get<UserSelection>('/selections'),
|
||||
|
||||
addRecipe: (recipeId: string, quantity: number = 1) =>
|
||||
api.post<UserSelection>('/selections/add', { recipeId, quantity }),
|
||||
|
||||
updateQuantity: (recipeId: string, quantity: number) =>
|
||||
api.put<UserSelection>('/selections/update', { recipeId, quantity }),
|
||||
|
||||
removeRecipe: (recipeId: string) =>
|
||||
api.delete<UserSelection>(`/selections/remove/${recipeId}`),
|
||||
|
||||
clear: () =>
|
||||
api.delete<UserSelection>('/selections/clear'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
Reference in New Issue
Block a user