diff --git a/README.md b/README.md index 31466b54c..5476ba272 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,81 @@ -# Final Project +# Seasonal Recipes -Replace this readme with your own information about your project. +A full-stack web application for discovering and saving seasonal recipes. Browse recipes by season, filter by diet and allergies, rate recipes, and save your favourites. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +**Live app:** [agnesfinal-project.netlify.app](https://agnesfinal-project.netlify.app/) +**API:** [seasoned-api.onrender.com](https://seasoned-api.onrender.com/) -## The problem +--- -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +## Features -## View it live +- Browse recipes filtered by season (spring, summer, autumn, winter) +- Filter by diet (vegan, vegetarian) and allergies (lactose-free, gluten-free) +- Search recipes by name +- Sort by most/least popular based on ratings +- Rate recipes +- User registration and login +- Save and manage favourite recipes (requires login) -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +## Tech Stack + +**Frontend** +- React +- React Router +- Axios + +**Backend** +- Node.js +- Express +- MongoDB with Mongoose +- bcryptjs (password hashing) + +## Getting Started + +### Backend + +```bash +cd backend +npm install +npm run dev +``` + +Create a `.env` file in the `backend` folder: + +``` +MONGO_URL=your_mongodb_connection_string +PORT=8080 +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +The frontend expects the API to run on `http://localhost:8080`. Update `frontend/src/utils/api.js` if needed. + +## API Endpoints + +| Method | Endpoint | Description | Auth required | +|--------|----------|-------------|---------------| +| GET | `/recipes` | Get all recipes. Query: `?season=spring&search=pasta` | No | +| GET | `/recipes/:id` | Get a single recipe | No | +| GET | `/recipes/popular` | Get recipes sorted by average rating | No | +| GET | `/recipes/:id/reviews` | Get reviews for a recipe | No | +| POST | `/recipes/:id/reviews` | Add a rating to a recipe | No | +| POST | `/users` | Register a new user | No | +| POST | `/sessions` | Login | No | +| GET | `/favourites` | Get user's saved recipes | Yes | +| POST | `/favourites/:recipeId` | Save a recipe | Yes | +| DELETE | `/favourites/:recipeId` | Remove a saved recipe | Yes | + +## Deployment + +- Frontend deployed on [Netlify](https://agnesfinal-project.netlify.app/) +- Backend deployed on [Render](https://seasoned-api.onrender.com/) +- Database hosted on MongoDB Atlas + +> Note: The backend runs on Render's free tier and may take up to 60 seconds to wake up after inactivity. diff --git a/backend/Routes/favouriteRoutes.js b/backend/Routes/favouriteRoutes.js new file mode 100644 index 000000000..eb02b78ed --- /dev/null +++ b/backend/Routes/favouriteRoutes.js @@ -0,0 +1,59 @@ +import express from "express"; +import authenticateUser from "../middleware/auth.js"; +import Favorite from "../models/favorite.js"; + +const router = express.Router(); + +// Get all favourites for the logged-in user +router.get("/favourites", authenticateUser, async (req, res) => { + try { + const favourites = await Favorite.find({ userId: req.user._id }).populate("recipeId"); + res.json(favourites); + } catch (err) { + res.status(500).json({ error: "Could not fetch favourites" }); + } +}); + +// Save a recipe as favourite +router.post("/favourites/:recipeId", authenticateUser, async (req, res) => { + try { + const existing = await Favorite.findOne({ + userId: req.user._id, + recipeId: req.params.recipeId, + }); + + if (existing) { + return res.status(409).json({ error: "Recipe already saved" }); + } + + const favourite = new Favorite({ + userId: req.user._id, + recipeId: req.params.recipeId, + }); + + await favourite.save(); + res.status(201).json(favourite); + } catch (err) { + res.status(400).json({ error: "Could not save favourite" }); + } +}); + +// Remove a favourite +router.delete("/favourites/:recipeId", authenticateUser, async (req, res) => { + try { + const deleted = await Favorite.findOneAndDelete({ + userId: req.user._id, + recipeId: req.params.recipeId, + }); + + if (!deleted) { + return res.status(404).json({ error: "Favourite not found" }); + } + + res.json({ message: "Favourite removed" }); + } catch (err) { + res.status(400).json({ error: "Could not remove favourite" }); + } +}); + +export default router; diff --git a/backend/Routes/reviewRoutes.js b/backend/Routes/reviewRoutes.js new file mode 100644 index 000000000..b00d7891f --- /dev/null +++ b/backend/Routes/reviewRoutes.js @@ -0,0 +1,46 @@ +import express from "express"; +import Review from "../models/review.js"; + +const router = express.Router(); + +// Get popular recipes sorted by average rating +router.get("/recipes/popular", async (req, res) => { + try { + const ratings = await Review.aggregate([ + { $group: { _id: "$recipeId", avgRating: { $avg: "$rating" } } }, + { $sort: { avgRating: -1 } }, + ]); + res.json(ratings); + } catch (err) { + res.status(500).json({ error: "Could not fetch popular recipes" }); + } +}); + +// Get all reviews for a recipe +router.get("/recipes/:recipeId/reviews", async (req, res) => { + try { + const reviews = await Review.find({ + recipeId: req.params.recipeId, + }).populate("userId", "name"); + res.json(reviews); + } catch (err) { + res.status(500).json({ error: "Could not fetch reviews" }); + } +}); + +// Add a review +router.post("/recipes/:recipeId/reviews", async (req, res) => { + try { + const review = new Review({ + recipeId: req.params.recipeId, + rating: req.body.rating, + }); + + await review.save(); + res.status(201).json(review); + } catch (err) { + res.status(400).json({ error: "Could not create review" }); + } +}); + +export default router; diff --git a/backend/Routes/userRoutes.js b/backend/Routes/userRoutes.js new file mode 100644 index 000000000..f5c55f5e3 --- /dev/null +++ b/backend/Routes/userRoutes.js @@ -0,0 +1,51 @@ +import express from "express"; +import jwt from "jsonwebtoken"; +import User from "../models/user.js"; +import bcrypt from "bcryptjs"; + +const router = express.Router(); + +// Register new user +router.post("/users", async (req, res) => { + try { + // Get data from request body + const { name, email, password } = req.body; + + // Does the email already exist? + const existingUser = await User.findOne({ email }); + if (existingUser) { + // if the email already exists, return error messages + return res.status(400).json({ error: "That email already exists" }); + } + + // Create a new user with a hashed password + const user = new User({ name, email, password: bcrypt.hashSync(password) }); + + await user.save(); + + // Return user ID and token on successful registration + res.status(201).json({ id: user._id, accessToken: user.accessToken, name: user.name }); + } catch (err) { + res + .status(400) + .json({ message: "Could not create user", errors: err.errors }); + } +}); + +// When user Log in, send email and password, receive accessToken. +router.post("/sessions", async (req, res) => { + try { + const user = await User.findOne({ email: req.body.email }); + + // Check if the user exists AND if the password is correct + if (user && bcrypt.compareSync(req.body.password, user.password)) { + res.json({ userId: user._id, accessToken: user.accessToken, name: user.name }); + } else { + res.status(401).json({ message: "Invalid email or password" }); + } + } catch (err) { + res.status(500).json({ error: "Login failed" }); + } +}); + +export default router; diff --git a/backend/data/recipes.json b/backend/data/recipes.json new file mode 100644 index 000000000..e08f66c39 --- /dev/null +++ b/backend/data/recipes.json @@ -0,0 +1,202 @@ +[ + { + "title": "Asparagus Risotto", + "description": "A creamy Italian risotto with fresh spring asparagus, parmesan cheese and a hint of lemon.", + "ingredients": ["1 bunch asparagus", "1.5 cups arborio rice", "4 cups vegetable broth", "1 onion, diced", "2 cloves garlic", "0.5 cup parmesan cheese", "2 tbsp butter", "1 tbsp olive oil", "1 lemon, zested"], + "instructions": "Heat broth in a saucepan. Sauté onion and garlic in olive oil. Add rice and stir for 1 minute. Add broth one ladle at a time, stirring until absorbed. After 15 minutes, add chopped asparagus. Continue until rice is creamy. Stir in butter, parmesan and lemon zest.", + "season": "spring", + "imageUrl": "https://images.unsplash.com/photo-1633964913295-ceb43826e7c1?w=800", + "diet": "vegetarian", + "allergies": ["lactose", "gluten"] + }, + { + "title": "Spring Pea Soup", + "description": "A vibrant green soup made with fresh peas, mint and a touch of cream. Light and refreshing.", + "ingredients": ["500g fresh peas", "1 onion", "2 cloves garlic", "3 cups vegetable broth", "0.5 cup cream", "Fresh mint leaves", "Salt and pepper"], + "instructions": "Sauté onion and garlic until soft. Add peas and broth, bring to a boil. Simmer for 10 minutes. Blend until smooth. Stir in cream and chopped mint. Season with salt and pepper.", + "season": "spring", + "imageUrl": "https://images.unsplash.com/photo-1547592166-23ac45744acd?w=800", + "diet": "vegetarian", + "allergies": ["lactose"] + }, + { + "title": "Spinach and Radish Salad", + "description": "A fresh spring salad with baby spinach, crispy radishes, toasted seeds and a light vinaigrette.", + "ingredients": ["200g baby spinach", "8 radishes, sliced", "2 tbsp sunflower seeds", "1 avocado", "2 tbsp olive oil", "1 tbsp apple cider vinegar", "1 tsp honey", "Salt and pepper"], + "instructions": "Wash spinach and place in a bowl. Slice radishes thinly and add to the bowl. Dice avocado and add on top. Toast sunflower seeds in a dry pan. Whisk together olive oil, vinegar, honey, salt and pepper. Drizzle dressing over salad and top with seeds.", + "season": "spring", + "imageUrl": "https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=800", + "diet": "vegan", + "allergies": [] + }, + { + "title": "Rhubarb Crumble", + "description": "A classic sweet and tart rhubarb crumble with a buttery oat topping. Perfect spring dessert.", + "ingredients": ["500g rhubarb", "100g sugar", "150g flour", "100g butter", "80g oats", "50g brown sugar", "1 tsp cinnamon"], + "instructions": "Cut rhubarb into pieces, mix with sugar and place in a baking dish. Mix flour, oats, brown sugar and cinnamon. Rub in cold butter until crumbly. Spread topping over rhubarb. Bake at 190°C for 35 minutes until golden.", + "season": "spring", + "imageUrl": "https://images.unsplash.com/photo-1464305795204-6f5bbfc7fb81?w=800", + "diet": "vegetarian", + "allergies": ["gluten", "lactose"] + }, + { + "title": "Asparagus and Goat Cheese Tart", + "description": "A flaky puff pastry tart topped with roasted asparagus and creamy goat cheese.", + "ingredients": ["1 sheet puff pastry", "1 bunch asparagus", "150g goat cheese", "1 egg", "2 tbsp olive oil", "Salt and pepper", "Fresh thyme"], + "instructions": "Roll out puff pastry on a baking sheet. Score a border 2cm from the edge. Spread goat cheese inside the border. Arrange asparagus on top. Drizzle with olive oil, season with salt, pepper and thyme. Brush border with beaten egg. Bake at 200°C for 20 minutes.", + "season": "spring", + "imageUrl": "https://images.unsplash.com/photo-1506084868230-bb9d95c24759?w=800", + "diet": "vegetarian", + "allergies": ["gluten", "lactose"] + }, + { + "title": "Grilled Zucchini Pasta", + "description": "Light summer pasta with charred zucchini, cherry tomatoes, garlic and fresh basil.", + "ingredients": ["300g pasta", "2 zucchini", "200g cherry tomatoes", "3 cloves garlic", "Fresh basil", "3 tbsp olive oil", "50g parmesan", "Salt and pepper"], + "instructions": "Cook pasta. Slice zucchini lengthwise, brush with oil and grill until charred. Halve tomatoes. Sauté garlic in olive oil, add tomatoes and cook 3 minutes. Chop grilled zucchini, add to pan with pasta. Toss with basil and parmesan.", + "season": "summer", + "imageUrl": "https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=800", + "diet": "vegetarian", + "allergies": ["gluten", "lactose"] + }, + { + "title": "Strawberry Summer Salad", + "description": "A sweet and savory salad with fresh strawberries, mixed greens, walnuts and balsamic dressing.", + "ingredients": ["150g mixed greens", "200g strawberries", "50g walnuts", "100g feta cheese", "2 tbsp balsamic vinegar", "3 tbsp olive oil", "1 tsp honey", "Salt and pepper"], + "instructions": "Wash and dry greens. Slice strawberries. Toast walnuts in a dry pan. Whisk balsamic vinegar, olive oil, honey, salt and pepper. Arrange greens on plates, top with strawberries, crumbled feta and walnuts. Drizzle with dressing.", + "season": "summer", + "imageUrl": "https://images.unsplash.com/photo-1505253716362-afaea1d3d1af?w=800", + "diet": "vegetarian", + "allergies": ["lactose"] + }, + { + "title": "Gazpacho", + "description": "A chilled Spanish tomato soup, perfect for hot summer days. No cooking required.", + "ingredients": ["6 ripe tomatoes", "1 cucumber", "1 red pepper", "2 cloves garlic", "3 tbsp olive oil", "2 tbsp red wine vinegar", "Salt and pepper", "Fresh basil"], + "instructions": "Roughly chop tomatoes, cucumber and pepper. Add all ingredients to a blender. Blend until smooth. Season with salt, pepper and vinegar to taste. Chill in the fridge for at least 1 hour. Serve cold with a drizzle of olive oil and fresh basil.", + "season": "summer", + "imageUrl": "https://images.unsplash.com/photo-1529566652340-2c41a1eb6d93?w=800", + "diet": "vegan", + "allergies": [] + }, + { + "title": "Cucumber Dill Tzatziki Bowl", + "description": "A refreshing Greek-inspired bowl with cucumber tzatziki, grilled chicken and warm pita.", + "ingredients": ["2 cucumbers", "200g Greek yogurt", "Fresh dill", "2 cloves garlic", "2 chicken breasts", "4 pita breads", "1 lemon", "Olive oil", "Salt and pepper"], + "instructions": "Grate cucumber and squeeze out water. Mix with yogurt, minced garlic, chopped dill, lemon juice, salt and pepper. Grill seasoned chicken breasts until cooked through. Warm pita breads. Slice chicken and serve in bowls with tzatziki and pita.", + "season": "summer", + "imageUrl": "https://images.unsplash.com/photo-1512058564366-18510be2db19?w=800", + "diet": "none", + "allergies": ["lactose", "gluten"] + }, + { + "title": "Tomato Bruschetta", + "description": "Classic Italian bruschetta with ripe summer tomatoes, fresh basil, garlic and good olive oil.", + "ingredients": ["4 ripe tomatoes", "1 baguette", "2 cloves garlic", "Fresh basil", "3 tbsp extra virgin olive oil", "1 tbsp balsamic vinegar", "Salt and pepper"], + "instructions": "Dice tomatoes and mix with chopped basil, olive oil, balsamic vinegar, salt and pepper. Let sit for 15 minutes. Slice baguette and toast until golden. Rub each slice with a cut garlic clove. Spoon tomato mixture on top. Serve immediately.", + "season": "summer", + "imageUrl": "https://images.unsplash.com/photo-1572695157366-5e585ab2b69f?w=800", + "diet": "vegan", + "allergies": ["gluten"] + }, + { + "title": "Mushroom Risotto", + "description": "A rich and earthy autumn risotto with mixed wild mushrooms, thyme and parmesan.", + "ingredients": ["300g mixed mushrooms", "1.5 cups arborio rice", "4 cups vegetable broth", "1 onion", "3 cloves garlic", "0.5 cup white wine", "50g parmesan", "2 tbsp butter", "Fresh thyme"], + "instructions": "Sauté mushrooms in butter until golden, set aside. Sauté onion and garlic. Add rice, stir 1 minute. Add wine and stir until absorbed. Add broth one ladle at a time. When rice is creamy, stir in mushrooms, parmesan and thyme.", + "season": "autumn", + "imageUrl": "https://images.unsplash.com/photo-1476124369491-e7addf5db371?w=800", + "diet": "vegetarian", + "allergies": ["lactose", "gluten"] + }, + { + "title": "Pumpkin Soup", + "description": "A velvety smooth pumpkin soup with warming spices, perfect for chilly autumn evenings.", + "ingredients": ["1 kg pumpkin", "1 onion", "2 cloves garlic", "3 cups vegetable broth", "200ml coconut cream", "1 tsp cumin", "0.5 tsp nutmeg", "Salt and pepper", "Pumpkin seeds for topping"], + "instructions": "Peel and cube pumpkin. Sauté onion and garlic. Add pumpkin, broth and spices. Bring to a boil and simmer 20 minutes until pumpkin is tender. Blend until smooth. Stir in coconut cream. Serve topped with toasted pumpkin seeds.", + "season": "autumn", + "imageUrl": "https://images.unsplash.com/photo-1476718406336-bb5a9690ee2a?w=800", + "diet": "vegan", + "allergies": [] + }, + { + "title": "Apple Cinnamon Cake", + "description": "A moist and fragrant autumn cake loaded with fresh apples and warm cinnamon.", + "ingredients": ["3 apples", "200g flour", "150g sugar", "2 eggs", "100ml vegetable oil", "1 tsp baking powder", "2 tsp cinnamon", "1 tsp vanilla extract", "Pinch of salt"], + "instructions": "Peel and slice apples. Mix flour, baking powder, cinnamon and salt. Whisk eggs, sugar, oil and vanilla. Combine wet and dry ingredients. Fold in apple slices. Pour into a greased baking pan. Bake at 180°C for 40 minutes until golden.", + "season": "autumn", + "imageUrl": "https://images.unsplash.com/photo-1568571780765-9276ac8b75a2?w=800", + "diet": "vegetarian", + "allergies": ["gluten"] + }, + { + "title": "Roasted Squash with Feta", + "description": "Caramelized roasted butternut squash with crumbled feta, pomegranate seeds and herbs.", + "ingredients": ["1 butternut squash", "100g feta cheese", "50g pomegranate seeds", "2 tbsp olive oil", "1 tbsp maple syrup", "Fresh sage", "Salt and pepper"], + "instructions": "Peel and cube squash. Toss with olive oil, maple syrup, salt and pepper. Roast at 200°C for 30 minutes until caramelized. Transfer to a serving plate. Top with crumbled feta, pomegranate seeds and fresh sage leaves.", + "season": "autumn", + "imageUrl": "https://images.unsplash.com/photo-1508030448922-26db6e228a29?w=800", + "diet": "vegetarian", + "allergies": ["lactose"] + }, + { + "title": "Plum Clafoutis", + "description": "A French baked custard dessert with sweet autumn plums. Simple and elegant.", + "ingredients": ["500g plums", "3 eggs", "100g sugar", "80g flour", "250ml milk", "1 tsp vanilla extract", "Butter for greasing", "Powdered sugar for dusting"], + "instructions": "Halve and pit plums. Butter a baking dish. Whisk eggs and sugar. Add flour, milk and vanilla, whisk until smooth. Pour a thin layer of batter in the dish. Arrange plums on top. Pour remaining batter over plums. Bake at 180°C for 35 minutes. Dust with powdered sugar.", + "season": "autumn", + "imageUrl": "https://images.unsplash.com/photo-1509461399763-ae67a981b254?w=800", + "diet": "vegetarian", + "allergies": ["gluten", "lactose"] + }, + { + "title": "Kale and Potato Soup", + "description": "A hearty winter soup with curly kale, potatoes and a hint of garlic. Warming and nutritious.", + "ingredients": ["200g kale", "4 potatoes", "1 onion", "3 cloves garlic", "4 cups vegetable broth", "2 tbsp olive oil", "Salt and pepper", "Crusty bread for serving"], + "instructions": "Dice potatoes and onion. Sauté onion and garlic in olive oil. Add potatoes and broth, bring to a boil. Simmer 15 minutes until potatoes are tender. Roughly chop kale, add to soup. Cook 5 more minutes. Season with salt and pepper. Serve with crusty bread.", + "season": "winter", + "imageUrl": "https://images.unsplash.com/photo-1547592166-23ac45744acd?w=800", + "diet": "vegan", + "allergies": [] + }, + { + "title": "Beef and Root Vegetable Stew", + "description": "A slow-cooked winter stew with tender beef, parsnips, carrots and beetroot.", + "ingredients": ["500g beef chuck", "2 parsnips", "3 carrots", "1 beetroot", "1 onion", "2 cloves garlic", "400ml beef broth", "200ml red wine", "2 tbsp flour", "Fresh rosemary", "Salt and pepper"], + "instructions": "Cut beef into chunks and coat with flour. Brown beef in a heavy pot. Add diced onion and garlic. Add chopped root vegetables, broth and wine. Add rosemary, salt and pepper. Bring to a boil, then simmer on low heat for 2 hours until beef is tender.", + "season": "winter", + "imageUrl": "https://images.unsplash.com/photo-1534939561126-855b8675edd7?w=800", + "diet": "none", + "allergies": ["gluten"] + }, + { + "title": "Cabbage Rolls", + "description": "Traditional Scandinavian cabbage rolls stuffed with pork, rice and spices. A winter classic.", + "ingredients": ["1 head cabbage", "400g ground pork", "100g cooked rice", "1 onion, grated", "1 egg", "200ml cream", "3 tbsp butter", "2 tbsp dark syrup", "Salt and pepper"], + "instructions": "Boil whole cabbage until leaves are soft. Mix pork, rice, grated onion, egg, salt and pepper. Place filling on each leaf and roll tightly. Brown rolls in butter. Place in a baking dish, pour cream and syrup over. Bake at 175°C for 1 hour.", + "season": "winter", + "imageUrl": "https://images.unsplash.com/photo-1518779578993-ec3579fee39f?w=800", + "diet": "none", + "allergies": ["lactose"] + }, + { + "title": "Beetroot and Lentil Salad", + "description": "A warm winter salad with roasted beetroot, lentils, goat cheese and a balsamic glaze.", + "ingredients": ["3 beetroots", "200g green lentils", "100g goat cheese", "50g walnuts", "2 tbsp balsamic vinegar", "3 tbsp olive oil", "Fresh parsley", "Salt and pepper"], + "instructions": "Roast whole beetroots at 200°C for 45 minutes. Cook lentils according to package. Peel and cube beetroot. Toss lentils and beetroot with olive oil, balsamic vinegar, salt and pepper. Top with crumbled goat cheese, walnuts and parsley.", + "season": "winter", + "imageUrl": "https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=800", + "diet": "vegetarian", + "allergies": ["lactose"] + }, + { + "title": "Turnip Gratin", + "description": "A creamy and golden baked turnip gratin with garlic, thyme and gruyère cheese.", + "ingredients": ["4 turnips", "200ml cream", "100ml milk", "100g gruyère cheese", "2 cloves garlic", "Fresh thyme", "1 tbsp butter", "Salt and pepper", "Pinch of nutmeg"], + "instructions": "Peel and thinly slice turnips. Mix cream, milk, minced garlic, nutmeg, salt and pepper. Layer turnip slices in a buttered baking dish, pouring cream mixture between layers. Top with grated gruyère and thyme. Bake at 180°C for 45 minutes until golden and bubbly.", + "season": "winter", + "imageUrl": "https://images.unsplash.com/photo-1506368249639-73a05d6f6488?w=800", + "diet": "vegetarian", + "allergies": ["lactose"] + } +] \ No newline at end of file diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 000000000..44a768128 --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,13 @@ +import User from "../models/user.js"; + +const authenticateUser = async (req, res, next) => { + const user = await User.findOne({ accessToken: req.header("Authorization") }); + if (user) { + req.user = user; + next(); + } else { + res.status(401).json({ error: "Authentication failed" }); + } +}; + +export default authenticateUser; diff --git a/backend/models/favorite.js b/backend/models/favorite.js new file mode 100644 index 000000000..147b0a30d --- /dev/null +++ b/backend/models/favorite.js @@ -0,0 +1,16 @@ +import mongoose from "mongoose"; + +const favoriteSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + recipeId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Recipe", + }, +}); + +const Favorite = mongoose.model("Favorite", favoriteSchema); + +export default Favorite; diff --git a/backend/models/recipe.js b/backend/models/recipe.js new file mode 100644 index 000000000..7d2ac44be --- /dev/null +++ b/backend/models/recipe.js @@ -0,0 +1,42 @@ +import mongoose from "mongoose"; + +const recipeSchema = new mongoose.Schema({ + title: { + type: String, + }, + description: { + type: String, + }, + ingredients: { + type: [String], + }, + instructions: { + type: String, + }, + season: { + type: String, + }, + imageUrl: { + type: String, + }, + hearts: { + type: Number, + default: 0, + }, + createdAt: { + type: Date, + default: () => new Date(), + }, + diet: { + type: String, + default: "none", + }, + allergies: { + type: [String], + default: [], + }, +}); + +const Recipe = mongoose.model("Recipe", recipeSchema); + +export default Recipe; diff --git a/backend/models/review.js b/backend/models/review.js new file mode 100644 index 000000000..33d0c416a --- /dev/null +++ b/backend/models/review.js @@ -0,0 +1,28 @@ +import mongoose from "mongoose"; + +const reviewSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + recipeId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Recipe", + }, + comment: { + type: String, + }, + rating: { + type: Number, + required: true, + min: 1, + max: 5, + }, + createdAt: { + type: Date, + default: () => new Date(), + }, +}); + +const Review = mongoose.model("Review", reviewSchema); +export default Review; diff --git a/backend/models/user.js b/backend/models/user.js new file mode 100644 index 000000000..34fe597b9 --- /dev/null +++ b/backend/models/user.js @@ -0,0 +1,27 @@ +import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; +import crypto from "crypto"; + +const userSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + }, + email: { + type: String, + unique: true, + required: true, + }, + password: { + type: String, + required: true, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, +}); + +const User = mongoose.model("User", userSchema); + +export default User; diff --git a/backend/package.json b/backend/package.json index 08f29f244..95da611d5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,8 @@ "description": "Server part of final project", "scripts": { "start": "babel-node server.js", - "dev": "nodemon server.js --exec babel-node" + "dev": "nodemon server.js --exec babel-node", + "seed": "babel-node seedDatabase.js" }, "author": "", "license": "ISC", @@ -12,9 +13,13 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "axios": "^1.13.5", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", + "dotenv": "^17.2.4", "express": "^4.17.3", + "jsonwebtoken": "^9.0.3", "mongoose": "^8.4.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/seedDatabase.js b/backend/seedDatabase.js new file mode 100644 index 000000000..afaac4356 --- /dev/null +++ b/backend/seedDatabase.js @@ -0,0 +1,99 @@ +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import axios from "axios"; +import { readFileSync } from "fs"; +import Recipe from "./models/recipe.js"; + +dotenv.config(); + +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; +const API_KEY = process.env.SPOONACULAR_API_KEY; + +const seasonIngredients = { + spring: "asparagus,rhubarb,radish,spinach,peas", + summer: "strawberry,zucchini,tomato,cucumber,dill", + autumn: "mushroom,apple,pumpkin,plum,squash", + winter: "cabbage,kale,parsnip,beetroot,turnip", +}; + +const fetchRecipesForSeason = async (season, ingredients) => { + const searchRes = await axios.get( + "https://api.spoonacular.com/recipes/complexSearch", + { + params: { + apiKey: API_KEY, + query: ingredients.split(",")[0], + number: 5, + }, + }, + ); + console.log(`${season} results:`, searchRes.data.totalResults, "found"); + console.log(searchRes.data.results.map((r) => r.title)); + + const recipes = []; + for (const result of searchRes.data.results) { + const detailRes = await axios.get( + `https://api.spoonacular.com/recipes/${result.id}/information`, + { + params: { apiKey: API_KEY }, + }, + ); + + const r = detailRes.data; + + recipes.push({ + title: r.title, + description: (r.summary || "").replace(/<[^>]*>/g, "").slice(0, 200), + ingredients: r.extendedIngredients + ? r.extendedIngredients.map((i) => i.original) + : [], + instructions: + r.analyzedInstructions?.[0]?.steps?.map((s) => s.step).join(" ") || + "No instructions available", + season: season, + imageUrl: r.image || "", + diet: (r.diets && r.diets[0]) || "none", + allergies: [ + ...(r.dairyFree ? [] : ["lactose"]), + ...(r.glutenFree ? [] : ["gluten"]), + ], + }); + } + return recipes; +}; + +const seedDatabase = async () => { + try { + await mongoose.connect(mongoUrl); + console.log("Connected to MongoDB"); + + await Recipe.deleteMany(); + console.log("Cleared old recipes"); + + let allRecipes = []; + + try { + // Try fetching from Spoonacular API + for (const [season, ingredients] of Object.entries(seasonIngredients)) { + console.log(`Fetching ${season} recipes...`); + const recipes = await fetchRecipesForSeason(season, ingredients); + allRecipes = allRecipes.concat(recipes); + } + } catch (apiError) { + // If API fails (e.g. rate limit), use local data as fallback + console.log("API failed, using local recipe data instead:", apiError.message); + allRecipes = JSON.parse( + readFileSync(new URL("./data/recipes.json", import.meta.url), "utf-8") + ); + } + + const created = await Recipe.insertMany(allRecipes); + console.log(`Seeded ${created.length} recipes`); + + await mongoose.connection.close(); + } catch (err) { + console.log("Seed failed:", err); + } +}; + +seedDatabase(); diff --git a/backend/server.js b/backend/server.js index 070c87518..c598e29f1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,14 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import authenticateUser from "./middleware/auth.js"; +import Recipe from "./models/recipe.js"; +import userRoutes from "./Routes/userRoutes.js"; +import favouriteRoutes from "./Routes/favouriteRoutes.js"; +import reviewRoutes from "./Routes/reviewRoutes.js"; + +import dotenv from "dotenv"; +dotenv.config(); const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; mongoose.connect(mongoUrl); @@ -11,12 +19,119 @@ const app = express(); app.use(cors()); app.use(express.json()); +app.use(reviewRoutes); +// API documentation app.get("/", (req, res) => { - res.send("Hello Technigo!"); + res.json({ + message: "Seasonal Recipes API", + endpoints: [ + { method: "GET", path: "/", description: "API documentation" }, + { + method: "GET", + path: "/recipes", + description: "Get all recipes. Query: ?season=spring&search=pasta", + }, + { + method: "GET", + path: "/recipes/:id", + description: "Get a specific recipe by ID", + }, + { + method: "PATCH", + path: "/recipes/:id/heart", + description: "Add a heart to a recipe (requires auth)", + }, + { method: "POST", path: "/users", description: "Register a new user" }, + { method: "POST", path: "/sessions", description: "Login" }, + { + method: "GET", + path: "/favourites", + description: "Get user's saved recipes (requires auth)", + }, + { + method: "POST", + path: "/favourites/:recipeId", + description: "Save a recipe (requires auth)", + }, + { + method: "DELETE", + path: "/favourites/:recipeId", + description: "Remove a saved recipe (requires auth)", + }, + ], + }); +}); + +// Get all recipes – supports ?season=spring and ?search=pasta +app.get("/recipes", async (req, res) => { + try { + const filter = {}; + + if (req.query.season) { + filter.season = req.query.season; + } + + if (req.query.search) { + filter.title = { $regex: req.query.search, $options: "i" }; + } + + const results = await Recipe.find(filter); + res.json(results); + } catch (err) { + res.status(500).json({ error: "Could not fetch recipes" }); + } +}); + +// Get a specific recipe by ID +app.get("/recipes/:id", async (req, res) => { + try { + const recipe = await Recipe.findById(req.params.id); + if (recipe) { + res.json(recipe); + } else { + res.status(404).json({ error: "Recipe not found" }); + } + } catch (error) { + res.status(400).json({ error: "Invalid recipe ID" }); + } +}); + +// Add a heart to a recipe – requires login +app.patch("/recipes/:id/heart", authenticateUser, async (req, res) => { + try { + const updatedRecipe = await Recipe.findByIdAndUpdate( + req.params.id, + { $inc: { hearts: 1 } }, + { new: true }, + ); + if (!updatedRecipe) { + return res.status(404).json({ error: "Recipe not found" }); + } + res.status(200).json(updatedRecipe); + } catch { + res.status(400).json({ error: "Unable to add heart to recipe" }); + } +}); + +app.use(userRoutes); +app.use(favouriteRoutes); + +// Temporary seed endpoint +import { readFileSync } from "fs"; +app.get("/seed", async (req, res) => { + try { + const data = JSON.parse( + readFileSync(new URL("./data/recipes.json", import.meta.url), "utf-8") + ); + await Recipe.deleteMany(); + const recipes = await Recipe.insertMany(data); + res.json({ message: `Seeded ${recipes.length} recipes` }); + } catch (err) { + res.status(500).json({ error: err.message }); + } }); -// Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); diff --git a/backend/vite-project/.gitignore b/backend/vite-project/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/backend/vite-project/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/backend/vite-project/README.md b/backend/vite-project/README.md new file mode 100644 index 000000000..cf4013c89 --- /dev/null +++ b/backend/vite-project/README.md @@ -0,0 +1,18 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information. + +Note: This will impact Vite dev & build performances. + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/backend/vite-project/eslint.config.js b/backend/vite-project/eslint.config.js new file mode 100644 index 000000000..4fa125da2 --- /dev/null +++ b/backend/vite-project/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/backend/vite-project/index.html b/backend/vite-project/index.html new file mode 100644 index 000000000..e9b5e01b0 --- /dev/null +++ b/backend/vite-project/index.html @@ -0,0 +1,13 @@ + + + + + + + vite-project + + +
+ + + diff --git a/backend/vite-project/package.json b/backend/vite-project/package.json new file mode 100644 index 000000000..0e486339a --- /dev/null +++ b/backend/vite-project/package.json @@ -0,0 +1,28 @@ +{ + "name": "vite-project", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^7.3.1" + } +} diff --git a/backend/vite-project/public/vite.svg b/backend/vite-project/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/backend/vite-project/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/vite-project/src/App.css b/backend/vite-project/src/App.css new file mode 100644 index 000000000..b9d355df2 --- /dev/null +++ b/backend/vite-project/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/backend/vite-project/src/App.jsx b/backend/vite-project/src/App.jsx new file mode 100644 index 000000000..f67355ae0 --- /dev/null +++ b/backend/vite-project/src/App.jsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from '/vite.svg' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> +
+ + Vite logo + + + React logo + +
+

Vite + React

+
+ +

+ Edit src/App.jsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default App diff --git a/frontend/src/assets/react.svg b/backend/vite-project/src/assets/react.svg similarity index 100% rename from frontend/src/assets/react.svg rename to backend/vite-project/src/assets/react.svg diff --git a/backend/vite-project/src/index.css b/backend/vite-project/src/index.css new file mode 100644 index 000000000..08a3ac9e1 --- /dev/null +++ b/backend/vite-project/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/backend/vite-project/src/main.jsx b/backend/vite-project/src/main.jsx new file mode 100644 index 000000000..b9a1a6dea --- /dev/null +++ b/backend/vite-project/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/backend/vite-project/vite.config.js b/backend/vite-project/vite.config.js new file mode 100644 index 000000000..4efe189b1 --- /dev/null +++ b/backend/vite-project/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react({ + babel: { + plugins: [['babel-plugin-react-compiler']], + }, + }), + ], +}) diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..a81ba3c9a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,17 @@ - + - + - Technigo React Vite Boiler Plate + + + + + Seasoned – Seasonal Recipe App
diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..984e35f3a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.13.5", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.13.0" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 000000000..ad37e2c2c --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/frontend/public/images/Carrot.jpg b/frontend/public/images/Carrot.jpg new file mode 100644 index 000000000..afa21ad8a Binary files /dev/null and b/frontend/public/images/Carrot.jpg differ diff --git a/frontend/public/images/Kitchen.jpg b/frontend/public/images/Kitchen.jpg new file mode 100644 index 000000000..d4dcb1052 Binary files /dev/null and b/frontend/public/images/Kitchen.jpg differ diff --git a/frontend/public/images/aboutUs.jpg b/frontend/public/images/aboutUs.jpg new file mode 100644 index 000000000..9516d4ffa Binary files /dev/null and b/frontend/public/images/aboutUs.jpg differ diff --git a/frontend/public/images/agnes.jpg b/frontend/public/images/agnes.jpg new file mode 100644 index 000000000..44ca1d211 Binary files /dev/null and b/frontend/public/images/agnes.jpg differ diff --git a/frontend/public/images/erik.jpg b/frontend/public/images/erik.jpg new file mode 100644 index 000000000..16def7796 Binary files /dev/null and b/frontend/public/images/erik.jpg differ diff --git a/frontend/public/images/food.png b/frontend/public/images/food.png new file mode 100644 index 000000000..6777ae7c8 Binary files /dev/null and b/frontend/public/images/food.png differ diff --git a/frontend/public/images/wheat.jpg b/frontend/public/images/wheat.jpg new file mode 100644 index 000000000..882c2cbac Binary files /dev/null and b/frontend/public/images/wheat.jpg differ diff --git a/frontend/public/images/wheat2.jpg b/frontend/public/images/wheat2.jpg new file mode 100644 index 000000000..f31d472d5 Binary files /dev/null and b/frontend/public/images/wheat2.jpg differ diff --git a/frontend/public/videos/wheat.mp4 b/frontend/public/videos/wheat.mp4 new file mode 100644 index 000000000..a3586e020 Binary files /dev/null and b/frontend/public/videos/wheat.mp4 differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..e71284b6d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,30 @@ -export const App = () => { +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { Header } from "./components/Header"; +import { Home } from "./pages/Home"; +import { AboutUs } from "./pages/AboutUs"; +import { Why } from "./pages/Why"; +import { RecipeList } from "./pages/RecipeList"; +import { Recipe } from "./pages/Recipe"; +import { Favorites } from "./pages/Favorites"; +import { Footer } from "./components/Footer"; +export const App = () => { return ( - <> -

Welcome to Final Project!

- + +
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
); }; diff --git a/frontend/src/assets/boiler-plate.svg b/frontend/src/assets/boiler-plate.svg deleted file mode 100644 index c9252833b..000000000 --- a/frontend/src/assets/boiler-plate.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/technigo-logo.svg b/frontend/src/assets/technigo-logo.svg deleted file mode 100644 index 3f0da3e57..000000000 --- a/frontend/src/assets/technigo-logo.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/components/AuthForm.jsx b/frontend/src/components/AuthForm.jsx new file mode 100644 index 000000000..62e0b7926 --- /dev/null +++ b/frontend/src/components/AuthForm.jsx @@ -0,0 +1,93 @@ +import React, { useState, useContext } from "react"; +import { api } from "../utils/api"; +import { AuthContext } from "../contexts/AuthContext"; + +// Modal form that handles both login and registration +// isRegister toggles between the two modes +export const AuthForm = ({ onSuccess, onClose }) => { + const [isRegister, setIsRegister] = useState(false); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const { login } = useContext(AuthContext); + + // Sends to /users (register) or /sessions (login) based on current mode + const handleSubmit = async (e) => { + e.preventDefault(); + try { + const url = isRegister ? "/users" : "/sessions"; + const body = isRegister ? { name, email, password } : { email, password }; + + const res = await api.post(url, body); + login(res.data.accessToken, res.data.name); + onSuccess(); + } catch (err) { + setError( + err.response?.data?.error || + err.response?.data?.message || + "Could not connect to server", + ); + } + }; + + return ( +
+
e.stopPropagation()}> + +
+

{isRegister ? "Register" : "Login"}

+ + {error && ( +

{error}

+ )} + + {isRegister && ( + <> + + + setName(e.target.value)} + placeholder="Your name.." + /> + + )} + + setEmail(e.target.value)} + placeholder="Your email.." + /> + + + setPassword(e.target.value)} + placeholder="Your password.." + /> + + + + +
+
+
+ ); +}; diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx new file mode 100644 index 000000000..462d7d80b --- /dev/null +++ b/frontend/src/components/Footer.jsx @@ -0,0 +1,8 @@ +export const Footer = () => ( + +); diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 000000000..f3eef03de --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -0,0 +1,93 @@ +import { Link, useNavigate } from "react-router-dom"; +import { useState, useContext } from "react"; +import { AuthContext } from "../contexts/AuthContext.jsx"; +import { AuthForm } from "./AuthForm"; + +// Responsive navbar with burger menu, login/logout, and greeting for logged-in users +export const Header = () => { + const [showAuth, setShowAuth] = useState(false); + const { isLoggedIn, userName, logout } = useContext(AuthContext); + const navigate = useNavigate(); + + const [isOpen, setIsOpen] = useState(false); + const closeMenu = () => setIsOpen(false); + const toggleMenu = () => setIsOpen((prev) => !prev); + + const handleLogout = () => { + logout(); + closeMenu(); + navigate("/"); + }; + + return ( + <> + + + {showAuth && ( + setShowAuth(false)} + onSuccess={() => setShowAuth(false)} + /> + )} + + ); +}; diff --git a/frontend/src/components/Rating.jsx b/frontend/src/components/Rating.jsx new file mode 100644 index 000000000..30e7b5e4b --- /dev/null +++ b/frontend/src/components/Rating.jsx @@ -0,0 +1,63 @@ +import { useState, useEffect } from "react"; +import { api } from "../utils/api"; + +// clickable=true shows interactive stars for rating, clickable=false shows average score only +const Rating = ({ recipeId, clickable = true }) => { + const [reviews, setReviews] = useState([]); + const [userRating, setUserRating] = useState(0); + + useEffect(() => { + api + .get(`/recipes/${recipeId}/reviews`) + .then((res) => setReviews(res.data)) + .catch((err) => console.error(err)); + }, [recipeId]); + const average = + reviews.length > 0 + ? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length + : 0; + + const submitRating = (rating) => { + api + .post(`/recipes/${recipeId}/reviews`, { rating }) + .then((res) => setReviews([...reviews, res.data])) + .catch((err) => console.error(err)); + }; + return ( +
+ {clickable ? ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + { + setUserRating(star); + submitRating(star); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + setUserRating(star); + submitRating(star); + } + }} + > + ★ + + ))} +
+ ) : ( +

★ {average.toFixed(1)}

+ )} + {clickable && ( +

+ {average.toFixed(1)} / 5 ({reviews.length} reviews) +

+ )} +
+ ); +}; +export default Rating; diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx new file mode 100644 index 000000000..990f12420 --- /dev/null +++ b/frontend/src/components/RecipeCard.jsx @@ -0,0 +1,86 @@ +import { AuthContext } from "../contexts/AuthContext.jsx"; +import { useContext, useEffect, useState } from "react"; +import { api } from "../utils/api"; +import { Link } from "react-router-dom"; +import Rating from "./Rating"; + +const RecipeCard = ({ + recipe, + showDelete = false, + onDelete, + isInitiallySaved = false, + onSavedChange, +}) => { + const { isLoggedIn, accessToken } = useContext(AuthContext); + const [isSaved, setIsSaved] = useState(isInitiallySaved); + + useEffect(() => { + setIsSaved(isInitiallySaved); + }, [isInitiallySaved]); + + // Optimistic UI: update state immediately, revert if the API call fails + const toggleSave = async () => { + const next = !isSaved; + setIsSaved(next); + onSavedChange?.(recipe._id, next); + + try { + if (next) { + await api.post(`/favourites/${recipe._id}`, null, { + headers: { Authorization: accessToken }, + }); + } else { + await api.delete(`/favourites/${recipe._id}`, { + headers: { Authorization: accessToken }, + }); + } + } catch (err) { + console.error(err); + setIsSaved(!next); + onSavedChange?.(recipe._id, !next); + } + }; + + return ( + +

{recipe.season}

+ {recipe.title} { + e.target.src = "/images/food.png"; + }} + /> + +

{recipe.title}

+

{recipe.description}

+ + {isLoggedIn && !showDelete && ( + + )} + + {isLoggedIn && showDelete && ( + + )} + + ); +}; + +export default RecipeCard; diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 000000000..9b9c03f4b --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,35 @@ +import { createContext, useState } from "react"; + +export const AuthContext = createContext(); + +// sessionStorage clears automatically when the browser tab is closed +export const AuthProvider = ({ children }) => { + const [accessToken, setAccessToken] = useState( + sessionStorage.getItem("accessToken"), + ); + const [userName, setUserName] = useState( + sessionStorage.getItem("userName"), + ); + + const isLoggedIn = !!accessToken; + + const login = (token, name) => { + sessionStorage.setItem("accessToken", token); + sessionStorage.setItem("userName", name); + setAccessToken(token); + setUserName(name); + }; + + const logout = () => { + sessionStorage.removeItem("accessToken"); + sessionStorage.removeItem("userName"); + setAccessToken(null); + setUserName(null); + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/data/seasonData.js b/frontend/src/data/seasonData.js new file mode 100644 index 000000000..4caee26f4 --- /dev/null +++ b/frontend/src/data/seasonData.js @@ -0,0 +1,147 @@ +export const seasonData = [ + { + season: "Winter ", + months: "December–February", + categories: [ + { + label: "Vegetables & root vegetables", + items: [ + "Carrots", + "Potatoes", + "Beets", + "Parsnips", + "Rutabaga", + "White & red cabbage", + "Kale", + "Leeks", + "Onions", + ], + }, + { + label: "Fruit & berries", + items: ["Swedish storage apples", "Dried berries and fruits"], + }, + { + label: "Legumes & grains", + items: [ + "Dried yellow peas", + "Green lentils", + "Beans (Swedish-grown if possible)", + "Oats and barley", + ], + }, + { + label: "Protein", + items: [ + "MSC-certified herring", + "Mussels ", + "Vegetarian proteins such as peas and lentils", + ], + }, + ], + }, + { + season: "Spring", + months: "mars–maj", + categories: [ + { + label: "Vegetables & root vegetables", + items: [ + "Asparagus", + "Spinach", + "Lettuce", + "Radishes", + "Spring onions", + "Nettles (wild-picked)]", + ], + }, + { + label: "Fruit & berries", + items: ["The last stored apples", "Rhubarb"], + }, + { + label: "Legumes & grains", + items: ["Pea shoots", "Swedish legumes from storage"], + }, + { + label: "Protein", + items: [ + "Sustainably caught Baltic herring", + "Rainbow trout (Swedish-farmed)", + "Legume-based alternatives", + ], + }, + ], + }, + { + season: "Summer", + months: "June–August", + categories: [ + { + label: "Vegetables & root vegetables", + items: [ + "Tomatoes (field-grown)", + "Cucumber", + "Zucchini", + "Broccoli", + "Cauliflower", + "Sugar snap peas", + "New potatoes", + "Carrots", + ], + }, + { + label: "Fruit & berries", + items: [ + "Strawberries", + "Raspberries", + "Blueberries", + "Cherries", + "Currants", + ], + }, + { + label: "Legumes & grains", + items: ["Fresh peas", "ava beans"], + }, + { + label: "Protein", + items: [ + "Sustainably caught mackerel", + "Mussels", + "Vegetarian proteins", + ], + }, + ], + }, + { + season: "Autumn", + months: "September–November", + categories: [ + { + label: "Vegetables & root vegetables", + items: [ + "Pumpkin", + "Potatoes", + "Carrots", + "Beets", + "Cabbage (all varieties)", + "Mushrooms", + "Celery", + ], + }, + { + label: "Fruit & berries", + items: ["Apples", "Pears", "Plums", "Lingonberries"], + }, + { + label: "Legumes & grains", + items: ["Dried beans and lentils", "Grains"], + }, + { + label: "Protein", + items: ["Sustainably caught cod (MSC-certified)", "Herring", "Legumes"], + }, + ], + }, +]; diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb..6ba8d7c6f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1,986 @@ +/* ======================================== + 1. GLOBAL RESET & BASE STYLES + ======================================== */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: "Inder", sans-serif; +} + +html, +body { + height: 100%; +} + +#root { + min-height: 100vh; +} + +/* Visually hidden but accessible to screen readers */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +h1 { + font-size: 30px; +} + +h2 { + font-size: 32px; +} + +h3 { + font-size: 24px; +} + +p { + font-size: 16px; + line-height: 26px; +} + +@media (min-width: 768px) { + h1 { + font-size: 48px; + } + + h2 { + font-size: 32px; + } + + h3 { + font-size: 24px; + } + + p { + font-size: 16px; + line-height: 26px; + } +} + +/* ======================================== + 2. APP LAYOUT + ======================================== */ + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-main { + flex: 1; +} + +/* ======================================== + 3. NAVBAR + ======================================== */ + +.nav-bar { + grid-area: navbar; + background: #00260d; + padding: 10px 15px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 2px solid rgb(27, 182, 184); + position: relative; + z-index: 10; +} + +@media (min-width: 768px) { + .nav-bar { + padding: 20px 20px; + } +} + +.nav-bar a { + display: block; + font-size: 16px; + padding: 8px 12px; + border-radius: 6px; + letter-spacing: 0.5px; + transition: all 0.2s ease-in-out; + cursor: pointer; + color: #ffffff; + text-decoration: none; +} + +a:hover { + transform: scale(1.05); +} + +.nav-left { + display: flex; + align-items: center; + gap: 10px; +} + +.nav-greeting { + color: white; + font-size: 0.8rem; +} + +@media (min-width: 768px) { + .nav-greeting { + font-size: 1rem; + } +} + +.nav-logo { + height: 40px; + width: 50px; + object-fit: cover; +} + +@media (min-width: 768px) { + .nav-logo { + height: 60px; + width: 70px; + } +} + +.nav-button { + background-color: #fc7824; + border: 1px solid white; + color: white; + cursor: pointer; + padding: 10px; + width: 180px; + border-radius: 10px; + font-size: 14px; + font-weight: bold; + letter-spacing: 1px; + transition: all 0.2s ease-in-out; +} + +.nav-bar .nav-button:hover { + transform: scale(1.05); +} + +.nav-bar .nav-button:focus-visible { + outline: 2px solid #fc7824; + outline-offset: 3px; +} + +/* Burger menu (mobile) */ +.nav-burger { + display: inline-flex; + flex-direction: column; + gap: 6px; + background: transparent; + border: 0; + cursor: pointer; + padding: 10px; +} + +.burger-line { + width: 26px; + height: 3px; + background: #fff; + border-radius: 2px; +} + +.nav-links { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #00260d; + padding: 12px; + z-index: 50; + flex-direction: column; + gap: 10px; +} + +.nav-links.open { + display: flex; +} + +@media (min-width: 1024px) { + .nav-burger { + display: none; + } + .nav-links { + display: flex; + position: static; + background: transparent; + padding: 0; + flex-direction: row; + align-items: center; + gap: 16px; + } +} + +/* ======================================== + 4. MODAL (Login/Register) + ======================================== */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background: rgb(255, 255, 255); + padding: 1.5rem; + border-radius: 8px; + width: 90vw; + max-width: 400px; + position: relative; +} + +.modal-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; +} + +.modal form { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: stretch; +} + +.modal input { + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 10px; + width: 100%; +} + +.log-button, +.log-button2 { + cursor: pointer; + padding: 10px; + width: 100%; + border-radius: 10px; + font-size: 14px; + font-weight: bold; + letter-spacing: 1px; + transition: all 0.2s ease-in-out; +} + +.log-button { + background-color: #fc7824; + border: 1px solid white; + color: white; +} + +.log-button2 { + background-color: white; + border: 1px solid black; + color: black; +} + +@media (min-width: 768px) { + .modal { + padding: 2rem; + } +} + +/* ======================================== + 5. HERO / HEADER (Video) + ======================================== */ + +.header { + grid-area: header; + position: relative; + overflow: hidden; + background: #e0e0e0; + max-width: 100%; + margin: 0; + border-radius: 0; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.15); +} + +@media (min-width: 768px) { + .header { + min-height: 60vh; + max-width: 90%; + margin: 20px auto; + border-radius: 20px; + } +} + +.hero-video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 40%; + z-index: 0; +} + +@media (max-width: 767px) { + .hero-video { + position: fixed; + height: 100vh; + } +} + +.header::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); + z-index: 1; +} + +.hero-content { + position: relative; + z-index: 2; + color: white; + text-align: center; + padding: 15px; +} + +@media (min-width: 768px) { + .hero-content { + padding: 40px; + } +} + +.Title { + padding: 5px; +} + +@media (min-width: 768px) { + .Title { + padding: 20px; + } +} + +.visit-link { + background-color: #fc7824; + border: 1px solid white; + color: white; + cursor: pointer; + padding: 10px; + width: 260px; + border-radius: 10px; + font-size: 14px; + font-weight: bold; + letter-spacing: 1px; + transition: all 0.2s ease-in-out; + text-decoration: none; + text-align: center; + display: inline-block; +} + +/* ======================================== + 6. HOME PAGE - Cards + ======================================== */ + +.home-cards { + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; + position: relative; + z-index: 5; + padding: 40px 20px 20px; + max-width: 90%; + margin: 0 auto 20px; +} + +@media (min-width: 768px) { + .home-cards { + flex-direction: row; + justify-content: center; + } +} + +.home-card { + background-color: #ffffff; + border: 1px solid rgb(233, 233, 233); + border-radius: 15px; + padding: 30px; + padding-top: 50px; + text-align: center; + flex: 1; + text-decoration: none; + color: black; + transition: transform 0.2s ease-in-out; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15); + overflow: visible; +} + +.home-card:hover, +.visit-button:hover, +.log-button:hover, +.log-button2:hover { + transform: scale(1.03); +} + +.home-card:focus-visible, +.visit-button:focus-visible, +.log-button:focus-visible, +.log-button2:focus-visible { + outline: 2px solid #b85300; + outline-offset: 3px; +} + +.home-card h2 { + color: #b85300; + margin-bottom: 0.2rem; + font-size: 1.2rem; +} + +.home-card p { + line-height: 1.3; +} + +.card-icon { + display: block; + margin: 0 auto; + font-size: 3rem; + color: white; + background: #fc7824; + border-radius: 50%; + padding: 10px; + transform: translateY(-75px); + margin-bottom: -70px; +} + +/* ======================================== + 7. HOME PAGE - Season Grid + ======================================== */ + +.season-grid { + display: flex; + flex-direction: column; + gap: 20px; + max-width: 100%; + margin: 10px auto 10px; +} + +.season-box { + background: #0c0c0c77; + border-radius: 15px; + padding: 20px; + flex: 1; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.season-box h2 { + color: #ffffff; + margin-bottom: 15px; + font-size: 1.2rem; +} + +.season-categories { + display: flex; + flex-direction: column; + gap: 15px; + text-align: left; +} + +@media (min-width: 768px) { + .season-categories { + flex-direction: row; + } +} + +.season-category { + flex: 1; +} + +.season-category h3 { + font-size: 14px; + color: rgb(255, 255, 255); +} + +.season-months { + margin-bottom: 20px; +} + +.season-category ul { + list-style: none; + padding: 0; + color: rgb(255, 255, 255); +} + +.season-category li { + font-size: 0.85rem; + padding: 2px 0; +} + +/* ======================================== + 8. ABOUT PAGE + ======================================== */ + +.about-cards { + display: flex; + flex-direction: column; + align-items: center; + gap: 25px; + margin-top: 30px; +} + +@media (min-width: 768px) { + .about-cards { + flex-direction: row; + justify-content: center; + } +} + +.about-card { + background: rgba(18, 17, 17, 0.286); + border-radius: 15px; + padding: 15px; + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + text-align: center; + width: 100%; + max-width: none; +} + +@media (min-width: 768px) { + .about-card { + flex: 1; + max-width: none; + } +} + +.about-card-img { + width: 120px; + aspect-ratio: 1 / 1; + border-radius: 50%; + object-fit: cover; + object-position: center; + flex-shrink: 0; +} + +.about-card h2 { + margin-bottom: 5px; + font-size: 1.2rem; +} + +.why-row { + display: flex; + gap: 30px; + margin-top: 10px; + margin-left: auto; + margin-right: auto; + max-width: 100%; +} + +@media (min-width: 768px) { + .why-row { + max-width: 90%; + } +} + +.why-card { + background: rgba(255, 255, 255, 0.566); + border-radius: 15px; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + text-align: left; + max-width: 90%; + margin: 20px auto; +} + +.why-card p { + color: black; +} + +.why-text { + background: rgba(0, 0, 0, 0.388); + border-radius: 15px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 15px; + text-align: center; + flex: 1; +} + +/* ======================================== + 9. RECIPES PAGE - Search & Filters + ======================================== */ + +.search-bar { + display: flex; + justify-content: center; + padding: 30px 20px 15px; +} + +.search-input { + padding: 12px 20px; + width: 100%; + max-width: 500px; + border-radius: 25px; + border: 2px solid #ddd; + font-size: 0.95rem; + outline: none; + transition: border-color 0.2s ease; +} + +.search-input:focus { + border-color: #00260d; +} + +.search-input::placeholder { + color: #aaa; +} + +.filters { + display: grid; + grid-template-columns: repeat(2, auto); + justify-content: center; + gap: 8px; + padding: 0 20px; + margin-bottom: 10px; +} + +@media (min-width: 768px) { + .filters { + display: flex; + flex-wrap: wrap; + } + .filter-button { + font-size: 0.85rem; + padding: 8px 18px; + } +} + +.filter-button { + background-color: #00260d; + border: none; + color: white; + cursor: pointer; + padding: 6px 12px; + border-radius: 25px; + font-size: 0.7rem; + letter-spacing: 0.5px; + transition: all 0.2s ease; + appearance: none; + -webkit-appearance: none; +} + +.filter-button:hover { + background-color: #004d1a; +} + +.filter-button:focus { + outline: 2px solid #00260d; + outline-offset: 2px; +} + +/* ======================================== + 10. RECIPES PAGE - Recipe List & Cards + ======================================== */ + +.recipe-list { + display: grid; + grid-template-columns: repeat(1, 1fr); + max-width: 90%; + gap: 20px; + margin: 40px auto; + justify-content: center; + justify-items: center; +} + +.recipe-card { + text-decoration: none; + color: inherit; + border-radius: 15px; + overflow: hidden; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.15); + width: 280px; + background: white; + position: relative; + padding-bottom: 70px; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +/* Tablet */ +@media (min-width: 768px) { + .recipe-list { + grid-template-columns: repeat(2, 1fr); + } + .recipe-card { + width: 300px; + } +} + +/* Desktop */ +@media (min-width: 1024px) { + .recipe-list { + grid-template-columns: repeat(3, 1fr); + } + .recipe-card { + width: 400px; + } +} + +.recipe-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25); +} + +.recipe-card img { + width: 100%; + aspect-ratio: 16 / 10; + object-fit: cover; +} + +.recipe-card h2, +.recipe-card h3 { + text-align: center; + margin: 15px 15px 5px; + font-size: 1.1rem; +} + +.recipe-card p { + text-align: center; + padding: 0 15px; + font-size: 0.9rem; + color: #555; + line-height: 1.4; +} + +/* Season badge (top left) */ +.recipe-card p:first-child { + background: #00260d; + color: white; + padding: 5px 12px; + border-radius: 20px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; + position: absolute; + top: 10px; + left: 10px; + z-index: 1; +} + +/* Rating badge (top right) */ +.recipe-card .rating { + position: absolute; + top: 10px; + right: 10px; + z-index: 1; +} + +.recipe-card .rating p.rating-short { + background: #00260d; + color: #f5a623; + text-transform: none; + letter-spacing: normal; + font-size: 0.75rem; + padding: 5px 12px; + margin: 0; + position: static; + border-radius: 20px; +} + +/* ======================================== + 11. SAVE BUTTON (shared) + ======================================== */ + +.savebutton { + position: absolute; + bottom: 15px; + left: 50%; + transform: translateX(-50%); + background-color: #00260d; + color: white; + border-radius: 25px; + border: none; + padding: 10px 24px; + cursor: pointer; + font-size: 0.85rem; + letter-spacing: 0.5px; + transition: all 0.2s ease; +} + +.savebutton:hover { + transform: translateX(-50%) translateY(-2px); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.2); +} + +.savebutton.saved { + background: #b85300; + color: white; + border: 2px solid rgb(248, 243, 243); +} + +/* ======================================== + 12. RECIPE PAGE (single recipe) + ======================================== */ + +.recipe-page { + max-width: 700px; + margin: 0 auto; + padding: 20px; +} + +.recipe-page-img { + width: 100%; + max-height: 400px; + object-fit: cover; + border-radius: 15px; +} + +.recipe-page-content { + background: white; + border-radius: 15px; + padding: 25px; + margin-top: 20px; +} + +.recipe-page-content h1 { + text-align: center; + margin-bottom: 5px; +} + +.recipe-page-season { + text-align: center; + color: #888; + font-style: italic; + margin-bottom: 20px; +} + +.recipe-page-description { + line-height: 1.6; + margin-bottom: 20px; +} + +.recipe-page-content h2 { + margin-bottom: 10px; + border-bottom: 1px solid #ddd; + padding-bottom: 5px; + font-size: 1.2rem; +} + +.recipe-page-ingredients { + padding-left: 20px; + margin-bottom: 20px; +} + +.recipe-page-ingredients li { + margin-bottom: 5px; +} + +.recipe-page-instructions { + line-height: 1.8; +} + +.recipe-page-content .savebutton { + position: static; + transform: none; + display: block; + margin: 25px auto 0; +} + +/* ======================================== + 13. RATING (stars) + ======================================== */ + +.stars { + display: flex; + gap: 5px; + justify-content: center; + margin: 15px 0; +} + +.star { + font-size: 1.8rem; + color: #ccc; + cursor: pointer; +} + +.star.filled { + color: #f5a623; +} + +/* ======================================== + 14. FAVORITES PAGE + ======================================== */ + +.Favorites-title, +.recipes-title { + text-align: center; + padding: 20px; + margin-top: 20px; +} + +/* ======================================== + 15. FOOTER + ======================================== */ + +.footer { + background: #00260d; + text-align: center; + padding: 30px; + border-bottom: 2px solid rgb(27, 182, 184); + color: #ffffff; + margin-top: auto; + position: relative; + z-index: 10; +} +/* ======================================== + 16. SPINNER-Loading + ======================================== */ + +.spinner-wrapper { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 0; +} +.spinner { + width: 48px; + height: 48px; + border: 5px solid #ddd; + border-top-color: #00260d; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f399..e9c46b71a 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App.jsx"; +import { AuthProvider } from "./contexts/AuthContext.jsx"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")).render( - - + + + + , ); diff --git a/frontend/src/pages/AboutUs.jsx b/frontend/src/pages/AboutUs.jsx new file mode 100644 index 000000000..f6e6dc0b3 --- /dev/null +++ b/frontend/src/pages/AboutUs.jsx @@ -0,0 +1,64 @@ +// About us page presenting the team behind the project +export const AboutUs = () => { + return ( + <> +
+ About us + +
+

About us

+
+

+ We are a couple from different backgrounds, brought together by a + shared vision: to help people make more sustainable food choices. We + believe that eating sustainably shouldn't feel complicated or + overwhelming. By focusing on seasonal and locally sourced + ingredients, we want to make it easier to understand what's in + season and how to cook with it. Our goal is to inspire mindful + everyday cooking through simple guidance, practical recipes, and + thoughtful design — helping sustainability become a natural part of + daily life. +

+
+ +
+
+ Erik +
+

Erik

+

+ Erik has a background in environmental science and works as an + environmental specialist. He loves spending time in the + kitchen and discovering new recipes that support sustainable + eating. +

+
+
+
+ Agnes +
+

Agnes

+

+ Agnes has a background as a social worker and is currently + studying web and digital service development. Unlike Erik, she + prefers cooking to be simple and appreciates easy ways to find + recipes that support sustainable food choices. +

+
+
+
+
+
+ + ); +}; diff --git a/frontend/src/pages/Favorites.jsx b/frontend/src/pages/Favorites.jsx new file mode 100644 index 000000000..093fff1ec --- /dev/null +++ b/frontend/src/pages/Favorites.jsx @@ -0,0 +1,150 @@ +import { useState, useEffect, useContext, useMemo } from "react"; +import { AuthContext } from "../contexts/AuthContext.jsx"; +import { api } from "../utils/api"; +import RecipeCard from "../components/RecipeCard.jsx"; + +// Shows the logged-in user's saved recipes with the same filters as RecipeList +export const Favorites = () => { + const [favorites, setFavorites] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedSeason, setSelectedSeason] = useState("all"); + const [selectedDiet, setSelectedDiet] = useState("all"); + const [selectedAllergy, setSelectedAllergy] = useState("all"); + const [sortBy, setSortBy] = useState("default"); + const [ratingsMap, setRatingsMap] = useState({}); + const { accessToken } = useContext(AuthContext); + + useEffect(() => { + api + .get("/favourites", { + headers: { Authorization: accessToken }, + }) + .then((res) => setFavorites(res.data)) + .catch((err) => console.error(err)); + }, [accessToken]); + + useEffect(() => { + api + .get("/recipes/popular") + .then((res) => { + const map = {}; + res.data.forEach((r) => { + map[r._id] = r.avgRating; + }); + setRatingsMap(map); + }) + .catch((err) => console.error(err)); + }, []); + + const handleDelete = async (recipeId) => { + try { + await api.delete(`/favourites/${recipeId}`, { + headers: { Authorization: accessToken }, + }); + setFavorites((prev) => + prev.filter((fav) => fav.recipeId?._id !== recipeId), + ); + } catch (err) { + alert(err.response?.data?.error || "Could not delete recipe"); + } + }; + + const filteredFavorites = useMemo(() => { + let result = favorites.filter((fav) => fav.recipeId); + + if (selectedSeason !== "all") { + result = result.filter((fav) => fav.recipeId.season === selectedSeason); + } + if (selectedDiet !== "all") { + result = result.filter( + (fav) => fav.recipeId.diet && fav.recipeId.diet.includes(selectedDiet), + ); + } + if (selectedAllergy !== "all") { + result = result.filter( + (fav) => + fav.recipeId.allergies && + !fav.recipeId.allergies.includes(selectedAllergy), + ); + } + result = result.filter((fav) => + fav.recipeId.title.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + return result; + }, [ + favorites, + searchQuery, + selectedSeason, + selectedDiet, + selectedAllergy, + sortBy, + ratingsMap, + ]); + + return ( +
+

Here are your saved favorites!

+
+ setSearchQuery(e.target.value)} + /> +
+
+ + + + + +
+
+ {filteredFavorites.map((fav) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 000000000..5a6ecb061 --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,54 @@ +import { Link } from "react-router-dom"; +import { MdRestaurantMenu } from "react-icons/md"; +import { FaUserCircle } from "react-icons/fa"; +import { useState } from "react"; +import { AuthForm } from "../components/AuthForm"; + +// Landing page with hero image and two call-to-action cards +export const Home = () => { + const [showAuth, setShowAuth] = useState(false); + + return ( + <> +
+ Wheat field + +
+

Welcome to Seasoned

+

+ Here you can learn about sustainable food practices and discover + seasonal recipes that support more sustainable eating. +

+
+
+ +
+ + +

Find Recipes

+

Find recipes instantly — no login required

+ + + +
+ {showAuth && ( + setShowAuth(false)} + onSuccess={() => setShowAuth(false)} + /> + )} + + ); +}; diff --git a/frontend/src/pages/Recipe.jsx b/frontend/src/pages/Recipe.jsx new file mode 100644 index 000000000..edf2b0a22 --- /dev/null +++ b/frontend/src/pages/Recipe.jsx @@ -0,0 +1,81 @@ +import { useParams } from "react-router-dom"; +import { useState, useEffect, useContext } from "react"; +import { api } from "../utils/api"; +import { AuthContext } from "../contexts/AuthContext"; +import Rating from "../components/Rating"; + +// Single recipe page – fetches recipe by ID from URL params and allows saving +export const Recipe = () => { + const { isLoggedIn, accessToken } = useContext(AuthContext); + const { id } = useParams(); + const [recipe, setRecipe] = useState(null); + const [isSaved, setIsSaved] = useState(false); + + useEffect(() => { + api + .get(`/recipes/${id}`) + .then((res) => setRecipe(res.data)) + .catch((err) => console.error(err)); + }, [id]); + + // Optimistic UI: update state immediately, revert if the API call fails + const toggleSave = async () => { + const next = !isSaved; + setIsSaved(next); + + try { + if (next) { + await api.post(`/favourites/${id}`, null, { + headers: { Authorization: accessToken }, + }); + } else { + await api.delete(`/favourites/${id}`, { + headers: { Authorization: accessToken }, + }); + } + } catch (err) { + console.error(err); + setIsSaved(!next); + } + }; + + if (!recipe) return

Loading...

; + return ( +
+ {recipe.title} { + e.target.src = "/images/food.png"; + }} + /> + +
+

{recipe.title}

+

{recipe.season}

+

{recipe.description}

+ +

Ingredients

+
    + {recipe.ingredients.map((item) => ( +
  • {item}
  • + ))} +
+ +

Instructions

+

{recipe.instructions}

+ + + {isLoggedIn && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/RecipeList.jsx b/frontend/src/pages/RecipeList.jsx new file mode 100644 index 000000000..adebaf989 --- /dev/null +++ b/frontend/src/pages/RecipeList.jsx @@ -0,0 +1,189 @@ +import { useState, useEffect, useContext, useMemo, useRef } from "react"; +import { api } from "../utils/api"; +import RecipeCard from "../components/RecipeCard"; +import { AuthContext } from "../contexts/AuthContext.jsx"; + +// Lists all recipes with search, filter by season/diet/allergy, and sort by rating +export const RecipeList = () => { + const [recipes, setRecipes] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedSeason, setSelectedSeason] = useState("all"); + const [selectedDiet, setSelectedDiet] = useState("all"); + const [selectedAllergy, setSelectedAllergy] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState("default"); + const [ratingsMap, setRatingsMap] = useState({}); + + const [savedSet, setSavedSet] = useState(new Set()); + + const { isLoggedIn, accessToken } = useContext(AuthContext); + + const searchRef = useRef(null); + + // Auto-fokusera sökfältet när sidan laddas + useEffect(() => { + searchRef.current?.focus(); + }, []); + + // Hämta recipes + useEffect(() => { + setLoading(true); + const url = + selectedSeason === "all" + ? "/recipes" + : `/recipes?season=${selectedSeason}`; + + api + .get(url) + .then((res) => setRecipes(res.data)) + .catch((err) => console.error(err)) + .finally(() => setLoading(false)); + }, [selectedSeason]); + + // Fetch favourites when logged in + useEffect(() => { + if (!isLoggedIn || !accessToken) { + setSavedSet(new Set()); + return; + } + + api + .get("/favourites", { headers: { Authorization: accessToken } }) // ev: Bearer + .then((res) => { + // res.data antas vara [{ _id, recipeId: { _id, ... } }, ...] + const ids = res.data.map((fav) => fav.recipeId?._id).filter(Boolean); + + setSavedSet(new Set(ids)); + }) + .catch((err) => console.error(err)); + }, [isLoggedIn, accessToken]); + + useEffect(() => { + api + .get("/recipes/popular") + .then((res) => { + const map = {}; + res.data.forEach((r) => { + map[r._id] = r.avgRating; + }); + setRatingsMap(map); + }) + .catch((err) => console.error(err)); + }, []); + + // Filtrering + const filteredRecipes = useMemo(() => { + const filteredByDiet = + selectedDiet === "all" + ? recipes + : recipes.filter((r) => r.diet && r.diet.includes(selectedDiet)); + + const filteredByAllergy = + selectedAllergy === "all" + ? filteredByDiet + : filteredByDiet.filter( + (r) => r.allergies && !r.allergies.includes(selectedAllergy), + ); + const searched = filteredByAllergy.filter((r) => + r.title.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + if (sortBy === "popular") { + searched.sort( + (a, b) => (ratingsMap[b._id] || 0) - (ratingsMap[a._id] || 0), + ); + } else if (sortBy === "least") { + searched.sort( + (a, b) => (ratingsMap[a._id] || 0) - (ratingsMap[b._id] || 0), + ); + } + + return searched; + }, [recipes, selectedDiet, selectedAllergy, searchQuery, sortBy, ratingsMap]); + + return ( +
+

Recipes

+
+ setSearchQuery(e.target.value)} + /> +
+ +
+ + + + + + +
+ + {loading && ( +
+

Loading...

+ + )} + +
+ {filteredRecipes.map((recipe) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/pages/Why.jsx b/frontend/src/pages/Why.jsx new file mode 100644 index 000000000..88c0d619c --- /dev/null +++ b/frontend/src/pages/Why.jsx @@ -0,0 +1,71 @@ +import { seasonData } from "../data/seasonData"; + +// Page explaining sustainable eating, with seasonal food data and an external link +export const Why = () => { + return ( + <> +
+ About us + +
+

Why sustainable eating?

+ +
+
+

+ Eating sustainably helps reduce environmental impact, supports + local farmers, and encourages healthier food choices. By + choosing seasonal ingredients, we rely less on imported food and + energy-intensive farming methods. Small, everyday choices can + make a big difference for the climate, biodiversity, and future + food systems. +

+

+ {" "} + Sometimes it can be difficult to know what types of foods to eat + during each season. Below, we list some typical seasonal foods + that fit the time of year and support sustainable eating. +

+
+
+ +
+ {seasonData.map((s) => ( +
+

{s.season}

+

{s.months}

+ +
+ {s.categories.map((cat) => ( +
+

{cat.label}

+
    + {cat.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+
+ ))} +
+
+

+ Want to learn more about sustainable food practices? Visit the + Naturskyddsföreningen. +

+ + Visit Naturskyddsföreningen + +
+
+
+ + ); +}; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 000000000..ff8263eb5 --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,7 @@ +import axios from "axios"; + +// Shared axios instance with the backend base URL +const api = axios.create({ + baseURL: "https://seasoned-api.onrender.com", +}); +export { api }; diff --git a/package.json b/package.json index 680d19077..40b0e5b8e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,32 @@ "name": "project-final-parent", "version": "1.0.0", "scripts": { - "postinstall": "npm install --prefix backend" + "start": "node server.js", + "dev": "nodemon server.js" + }, + "description": "Replace this readme with your own information about your project.", + "main": "index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/AgnesSj01/project-final.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/AgnesSj01/project-final/issues" + }, + "homepage": "https://github.com/AgnesSj01/project-final#readme", + "dependencies": { + "bcrypt": "^6.0.0", + "cors": "^2.8.6", + "dotenv": "^17.2.4", + "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", + "mongoose": "^9.1.6" + }, + "devDependencies": { + "nodemon": "^3.1.11" } -} \ No newline at end of file +}