From 100afa658eade71640cfdd78da927e1faf6ceeed Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 10:16:20 +0100 Subject: [PATCH 001/127] inital package install --- backend/package.json | 11 +++++++---- frontend/package.json | 6 +++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/package.json b/backend/package.json index 08f29f244..76371a490 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,12 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", - "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", + "bcryptjs": "^3.0.3", + "cors": "^2.8.6", + "dotenv": "^17.2.4", + "express": "^4.22.1", + "jsonwebtoken": "^9.0.3", + "mongoose": "^8.23.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..701d4bc53 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.13.5", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.0", + "styled-components": "^6.3.9", + "zustand": "^5.0.11" }, "devDependencies": { "@types/react": "^18.2.15", From fffbb72ab30bb43285ef457be3eb1ff669d614b9 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 10:27:56 +0100 Subject: [PATCH 002/127] import dotenv --- backend/server.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/server.js b/backend/server.js index 070c87518..d9f06bce2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,8 +1,10 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import dotenv from "dotenv"; +dotenv.config(); -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; +const mongoUrl = process.env.MONGO_URL mongoose.connect(mongoUrl); mongoose.Promise = Promise; From 3cc71e8d0f50bb20ee20f9781172bf71fb38db5e Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 14:32:58 +0100 Subject: [PATCH 003/127] Add API documentation and route setup for user and recipes --- backend/server.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/server.js b/backend/server.js index d9f06bce2..48dbacc56 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,9 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import listEndpoints from "express-list-endpoints"; +import userRoutes from "./routes/user.js"; +import recipeRoutes from "./routes/recipes.js"; import dotenv from "dotenv"; dotenv.config(); @@ -14,10 +17,18 @@ const app = express(); app.use(cors()); app.use(express.json()); +// documentation of the API with express-list-endpoints app.get("/", (req, res) => { - res.send("Hello Technigo!"); + const endpoints = listEndpoints(app); + res.json([{ + message: "Welcome to the Recipe API. Here are the available endpoints:", + endpoints: endpoints + }]); }); +app.use("/user", userRoutes); +app.use("/recipes", recipeRoutes); + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); From f1fef0b90dfeae2c4a9b1ed082eaef1261772c80 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 14:33:15 +0100 Subject: [PATCH 004/127] Add authentication middleware for user validation --- backend/middleware/authMiddleware.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/middleware/authMiddleware.js diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js new file mode 100644 index 000000000..448959e53 --- /dev/null +++ b/backend/middleware/authMiddleware.js @@ -0,0 +1,23 @@ +import User from "../models/User.js"; + +const authenticateUser = async (req, res, next) => { + try { + const user = await User.findOne({ + accessToken: req.header("Authorization").replace("Bearer ", ""), + }); + if (user) { + req.user = user; + next(); + } else { + res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true, + }); + } + } catch (err) { + res + .status(500) + .json({ message: "Internal server error", error: err.message }); + } +}; +export default authenticateUser; \ No newline at end of file From fd9dd2e779e3c7792994f6fb1e28c4d7e3e5cb66 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 14:33:41 +0100 Subject: [PATCH 005/127] Add Recipe model schema --- backend/model/Recipe.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 backend/model/Recipe.js diff --git a/backend/model/Recipe.js b/backend/model/Recipe.js new file mode 100644 index 000000000..297d8a0e7 --- /dev/null +++ b/backend/model/Recipe.js @@ -0,0 +1,28 @@ +import mongoose from "mongoose"; + +const recipeSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + + spoonacularId: { + type: Number, + required: true, + }, + + title: String, + image: String, + ingredients: [String], + instructions: String, + + createdAt: { + type: Date, + default: Date.now, + }, +}); + +const Recipe = mongoose.model("Recipe", recipeSchema); + +export default Recipe; \ No newline at end of file From 7fc6dc1fb142db3844ae5e64130ee9f7bbb48434 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 14:34:13 +0100 Subject: [PATCH 006/127] Add User model schema --- backend/model/User.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 backend/model/User.js diff --git a/backend/model/User.js b/backend/model/User.js new file mode 100644 index 000000000..41aa3233c --- /dev/null +++ b/backend/model/User.js @@ -0,0 +1,24 @@ +import mongoose, { Schema } from "mongoose"; +import crypto from "crypto"; + +const userSchema = new Schema({ + email: { + type: String, + required: true, + unique: true, + }, + password: { + type: String, + required: true, + minlength: 6, + }, + accessToken: { + type: String, + required: true, + default: () => crypto.randomBytes(128).toString("hex"), + }, +}) + +const User = mongoose.model("User", userSchema); + +export default User; \ No newline at end of file From 71cfcf6db84019a9e8d0449411c4928e370678a2 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 14:35:20 +0100 Subject: [PATCH 007/127] Add user authentication routes and forms for login and signup --- backend/routes/userRoutes.js | 90 ++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 backend/routes/userRoutes.js diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 000000000..995030495 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,90 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import User from "../models/User.js"; + +const router = express.Router(); + +//endpoint is /user/signup +// Here we can create a new user +router.post("/signup", async (req, res) => { + try { + const { email, password } = req.body; + + if (!password || password.length < 6) { + return res.status(400).json({ + success: false, + message: "Password must be at least 6 characters.", + }); + } + + + const existingUser = await User.findOne({ email: email.toLowerCase() }); + + if (existingUser) { + return res.status(400).json({ + success: false, + message: "User with this email already exists", + }); + } + + const salt = bcrypt.genSaltSync(); + const hashedPassword = bcrypt.hashSync(password, salt); + const user = new User({email, password: hashedPassword}); + + await user.save(); + + res.status(200).json({ + success: true, + message: "User created successfully", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken, + }, + }); + + } catch (error) { + res.status(400).json({ + success: false, + message: "Failed to create user", + response: error, + }); + } +}); + +// endpoint is /user/login +// Here we can verify email and password and return accessToken +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + + const user = await User.findOne({ email: email.toLowerCase() }); + + if (user && bcrypt.compareSync(password, user.password)) { + res.json({ + success: true, + message: "Login successful", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken, + }, + }); + } else { + res.status(401).json({ + success: false, + message: "Invalid email or password", + response: null, + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Something went wrong", + response: error, + }); + } +}); + +// export the router to be used in server.js +export default router; \ No newline at end of file From 02e6b7157d2d5ba1f769304ebb85d68a588d38c1 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 14:36:17 +0100 Subject: [PATCH 008/127] Add recipe routes for creating, fetching, and searching recipes --- backend/routes/recipeRoutes.js | 140 +++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 backend/routes/recipeRoutes.js diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js new file mode 100644 index 000000000..aa9a9f4d1 --- /dev/null +++ b/backend/routes/recipeRoutes.js @@ -0,0 +1,140 @@ +import express from "express"; +import Recipe from "../model/Recipe.js"; +import mongoose from "mongoose"; +import axios from "axios"; +import authenticateUser from "../middleware/authMiddleware.js"; + +const router = express.Router(); + +// post a new recipe to the database when user clicks on save recipe button in the frontend +router.post("/", authenticateUser, async (req, res) => { + try { + // check if user already liked recipe + const existing = await Recipe.findOne({ + userId: req.user._id, + spoonacularId: req.body.spoonacularId, + }); + + if (existing) { + return res.status(400).json({ + success: false, + message: "Recipe already saved", + response: null, + }); + } + + const recipe = new Recipe({ + ...req.body, + userId: req.user._id, + }); + + await recipe.save(); + + res.status(201).json({ + success: true, + message: "Recipe saved successfully", + response: recipe, + }); + } catch (err) { + res.status(500).json({ + success: false, + message: "Failed to save recipe", + response: err.message || err, + }); + } +}); + + +// Get all recipes +router.get('/', authenticateUser, async (req, res) => { + try { + const recipes = await Recipe.find({ userId: req.user._id }); + res.status(200).json({ + success: true, + message: "Recipes fetched successfully", + response: recipes, + }); + } catch (err) { + res.status(500).json({ + success: false, + message: "Failed to fetch recipes", + response: err.message || err, + }); + } +}); + +// get recipes by id + +router.get("/:id", authenticateUser, async (req, res) => { + const { id } = req.params; + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + message: "Invalid ID format", + response: null, + }); + } + const recipe = await Recipe.findById(id); + if (!recipe) { + return res.status(404).json({ + success: false, + message: "Recipe not found", + response: null, + }); + } + res.status(200).json({ + success: true, + message: "Recipe found", + response: recipe, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "Recipe couldn't be found", + response: error.message || error, + }); + } +}); + + +// search for recipes based on ingredients and mode (allowExtra or exact) + +router.get("/search", authenticateUser, async (req, res) => { + const { ingredients, mode } = req.query; + try { + const params = { + includeIngredients: ingredients, + number: 10, + instructionsRequired: true, + addRecipeInformation: true, + apiKey: process.env.SPOONACULAR_API_KEY, + }; + if (mode === "exact") { + params.fillIngredients = false; + params.ignorePantry = true; + } + const response = await axios.get( + "https://api.spoonacular.com/recipes/complexSearch", + { params } + ); + + + res.status(200).json({ + success: true, + message: "Recipes fetched from Spoonacular", + response: response.data, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "Failed to fetch recipes from Spoonacular", + response: error.message || error, + }); + } +}); + + + + +export default router; \ No newline at end of file From ff5be9d49aa6eb50da0849f14a07ee35a2527bf1 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 14:37:04 +0100 Subject: [PATCH 009/127] Update package.json --- backend/package.json | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/backend/package.json b/backend/package.json index 76371a490..b2b0f5e2c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,18 +6,22 @@ "start": "babel-node server.js", "dev": "nodemon server.js --exec babel-node" }, - "author": "", "license": "ISC", + "author": "", + "type": "commonjs", + "main": "server.js", + "dependencies": { "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", - "bcryptjs": "^3.0.3", - "cors": "^2.8.6", - "dotenv": "^17.2.4", + "bcrypt": "^6.0.0", + "cors": "^2.8.5", + "dotenv": "^17.2.3", "express": "^4.22.1", - "jsonwebtoken": "^9.0.3", - "mongoose": "^8.23.0", - "nodemon": "^3.0.1" + "express-list-endpoints": "^7.1.1", + "mongodb": "^7.0.0", + "mongoose": "^9.1.5", + "nodemon": "^3.1.11" } } From 540eea751004961177df65fec6e7791c6aebb281 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 16:00:44 +0100 Subject: [PATCH 010/127] Fix import paths for user and recipe routes in server.js --- backend/package.json | 1 - backend/server.js | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/package.json b/backend/package.json index b2b0f5e2c..a3737ed69 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,6 @@ "author": "", "type": "commonjs", "main": "server.js", - "dependencies": { "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", diff --git a/backend/server.js b/backend/server.js index 48dbacc56..8c5451f79 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2,8 +2,8 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; import listEndpoints from "express-list-endpoints"; -import userRoutes from "./routes/user.js"; -import recipeRoutes from "./routes/recipes.js"; +import userRoutes from "./routes/userRoutes.js"; +import recipeRoutes from "./routes/recipeRoutes.js"; import dotenv from "dotenv"; dotenv.config(); From f6da433596eb9289aa55c87a7507e4097aacd947 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 17:40:29 +0100 Subject: [PATCH 011/127] Fix import paths for User model in authMiddleware and userRoutes --- backend/middleware/authMiddleware.js | 2 +- backend/routes/userRoutes.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js index 448959e53..9a918a614 100644 --- a/backend/middleware/authMiddleware.js +++ b/backend/middleware/authMiddleware.js @@ -1,4 +1,4 @@ -import User from "../models/User.js"; +import User from "../model/User.js"; const authenticateUser = async (req, res, next) => { try { diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index 995030495..ccf59e086 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -1,6 +1,6 @@ import express from "express"; import bcrypt from "bcrypt"; -import User from "../models/User.js"; +import User from "../model/User.js"; const router = express.Router(); From e17a7bf1fe8a3755f9db88392c59119084c6afef Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 17:43:34 +0100 Subject: [PATCH 012/127] Add svg icon --- frontend/index.html | 6 +++--- frontend/public/recipe3.png | Bin 0 -> 23917 bytes 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 frontend/public/recipe3.png diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..4522ef0ad 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - + - Technigo React Vite Boiler Plate + PantryMatch
diff --git a/frontend/public/recipe3.png b/frontend/public/recipe3.png new file mode 100644 index 0000000000000000000000000000000000000000..4ee13d8f5c913145344baea833eb12599ff896bf GIT binary patch literal 23917 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 kC}y51`TzeK2dFd(T=vlKHrww3t8|dLu{FH Date: Tue, 10 Feb 2026 17:44:02 +0100 Subject: [PATCH 013/127] Add API_URL constant --- frontend/src/api.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 frontend/src/api.js diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 000000000..6b542e366 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1 @@ +export const API_URL = 'https://pantrymatch.onrender.com' \ No newline at end of file From a52a224312a9acd41a5bc2749c7aa7fa5a63d955 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 17:44:47 +0100 Subject: [PATCH 014/127] Add axios dependency to package.json --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 680d19077..ce02aa3ce 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,8 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "axios": "^1.13.5" } -} \ No newline at end of file +} From 974c59ec0ea049bdac1bff3c876b9f902e3dfe47 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 17:45:39 +0100 Subject: [PATCH 015/127] Add LoginForm component with styled elements and authentication --- frontend/src/components/LoginForm.jsx | 161 ++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 frontend/src/components/LoginForm.jsx diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 000000000..6afefd645 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,161 @@ + +import { useState } from "react"; +import { API_URL } from "../api"; +import styled from "styled-components"; + +// Styled Components +const StyledForm = styled.form` + background: #fff; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + max-width: 350px; + margin: 2rem auto; +`; + +const StyledHeading = styled.h2` + text-align: center; + margin-bottom: 1.5rem; +`; + +const InputsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const StyledLabel = styled.label` + display: flex; + flex-direction: column; + font-weight: 500; +`; + +const StyledInput = styled.input` + padding: 0.5rem; + margin-top: 0.3rem; + border-radius: 4px; + border: 1px solid #ccc; + font-size: 1rem; +`; + +const StyledButton = styled.button` + background: #2e8b57; + color: #fff; + border: none; + padding: 0.7rem 1.2rem; + border-radius: 4px; + cursor: pointer; + margin-top: 1rem; + font-size: 1rem; + transition: background 0.2s; + &:hover { + background: #256b45; + } +`; + +const AuthActions = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 0.7rem; +`; + +const ToggleAuth = styled.span` + color: #2e8b57; + cursor: pointer; + font-size: 0.95rem; + text-decoration: underline; +`; + +const ErrorMessage = styled.p` + color: #c0392b; + text-align: center; + margin-top: 1rem; +`; + + +export const LoginForm = ({ handleLogin, onToggleAuth }) => { + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + const [error, setError] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.email || !formData.password) { + setError("Please fill in both fields"); + return; + } + + setError(""); + + try { + const response = await fetch(`${API_URL}/user/login`, { + method: "POST", + body: JSON.stringify({ + email: formData.email, + password: formData.password, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + + handleLogin(data.response); + //reset form + e.target.reset(); + + } catch (error) { + setError("Invalid email or password"); + console.log(error); + } + }; + + // Handle input changes + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prevFormData) => ({ ...prevFormData, [name]: value })); + }; + + return ( + + Log in + + + Email + + + + Password + + + + {error && {error}} + + Log In + + Don't have an account? Sign up + + + + ); +}; \ No newline at end of file From 069eae6d6d005ee4fe63399c2908d07a3cfa0642 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 17:46:11 +0100 Subject: [PATCH 016/127] Add SignupForm component with form handling and validation --- frontend/src/components/SignupForm.jsx | 162 +++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 frontend/src/components/SignupForm.jsx diff --git a/frontend/src/components/SignupForm.jsx b/frontend/src/components/SignupForm.jsx new file mode 100644 index 000000000..d8e7af2af --- /dev/null +++ b/frontend/src/components/SignupForm.jsx @@ -0,0 +1,162 @@ + +import { useState } from "react"; +import { API_URL } from "../api"; +import styled from "styled-components"; + + +const StyledForm = styled.form` + background: #fff; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + max-width: 350px; + margin: 2rem auto; +`; + +const StyledHeading = styled.h2` + text-align: center; + margin-bottom: 1.5rem; +`; + +const InputsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const StyledLabel = styled.label` + display: flex; + flex-direction: column; + font-weight: 500; +`; + +const StyledInput = styled.input` + padding: 0.5rem; + margin-top: 0.3rem; + border-radius: 4px; + border: 1px solid #ccc; + font-size: 1rem; +`; + +const StyledButton = styled.button` + background: #2e8b57; + color: #fff; + border: none; + padding: 0.7rem 1.2rem; + border-radius: 4px; + cursor: pointer; + margin-top: 1rem; + font-size: 1rem; + transition: background 0.2s; + &:hover { + background: #256b45; + } +`; + +const AuthActions = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 0.7rem; +`; + +const ToggleAuth = styled.span` + color: #2e8b57; + cursor: pointer; + font-size: 0.95rem; + text-decoration: underline; +`; + +const ErrorMessage = styled.p` + color: #c0392b; + text-align: center; + margin-top: 1rem; +`; + +export const SignupForm = ({ handleLogin, onToggleAuth }) => { + const [error, setError] = useState(""); + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.email || !formData.password) { + setError("Please fill in both fields"); + return; + } + + try { + const response = await fetch(`${API_URL}/user/signup`, { + method: "POST", + body: JSON.stringify({ + email: formData.email, + password: formData.password, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + + if (!response.ok && response.status > 499) { + throw new Error("Server error."); + } + + const resJson = await response.json(); + + if (!resJson.success) { + throw new Error(resJson.message || "Failed to create user"); + } + + handleLogin(resJson.response); + // Reset form + e.target.reset(); + + } catch (error) { + setError(error.message); + console.log("error occurred during signup", error); + } + }; + + // Handle input changes + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prevFormData) => ({ ...prevFormData, [name]: value })); + }; + + return ( + + Sign up + + + Email + + + + Password + + + + {error && {error}} + + Sign up + + Already have an account? Log in + + + + ); +}; \ No newline at end of file From 4ebe12976032d4edb731dce1539b8218a3dcaed7 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 10 Feb 2026 18:13:00 +0100 Subject: [PATCH 017/127] Add axios dependency to package.json --- backend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/package.json b/backend/package.json index a3737ed69..0932fd6b3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "axios": "^1.13.5", "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^17.2.3", From d5e8df1cb6ce73e0ab1abb17e2c91c84b9edfe36 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 11:29:08 +0100 Subject: [PATCH 018/127] Remove authentication requirement for some recipe routes --- backend/routes/recipeRoutes.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index aa9a9f4d1..b85b02a10 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -46,7 +46,7 @@ router.post("/", authenticateUser, async (req, res) => { // Get all recipes -router.get('/', authenticateUser, async (req, res) => { +router.get('/', async (req, res) => { try { const recipes = await Recipe.find({ userId: req.user._id }); res.status(200).json({ @@ -65,7 +65,7 @@ router.get('/', authenticateUser, async (req, res) => { // get recipes by id -router.get("/:id", authenticateUser, async (req, res) => { +router.get("/:id", async (req, res) => { const { id } = req.params; try { if (!mongoose.Types.ObjectId.isValid(id)) { @@ -100,7 +100,7 @@ router.get("/:id", authenticateUser, async (req, res) => { // search for recipes based on ingredients and mode (allowExtra or exact) -router.get("/search", authenticateUser, async (req, res) => { +router.get("/search", async (req, res) => { const { ingredients, mode } = req.query; try { const params = { From 2de5846f5e28c46da2b9bfafb50270d231d88c32 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 11:30:02 +0100 Subject: [PATCH 019/127] Add user store with Zustand --- frontend/src/stores/userStore.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 frontend/src/stores/userStore.js diff --git a/frontend/src/stores/userStore.js b/frontend/src/stores/userStore.js new file mode 100644 index 000000000..4fdfa8afa --- /dev/null +++ b/frontend/src/stores/userStore.js @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +export const useUserStore = create((set) => ({ + + user: JSON.parse(localStorage.getItem('user')) || null, + + setUser: (user) => { + localStorage.setItem('user', JSON.stringify(user)); + set({ user }); + }, + + logout: () => { + localStorage.removeItem('user'); + set({ user: null }); + }, + +})); From b7d89156f43049e70462eaacc2fe62f568461fa5 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 11:33:38 +0100 Subject: [PATCH 020/127] create nav skeleton --- frontend/src/components/Navigation.jsx | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 frontend/src/components/Navigation.jsx diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx new file mode 100644 index 000000000..1279238ca --- /dev/null +++ b/frontend/src/components/Navigation.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import styled from "styled-components"; +import { Link } from "react-router-dom"; + +const NavContainer = styled.nav` + display: flex; + justify-content: center; + align-items: center; + background: #f8f8f8; + padding: 1rem 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.03); +`; + +const NavList = styled.ul` + display: flex; + gap: 2rem; + list-style: none; + margin: 0; + padding: 0; +`; + +const NavItem = styled.li` + font-size: 1.1rem; +`; + +const NavLink = styled(Link)` + background: none; + border: none; + color: #333; + font-weight: 600; + cursor: pointer; + font-size: 1.1rem; + text-decoration: none; + transition: color 0.2s; + &:hover { + color: #4e8cff; + } +`; + +const Navigation = () => ( + + + + Home + + + Saved Recipes + + + About this app + + + +); + +export default Navigation; From e4c65b03ec8c5e4d0c0f88add071e6a697a6bbde Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 17:51:28 +0100 Subject: [PATCH 021/127] Add recipe store using Zustand --- frontend/src/stores/recipeStore.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 frontend/src/stores/recipeStore.js diff --git a/frontend/src/stores/recipeStore.js b/frontend/src/stores/recipeStore.js new file mode 100644 index 000000000..2696457e6 --- /dev/null +++ b/frontend/src/stores/recipeStore.js @@ -0,0 +1,24 @@ +import { create } from "zustand"; + +export const useRecipeStore = create((set) => ({ + + ingredients: [], + recipes: [], + loading: false, + error: null, + + addIngredient: (ing) => + set((state) => ({ + ingredients: [...state.ingredients, ing], + })), + + removeIngredient: (ing) => + set((state) => ({ + ingredients: state.ingredients.filter((i) => i !== ing), + })), + + setRecipes: (recipes) => set({ recipes }), + setLoading: (loading) => set({ loading }), + setError: (error) => set({ error }), + +})); From 31080575a6387d5fec8165d2b469824e5a472882 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 17:52:07 +0100 Subject: [PATCH 022/127] Add Home component with login/signup form --- frontend/src/pages/home.jsx | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 frontend/src/pages/home.jsx diff --git a/frontend/src/pages/home.jsx b/frontend/src/pages/home.jsx new file mode 100644 index 000000000..3dd5fba16 --- /dev/null +++ b/frontend/src/pages/home.jsx @@ -0,0 +1,60 @@ +import { LoginForm } from '../components/LoginForm'; +import { SignupForm } from '../components/SignupForm'; +import styled from "styled-components"; + + +const Intro = styled.div` + margin-top: 3rem; + text-align: center; + h1 { + font-size: 2.2rem; + margin-bottom: 0.5rem; + } + p { + color: #555; + font-size: 1.1rem; + } +`; + +const AuthContainer = styled.div` + margin-top: 2rem; +`; + +const LogoutButton = styled.button` + background: #c0392b; + color: #fff; + border: none; + padding: 0.7rem 1.2rem; + border-radius: 4px; + cursor: pointer; + margin-top: 2rem; + font-size: 1rem; + transition: background 0.2s; + &:hover { + background: #a93226; + } +`; + +const Home = ({ user, isSigningUp, setIsSigningUp, handleLogin, handleLogout }) => { + return ( + <> + +

What to cook?

+

Sign up or log in and start discovering recipes based on what you have at home

+
+ {user ? ( + Logout + ) : ( + + {isSigningUp ? ( + setIsSigningUp(false)} /> + ) : ( + setIsSigningUp(true)} /> + )} + + )} + + ); +}; + +export default Home; From 8300e5c33a54a1b3a4684147317581acd9b7f797 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 17:53:06 +0100 Subject: [PATCH 023/127] add routing and user state management --- frontend/src/App.jsx | 55 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..69c060e9e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,57 @@ +import { useState } from "react"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import Navigation from "./components/Navigation"; +import Home from "./pages/home"; +import { useUserStore } from "./stores/userStore"; +import About from "./pages/about"; + + export const App = () => { + const { user, setUser, logout } = useUserStore(); + const [isSigningUp, setIsSigningUp] = useState(false); + + return ( - <> -

Welcome to Final Project!

- + + + + + + + + } + /> + + + {/* Protected route */} + Saved Recipes : + } + + /> + + + + } + /> + + + + ); }; From 6f7e54f9aab3a9b766f72a0986c9dcd29bce4d93 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 17:53:30 +0100 Subject: [PATCH 024/127] add framer-motion dependency to package.json --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ce02aa3ce..a0f62cae1 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "postinstall": "npm install --prefix backend" }, "dependencies": { - "axios": "^1.13.5" + "axios": "^1.13.5", + "framer-motion": "^12.34.0" } } From 8239b31a8f689b5e2d0d587efb08e4f904a2501c Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 17:54:26 +0100 Subject: [PATCH 025/127] add About page skeleton with basic design and framer motion animation --- frontend/src/pages/about.jsx | 126 +++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 frontend/src/pages/about.jsx diff --git a/frontend/src/pages/about.jsx b/frontend/src/pages/about.jsx new file mode 100644 index 000000000..15b8463db --- /dev/null +++ b/frontend/src/pages/about.jsx @@ -0,0 +1,126 @@ +import styled from "styled-components"; +import { motion } from "framer-motion"; + +const Container = styled(motion.div)` + max-width: 900px; + margin: 3rem auto; + padding: 2rem; + background: #ffffff; + border-radius: 1.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); +`; + +const Title = styled.h1` + text-align: center; + font-size: 2.5rem; + margin-bottom: 1rem; + color: #2e8b57; +`; + +const Subtitle = styled.p` + text-align: center; + font-size: 1.1rem; + color: #666; + margin-bottom: 2.5rem; +`; + +const Section = styled.section` + margin-bottom: 2.5rem; +`; + +const SectionTitle = styled.h2` + font-size: 1.6rem; + margin-bottom: 0.8rem; + color: #333; +`; + +const Text = styled.p` + line-height: 1.7; + color: #444; + font-size: 1rem; +`; + +const FeatureList = styled.ul` + list-style: none; + padding: 0; + margin-top: 1rem; +`; + +const FeatureItem = styled.li` + margin-bottom: 0.6rem; + font-size: 1rem; + color: #444; + + &::before { + content: "✔ "; + color: #2e8b57; + font-weight: bold; + } +`; + +const Footer = styled.div` + text-align: center; + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid #eee; + color: #777; + font-size: 0.95rem; +`; + +const About = () => { + return ( + + About PantryMatch + Find recipes with what you already have at home + +
+ Our Mission + + PantryMatch helps home cooks reduce food waste and save time by + suggesting recipes based on ingredients they already have. Our goal + is to make cooking simple, fun, and accessible for everyone. + +
+ +
+ How It Works + + Simply enter the ingredients you have in your kitchen, choose your + preferred search mode, and get instant recipe suggestions. You can + explore full cooking instructions and save your favorite recipes for + later. + +
+ +
+ Main Features + + Search recipes by ingredients + Filter: exact match or allow extra ingredients + View full recipe instructions + Save favorite recipes (for logged-in users) + Personal recipe history + +
+ +
+ Why PantryMatch? + + Many people struggle with deciding what to cook and often waste food. + PantryMatch solves this problem by turning your available ingredients + into delicious meal ideas in seconds. + +
+ +
+

© 2026 PantryMatch

+
+
+ ); +}; + +export default About; From 0eb454367f0f0867336c2e66cd8bca26facff1f8 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 20:35:20 +0100 Subject: [PATCH 026/127] add Member page/router and moved authentication forms and styling from app --- frontend/src/App.jsx | 22 ++++++------- frontend/src/pages/member.jsx | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/member.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 69c060e9e..088ed907d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import Navigation from "./components/Navigation"; +import Member from "./pages/member"; import Home from "./pages/home"; import { useUserStore } from "./stores/userStore"; import About from "./pages/about"; @@ -14,15 +15,18 @@ export const App = () => { return ( - - + } + /> + { } /> - - {/* Protected route */} + {/* Protected/Auth route */} Saved Recipes : + user ?

Saved Recipes

: } - /> - } + element={} /> -
+
); }; diff --git a/frontend/src/pages/member.jsx b/frontend/src/pages/member.jsx new file mode 100644 index 000000000..1467a5503 --- /dev/null +++ b/frontend/src/pages/member.jsx @@ -0,0 +1,60 @@ +import { LoginForm } from '../components/LoginForm'; +import { SignupForm } from '../components/SignupForm'; +import styled from "styled-components"; + + +const Intro = styled.div` + margin-top: 3rem; + text-align: center; + h1 { + font-size: 2.2rem; + margin-bottom: 0.5rem; + } + p { + color: #555; + font-size: 1.1rem; + } +`; + +const AuthContainer = styled.div` + margin-top: 2rem; +`; + +const LogoutButton = styled.button` + background: #c0392b; + color: #fff; + border: none; + padding: 0.7rem 1.2rem; + border-radius: 4px; + cursor: pointer; + margin-top: 2rem; + font-size: 1rem; + transition: background 0.2s; + &:hover { + background: #a93226; + } +`; + +const Member = ({ user, isSigningUp, setIsSigningUp, handleLogin, handleLogout }) => { + return ( + <> + +

What to cook?

+

Sign up or log in and start discovering recipes based on what you have at home

+
+ {user ? ( + Logout + ) : ( + + {isSigningUp ? ( + setIsSigningUp(false)} /> + ) : ( + setIsSigningUp(true)} /> + )} + + )} + + ); +}; + +export default Member; From df02348cf1f2e186efe7c5f3e1ede4ae7fd8c465 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 20:36:06 +0100 Subject: [PATCH 027/127] update About link text and ensure Login link connect to /member page --- frontend/src/components/Navigation.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index 1279238ca..b7a4f9c8a 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -1,4 +1,3 @@ -import React from "react"; import styled from "styled-components"; import { Link } from "react-router-dom"; @@ -47,7 +46,10 @@ const Navigation = () => ( Saved Recipes - About this app + About + + + Login From 76e316dd321cfe3ae1347b6f91ac4411002fdfeb Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 20:37:09 +0100 Subject: [PATCH 028/127] add mode management to recipe store(allow extra or exact ingredient) --- frontend/src/stores/recipeStore.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/stores/recipeStore.js b/frontend/src/stores/recipeStore.js index 2696457e6..500ad5450 100644 --- a/frontend/src/stores/recipeStore.js +++ b/frontend/src/stores/recipeStore.js @@ -1,12 +1,15 @@ import { create } from "zustand"; -export const useRecipeStore = create((set) => ({ +export const useRecipeStore = create((set) => ({ ingredients: [], recipes: [], + mode: "allowExtra", loading: false, error: null, + setMode: (mode) => set({ mode }), + addIngredient: (ing) => set((state) => ({ ingredients: [...state.ingredients, ing], @@ -20,5 +23,5 @@ export const useRecipeStore = create((set) => ({ setRecipes: (recipes) => set({ recipes }), setLoading: (loading) => set({ loading }), setError: (error) => set({ error }), - -})); + clearRecipes: () => set({ recipes: [] }), +})); \ No newline at end of file From b39472a4c10e4874c3692c5a6a3b168e0ea93886 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 20:38:37 +0100 Subject: [PATCH 029/127] import search recipe and moved toggle signup/login from home component to member component --- frontend/src/pages/home.jsx | 75 +++++++++++++------------------------ 1 file changed, 27 insertions(+), 48 deletions(-) diff --git a/frontend/src/pages/home.jsx b/frontend/src/pages/home.jsx index 3dd5fba16..cb9aa8de7 100644 --- a/frontend/src/pages/home.jsx +++ b/frontend/src/pages/home.jsx @@ -1,59 +1,38 @@ -import { LoginForm } from '../components/LoginForm'; -import { SignupForm } from '../components/SignupForm'; +import SearchRecipe from "../components/SearchRecipe"; import styled from "styled-components"; - -const Intro = styled.div` - margin-top: 3rem; - text-align: center; - h1 { - font-size: 2.2rem; - margin-bottom: 0.5rem; - } - p { - color: #555; - font-size: 1.1rem; - } +const Container = styled.div` + max-width: 900px; + margin: 3rem auto; + padding: 2rem; + background: #ffffff; + border-radius: 1.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + `; -const AuthContainer = styled.div` - margin-top: 2rem; +const Title = styled.h1` + text-align: center; + font-size: 2.5rem; + margin-bottom: 1rem; + color: #2e8b57; `; -const LogoutButton = styled.button` - background: #c0392b; - color: #fff; - border: none; - padding: 0.7rem 1.2rem; - border-radius: 4px; - cursor: pointer; - margin-top: 2rem; - font-size: 1rem; - transition: background 0.2s; - &:hover { - background: #a93226; - } +const Subtitle = styled.p` + text-align: center; + font-size: 1.1rem; + color: #666; + margin-bottom: 2.5rem; + `; - -const Home = ({ user, isSigningUp, setIsSigningUp, handleLogin, handleLogout }) => { +const Home = () => { return ( - <> - -

What to cook?

-

Sign up or log in and start discovering recipes based on what you have at home

-
- {user ? ( - Logout - ) : ( - - {isSigningUp ? ( - setIsSigningUp(false)} /> - ) : ( - setIsSigningUp(true)} /> - )} - - )} - + + What to cook? + Discover recipes based on what you have at home + + + ); }; From 7af0126df7aab3bfc42331292e3b4f2c73dd0adb Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 11 Feb 2026 20:39:35 +0100 Subject: [PATCH 030/127] add SearchRecipe component with input and filter without functionality, only basic design/skeleton --- frontend/src/components/SearchRecipe.jsx | 127 +++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 frontend/src/components/SearchRecipe.jsx diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx new file mode 100644 index 000000000..9735471a6 --- /dev/null +++ b/frontend/src/components/SearchRecipe.jsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import styled from "styled-components"; + +const Section = styled.section` + max-width: 700px; + margin: 0 auto; + +`; + + +const Form = styled.form` + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 2rem; +`; + +const Input = styled.input` + padding: 0.5rem 1rem; + border-radius: 4px; + border: 1px solid #ccc; + font-size: 1rem; + min-width: 350px; +`; + +const AddButton = styled.button` + background: #2e8b57; + color: #fff; + border: none; + padding: 0.5rem 1.2rem; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + margin-left: 0.5rem; + transition: background 0.2s; + &:hover { + background: #119a65; + } +`; + +const FilterSection = styled.div` + margin-bottom: 2rem; + background: #f7f7f7; + border-radius: 8px; + padding: 1.2rem 1rem; +`; + +const FilterTitle = styled.div` + font-weight: 600; + font-size: 1.1rem; + color: #222; + margin-bottom: 1rem; +`; + +const FilterLabel = styled.label` + display: flex; + align-items: center; + font-size: 1rem; + margin-bottom: 0.5rem; +`; + +const Radio = styled.input` + margin-right: 0.5rem; +`; + +const ShowButton = styled.button` + background: #2e8b57; + color: #fff; + border: none; + padding: 0.7rem 2rem; + border-radius: 4px; + cursor: pointer; + font-size: 1.1rem; + margin-top: 1rem; + box-shadow: 0 4px 16px rgba(46,139,87,0.15); + transition: background 0.2s; + &:hover { + background: #119a65; + } +`; + +const SearchRecipe = () => { + const [input, setInput] = useState(""); + + return ( +
+
+ setInput(e.target.value)} + /> + + Add + + +
+ + Filter + + + Use only these ingredients (no extras allowed) + + + + Allow extra ingredients (recipe may contain more) + + + + + Show recipes + +
+ ); +}; + +export default SearchRecipe; From c1c487a36fe504c2b4502940647a9fd4af149e9d Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Thu, 12 Feb 2026 16:35:39 +0100 Subject: [PATCH 031/127] Add APi functions for recipes: search, details, save, fetch saved, and delete --- frontend/src/api.js | 1 - frontend/src/api/api.js | 70 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) delete mode 100644 frontend/src/api.js create mode 100644 frontend/src/api/api.js diff --git a/frontend/src/api.js b/frontend/src/api.js deleted file mode 100644 index 6b542e366..000000000 --- a/frontend/src/api.js +++ /dev/null @@ -1 +0,0 @@ -export const API_URL = 'https://pantrymatch.onrender.com' \ No newline at end of file diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js new file mode 100644 index 000000000..ad7d6f33d --- /dev/null +++ b/frontend/src/api/api.js @@ -0,0 +1,70 @@ + +export const API_URL = 'https://pantrymatch.onrender.com'; + +// Helper to handle fetch responses +async function handleResponse(response) { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `API error: ${response.status}`); + } + return response.json(); +} + +// Fetch recipes by ingredients from backend API +export async function fetchRecipeByIngredients(ingredients, mode = "allowExtra") { + if (!ingredients || ingredients.length < 2) { + throw new Error("Please add at least 2 ingredients."); + } + // Create query string + const query = ingredients.join(","); + const params = new URLSearchParams({ ingredients: query, mode }); + const res = await fetch(`/api/recipes/search?${params}`); + const data = await handleResponse(res); + return data.response; +} + +// Fetch recipe details by ID from Spoonacular API (public) +export async function fetchRecipeDetails(recipeId) { + const API_KEY = import.meta.env.VITE_SPOONACULAR_API_KEY; + const url = `https://api.spoonacular.com/recipes/${recipeId}/information?apiKey=${API_KEY}`; + const res = await fetch(url); + if (!res.ok) + throw new Error(`Failed to fetch recipe details: ${res.status}`); + return await res.json(); +} + +// Save a recipe to the database for logged-in user (auth required) +export async function saveRecipe(recipeData, token) { + const res = await fetch('/api/recipes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(recipeData), + }); + return handleResponse(res); +} + +// Get all saved recipes for logged-in user (auth required) +export async function getSavedRecipes(token) { + const res = await fetch('/api/recipes', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const data = await handleResponse(res); + return data.response; +} + +// Delete a saved recipe by ID (auth required) +export async function deleteRecipe(recipeId, token) { + const res = await fetch(`/api/recipes/${recipeId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return handleResponse(res); +} \ No newline at end of file From 81fe4482178d4b9eea0c07cff83225d8779bfe8a Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Thu, 12 Feb 2026 16:38:14 +0100 Subject: [PATCH 032/127] Refactor recipe routes: add ingredient search with filter, improve error handling, protect saved recipes endpoints --- backend/routes/recipeRoutes.js | 171 ++++++++++++++++++++++----------- 1 file changed, 116 insertions(+), 55 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index b85b02a10..84256c2f7 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -6,47 +6,82 @@ import authenticateUser from "../middleware/authMiddleware.js"; const router = express.Router(); -// post a new recipe to the database when user clicks on save recipe button in the frontend -router.post("/", authenticateUser, async (req, res) => { - try { - // check if user already liked recipe - const existing = await Recipe.findOne({ - userId: req.user._id, - spoonacularId: req.body.spoonacularId, - }); - if (existing) { - return res.status(400).json({ - success: false, - message: "Recipe already saved", - response: null, - }); - } +// (GET)Search for recipes based on ingredients and mode (allowExtra or exact) +router.get("/search", async (req, res) => { + const { ingredients, mode } = req.query; - const recipe = new Recipe({ - ...req.body, - userId: req.user._id, + if (!ingredients) { + return res.status(400).json({ + success: false, + message: "Ingredients are required", + response: null, }); + } - await recipe.save(); + // split ingredients by comma and trim whitespace, also filter out empty strings + const ingredientList = ingredients.split(",").map(i => i.trim()).filter(Boolean); + + if (ingredientList.length < 2) { + return res.status(400).json({ + success: false, + message: "Please provide at least 2 ingredients", + response: null, + }); + } - res.status(201).json({ + // ingredient list for the API call + try { + const params = { + includeIngredients: ingredients, + number: 15, + instructionsRequired: true, + addRecipeInformation: true, + ranking: 2, // 1 = maximize used ingredients, 2 = minimize missing ingredients + apiKey: process.env.SPOONACULAR_API_KEY, + }; + + if (mode === "exact") { + params.fillIngredients = false; + params.ignorePantry = false; + } + + const response = await axios.get( + "https://api.spoonacular.com/recipes/complexSearch", + { params } + ); + + // Extract recipes from response, handle case where data or results might be missing + const recipes = response.data.results || []; + + // Format the recipes to only include necessary fields for the frontend + const formattedRecipes = recipes.map(recipe => ({ + id: recipe.id, + title: recipe.title, + image: recipe.image, + usedIngredients: recipe.usedIngredients || [], + missedIngredients: recipe.missedIngredients || [], + likes: recipe.aggregateLikes || 0, + readyInMinutes: recipe.readyInMinutes, + servings: recipe.servings, + })); + + res.status(200).json({ success: true, - message: "Recipe saved successfully", - response: recipe, + message: "Recipes fetched from Spoonacular", + response: formattedRecipes, }); - } catch (err) { + } catch (error) { res.status(500).json({ success: false, - message: "Failed to save recipe", - response: err.message || err, + message: "Failed to fetch recipes from Spoonacular", + response: error.response?.data || error.message, }); } }); - -// Get all recipes -router.get('/', async (req, res) => { +// (GET) Get all saved recipes for logged-in user +router.get("/", authenticateUser, async (req, res) => { try { const recipes = await Recipe.find({ userId: req.user._id }); res.status(200).json({ @@ -63,8 +98,7 @@ router.get('/', async (req, res) => { } }); -// get recipes by id - +// (GET) Get recipe by ID router.get("/:id", async (req, res) => { const { id } = req.params; try { @@ -97,44 +131,71 @@ router.get("/:id", async (req, res) => { } }); +// (POST) Post a new recipe to the database when user clicks save recipe button +router.post("/", authenticateUser, async (req, res) => { + try { + // Check if user already saved this recipe + const existing = await Recipe.findOne({ + userId: req.user._id, + spoonacularId: req.body.spoonacularId, + }); -// search for recipes based on ingredients and mode (allowExtra or exact) + if (existing) { + return res.status(400).json({ + success: false, + message: "Recipe already saved", + response: null, + }); + } -router.get("/search", async (req, res) => { - const { ingredients, mode } = req.query; + const recipe = new Recipe({ + ...req.body, + userId: req.user._id, + }); + + await recipe.save(); + + res.status(201).json({ + success: true, + message: "Recipe saved successfully", + response: recipe, + }); + } catch (err) { + res.status(500).json({ + success: false, + message: "Failed to save recipe", + response: err.message || err, + }); + } +}); + +// (DELETE) Delete a saved recipe +router.delete("/:id", authenticateUser, async (req, res) => { try { - const params = { - includeIngredients: ingredients, - number: 10, - instructionsRequired: true, - addRecipeInformation: true, - apiKey: process.env.SPOONACULAR_API_KEY, - }; - if (mode === "exact") { - params.fillIngredients = false; - params.ignorePantry = true; - } - const response = await axios.get( - "https://api.spoonacular.com/recipes/complexSearch", - { params } - ); + const recipe = await Recipe.findOneAndDelete({ + _id: req.params.id, + userId: req.user._id, // make sure user can only delete their own saved recipes + }); + if (!recipe) { + return res.status(404).json({ + success: false, + message: "Recipe not found or you don't have permission to delete it", + }); + } res.status(200).json({ success: true, - message: "Recipes fetched from Spoonacular", - response: response.data, + message: "Recipe deleted successfully", + response: recipe, }); } catch (error) { res.status(500).json({ success: false, - message: "Failed to fetch recipes from Spoonacular", - response: error.message || error, + message: "Failed to delete recipe", + response: error.message, }); } }); - - - export default router; \ No newline at end of file From 0ce7897b9ead41b0f29bdfb372d77736b2d0c000 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Thu, 12 Feb 2026 16:38:34 +0100 Subject: [PATCH 033/127] Fix import path for API_URL in LoginForm and SignupForm components --- frontend/src/components/LoginForm.jsx | 2 +- frontend/src/components/SignupForm.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx index 6afefd645..c0aa0015f 100644 --- a/frontend/src/components/LoginForm.jsx +++ b/frontend/src/components/LoginForm.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { API_URL } from "../api"; +import { API_URL } from "../api/api"; import styled from "styled-components"; // Styled Components diff --git a/frontend/src/components/SignupForm.jsx b/frontend/src/components/SignupForm.jsx index d8e7af2af..8b773b264 100644 --- a/frontend/src/components/SignupForm.jsx +++ b/frontend/src/components/SignupForm.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { API_URL } from "../api"; +import { API_URL } from "../api/api"; import styled from "styled-components"; From 3479bd990fb1d09e1c318da130116fd84d7d8a6a Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 13 Feb 2026 09:28:08 +0100 Subject: [PATCH 034/127] Update favicon to use PantryMatch.svg --- frontend/index.html | 2 +- frontend/public/PantryMatch.svg | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 frontend/public/PantryMatch.svg diff --git a/frontend/index.html b/frontend/index.html index 4522ef0ad..774f1a408 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + PantryMatch diff --git a/frontend/public/PantryMatch.svg b/frontend/public/PantryMatch.svg new file mode 100644 index 000000000..d0ebedf25 --- /dev/null +++ b/frontend/public/PantryMatch.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 1bcf3e2f8dcceb3c45a52d003011a10ca0f9c333 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 13 Feb 2026 09:29:33 +0100 Subject: [PATCH 035/127] Add GlobalStyles component and update member page message --- frontend/src/App.jsx | 2 ++ frontend/src/GlobalStyles.js | 14 ++++++++++++++ frontend/src/pages/member.jsx | 1 + 3 files changed, 17 insertions(+) create mode 100644 frontend/src/GlobalStyles.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 088ed907d..f7e3b4e4f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import Member from "./pages/member"; import Home from "./pages/home"; import { useUserStore } from "./stores/userStore"; import About from "./pages/about"; +import GlobalStyles from "./GlobalStyles"; export const App = () => { @@ -16,6 +17,7 @@ export const App = () => { return ( + diff --git a/frontend/src/GlobalStyles.js b/frontend/src/GlobalStyles.js new file mode 100644 index 000000000..736af7988 --- /dev/null +++ b/frontend/src/GlobalStyles.js @@ -0,0 +1,14 @@ +import { createGlobalStyle } from "styled-components"; + +const GlobalStyles = createGlobalStyle` + body { + background: linear-gradient(135deg, #f8fdf9 0%, #e8f5e9 100%); + min-height: 100vh; + margin: 0; + font-family: 'Inter', Arial, Helvetica, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } +`; + +export default GlobalStyles; \ No newline at end of file diff --git a/frontend/src/pages/member.jsx b/frontend/src/pages/member.jsx index 1467a5503..47519951b 100644 --- a/frontend/src/pages/member.jsx +++ b/frontend/src/pages/member.jsx @@ -41,6 +41,7 @@ const Member = ({ user, isSigningUp, setIsSigningUp, handleLogin, handleLogout }

What to cook?

Sign up or log in and start discovering recipes based on what you have at home

+

And save your favorite recipes

{user ? ( Logout From 64881144b3a81c3a068d9fa9a7b3958fda2eaadf Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 13 Feb 2026 09:41:32 +0100 Subject: [PATCH 036/127] Add global media.js and responsive media queries to components --- frontend/src/components/LoginForm.jsx | 26 ++++++++++++- frontend/src/components/Navigation.jsx | 42 ++++++++++++++++++--- frontend/src/components/SearchRecipe.jsx | 48 +++++++++++++++++++++--- frontend/src/components/SignupForm.jsx | 22 ++++++++++- frontend/src/styles/media.js | 6 +++ 5 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 frontend/src/styles/media.js diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx index c0aa0015f..998c2bdb4 100644 --- a/frontend/src/components/LoginForm.jsx +++ b/frontend/src/components/LoginForm.jsx @@ -2,17 +2,29 @@ import { useState } from "react"; import { API_URL } from "../api/api"; import styled from "styled-components"; +import { media } from "../styles/media"; // Styled Components const StyledForm = styled.form` background: #fff; - padding: 2rem; + padding: 1.2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); - max-width: 350px; + max-width: 98vw; margin: 2rem auto; + + @media ${media.tablet} { + max-width: 400px; + padding: 1.5rem; + } + @media ${media.desktop} { + max-width: 350px; + padding: 2rem; + } `; + + const StyledHeading = styled.h2` text-align: center; margin-bottom: 1.5rem; @@ -36,8 +48,18 @@ const StyledInput = styled.input` border-radius: 4px; border: 1px solid #ccc; font-size: 1rem; + width: 100%; + + @media ${media.tablet} { + font-size: 1.05rem; + } + @media ${media.desktop} { + font-size: 1.1rem; + } `; + + const StyledButton = styled.button` background: #2e8b57; color: #fff; diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index b7a4f9c8a..defa06ca2 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -1,21 +1,43 @@ import styled from "styled-components"; +import { media } from "../styles/media"; import { Link } from "react-router-dom"; const NavContainer = styled.nav` display: flex; justify-content: center; align-items: center; - background: #f8f8f8; - padding: 1rem 0; - box-shadow: 0 2px 4px rgba(0,0,0,0.03); + background: #ffffff; + padding: 0.7rem 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + @media ${media.tablet} { + padding: 1rem 0; + } + @media ${media.desktop} { + padding: 1.2rem 0; + } `; const NavList = styled.ul` display: flex; - gap: 2rem; + gap: 1rem; list-style: none; margin: 0; padding: 0; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + &::-webkit-scrollbar { display: none; } + + @media ${media.tablet} { + gap: 2rem; + font-size: 1.05rem; + } + @media ${media.desktop} { + gap: 2.5rem; + font-size: 1.1rem; + } `; const NavItem = styled.li` @@ -28,12 +50,22 @@ const NavLink = styled(Link)` color: #333; font-weight: 600; cursor: pointer; - font-size: 1.1rem; + font-size: 1rem; text-decoration: none; transition: color 0.2s; + padding: 0.2rem 0.3rem; &:hover { color: #4e8cff; } + + @media ${media.tablet} { + font-size: 1.08rem; + padding: 0.3rem 0.5rem; + } + @media ${media.desktop} { + font-size: 1.1rem; + padding: 0.4rem 0.7rem; + } `; const Navigation = () => ( diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index 9735471a6..0467f228d 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -1,26 +1,52 @@ import { useState } from "react"; import styled from "styled-components"; +import { media } from "../styles/media"; const Section = styled.section` - max-width: 700px; + max-width: 98vw; margin: 0 auto; - + padding: 0 0.5rem; + + @media ${media.tablet} { + max-width: 700px; + padding: 0; + } `; const Form = styled.form` display: flex; - gap: 1rem; - align-items: center; + flex-direction: column; + gap: 0.7rem; + align-items: stretch; margin-bottom: 2rem; + + @media (min-width: 768px) { + flex-direction: row; + gap: 1rem; + align-items: center; + } `; const Input = styled.input` + box-sizing: border-box; padding: 0.5rem 1rem; border-radius: 4px; border: 1px solid #ccc; font-size: 1rem; - min-width: 350px; + width: 100%; + min-width: 0; + max-width: 100%; + + @media ${media.tablet} { + min-width: 350px; + width: auto; + font-size: 1.05rem; + max-width: none; + } + @media ${media.desktop} { + font-size: 1.1rem; + } `; const AddButton = styled.button` @@ -31,11 +57,21 @@ const AddButton = styled.button` border-radius: 4px; cursor: pointer; font-size: 1rem; - margin-left: 0.5rem; + width: 100%; + margin-left: 0; transition: background 0.2s; &:hover { background: #119a65; } + + @media ${media.tablet} { + width: auto; + margin-left: 0.5rem; + font-size: 1.05rem; + } + @media ${media.desktop} { + font-size: 1.1rem; + } `; const FilterSection = styled.div` diff --git a/frontend/src/components/SignupForm.jsx b/frontend/src/components/SignupForm.jsx index 8b773b264..818c9e728 100644 --- a/frontend/src/components/SignupForm.jsx +++ b/frontend/src/components/SignupForm.jsx @@ -2,15 +2,25 @@ import { useState } from "react"; import { API_URL } from "../api/api"; import styled from "styled-components"; +import { media } from "../styles/media"; const StyledForm = styled.form` background: #fff; - padding: 2rem; + padding: 1.2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); - max-width: 350px; + max-width: 98vw; margin: 2rem auto; + + @media ${media.tablet} { + max-width: 400px; + padding: 1.5rem; + } + @media ${media.desktop} { + max-width: 350px; + padding: 2rem; + } `; const StyledHeading = styled.h2` @@ -36,6 +46,14 @@ const StyledInput = styled.input` border-radius: 4px; border: 1px solid #ccc; font-size: 1rem; + width: 100%; + + @media ${media.tablet} { + font-size: 1.05rem; + } + @media ${media.desktop} { + font-size: 1.1rem; + } `; const StyledButton = styled.button` diff --git a/frontend/src/styles/media.js b/frontend/src/styles/media.js new file mode 100644 index 000000000..3e206742b --- /dev/null +++ b/frontend/src/styles/media.js @@ -0,0 +1,6 @@ +// src/styles/media.js +export const media = { + mobile: "(max-width: 767px)", + tablet: "(max-width: 1024px)", + desktop: "(min-width: 1025px)", +}; From 29ee8bc29f2e5a61f1c8133cb7143763ed37c0e1 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 13 Feb 2026 09:42:59 +0100 Subject: [PATCH 037/127] Add Hero component to home page with logo --- frontend/src/components/Hero.jsx | 163 +++++++++++++++++++++++++++++++ frontend/src/pages/home.jsx | 19 +++- 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Hero.jsx diff --git a/frontend/src/components/Hero.jsx b/frontend/src/components/Hero.jsx new file mode 100644 index 000000000..acb625a2a --- /dev/null +++ b/frontend/src/components/Hero.jsx @@ -0,0 +1,163 @@ +import styled, { keyframes } from "styled-components"; +import { media } from "../styles/media"; + +// Animations +const fadeInUp = keyframes` + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const gradientShift = keyframes` + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +`; + +const float = keyframes` + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +`; + +// Styled Components +const HeroSection = styled.section` + min-height: 50vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2.5rem 0.7rem 2rem; + position: relative; + overflow: hidden; + + @media ${media.tablet} { + padding: 3.5rem 1.5rem 2.5rem; + } + @media ${media.desktop} { + padding: 5rem 2rem 4rem; + } + + &::before { + content: ""; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient( + circle, + rgba(46, 139, 87, 0.03) 1px, + transparent 1px + ); + background-size: 50px 50px; + animation: ${float} 20s ease-in-out infinite; + } +`; + +const LogoContainer = styled.div` + width: 100px; + height: 100px; + margin-bottom: 1.5rem; + animation: ${fadeInUp} 0.8s ease-out; + filter: drop-shadow(0 4px 12px rgba(46, 139, 87, 0.15)); + + img { + width: 100%; + height: 100%; + object-fit: contain; + } +`; + +const TitleWrapper = styled.div` + text-align: center; + position: relative; + z-index: 1; +`; + +const MainTitle = styled.h1` + font-size: clamp(3rem, 8vw, 5.5rem); + font-weight: 800; + margin: 0; + background: linear-gradient( + 135deg, + #2e8b57 0%, + #3ba569 25%, + #48c07a 50%, + #3ba569 75%, + #2e8b57 100% + ); + background-size: 300% 300%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: ${gradientShift} 6s ease infinite, ${fadeInUp} 0.8s ease-out 0.2s + both; + letter-spacing: -0.02em; + line-height: 1.1; + + @media (max-width: 768px) { + letter-spacing: -0.01em; + } +`; + +const Subtitle = styled.p` + font-size: clamp(1rem, 2.5vw, 1.3rem); + color: #555; + margin: 1.5rem 0 0; + font-weight: 400; + max-width: 600px; + animation: ${fadeInUp} 0.8s ease-out 0.4s both; + line-height: 1.6; + + span { + color: #2e8b57; + font-weight: 600; + } +`; + +const AccentLine = styled.div` + width: 100px; + height: 4px; + background: linear-gradient(90deg, transparent, #2e8b57, transparent); + margin: 2rem auto 0; + border-radius: 2px; + animation: ${fadeInUp} 0.8s ease-out 0.6s both; +`; + + + +// Component +const Hero = () => { + return ( + + + PantryMatch Logo + + + + PantryMatch + + Turn your ingredients into delicious recipes + + + + + ); +}; + +export default Hero; \ No newline at end of file diff --git a/frontend/src/pages/home.jsx b/frontend/src/pages/home.jsx index cb9aa8de7..e6400e827 100644 --- a/frontend/src/pages/home.jsx +++ b/frontend/src/pages/home.jsx @@ -1,5 +1,6 @@ import SearchRecipe from "../components/SearchRecipe"; import styled from "styled-components"; +import Hero from "../components/Hero"; const Container = styled.div` max-width: 900px; @@ -8,7 +9,19 @@ const Container = styled.div` background: #ffffff; border-radius: 1.5rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); - + + @media (max-width: 600px) { + padding: 1rem 0.5rem; + max-width: 98vw; + } + @media (min-width: 601px) and (max-width: 1024px) { + padding: 1.5rem 1rem; + max-width: 98vw; + } + @media (min-width: 1205px) { + max-width: 1100px; + padding: 2.5rem 3rem; + } `; const Title = styled.h1` @@ -27,12 +40,14 @@ const Subtitle = styled.p` `; const Home = () => { return ( + <> + What to cook? Discover recipes based on what you have at home - + ); }; From 8a46b465492a3fc985bb7d48c12b5e7f9a53b48d Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 17 Feb 2026 12:12:26 +0100 Subject: [PATCH 038/127] structure api url, add recipe fields, changes req ingredient for search to 1 --- backend/routes/recipeRoutes.js | 9 ++++++--- frontend/src/api/api.js | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index 84256c2f7..894026c7c 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -22,10 +22,10 @@ router.get("/search", async (req, res) => { // split ingredients by comma and trim whitespace, also filter out empty strings const ingredientList = ingredients.split(",").map(i => i.trim()).filter(Boolean); - if (ingredientList.length < 2) { + if (ingredientList.length < 1) { return res.status(400).json({ success: false, - message: "Please provide at least 2 ingredients", + message: "Please provide at least 1 ingredient", response: null, }); } @@ -61,9 +61,11 @@ router.get("/search", async (req, res) => { image: recipe.image, usedIngredients: recipe.usedIngredients || [], missedIngredients: recipe.missedIngredients || [], - likes: recipe.aggregateLikes || 0, + summary: recipe.summary, readyInMinutes: recipe.readyInMinutes, servings: recipe.servings, + dishTypes: recipe.dishTypes?.join(", "), + cuisines: recipe.cuisines?.join(", "), })); res.status(200).json({ @@ -72,6 +74,7 @@ router.get("/search", async (req, res) => { response: formattedRecipes, }); } catch (error) { + console.log(error.response?.data || error.message || error); res.status(500).json({ success: false, message: "Failed to fetch recipes from Spoonacular", diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index ad7d6f33d..f1a766a72 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -1,6 +1,7 @@ export const API_URL = 'https://pantrymatch.onrender.com'; + // Helper to handle fetch responses async function handleResponse(response) { if (!response.ok) { @@ -12,13 +13,13 @@ async function handleResponse(response) { // Fetch recipes by ingredients from backend API export async function fetchRecipeByIngredients(ingredients, mode = "allowExtra") { - if (!ingredients || ingredients.length < 2) { - throw new Error("Please add at least 2 ingredients."); + if (!ingredients || ingredients.length < 1) { + throw new Error("Please add at least 1 ingredient."); } // Create query string const query = ingredients.join(","); const params = new URLSearchParams({ ingredients: query, mode }); - const res = await fetch(`/api/recipes/search?${params}`); + const res = await fetch(`${API_URL}/recipes/search?${params}`); const data = await handleResponse(res); return data.response; } From d4271ae7b8f0ee8aca01da24140df74cea1c0f02 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 17 Feb 2026 12:13:37 +0100 Subject: [PATCH 039/127] Add initial RecipeCard component for displaying recipe details --- frontend/src/components/RecipeCard.jsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 frontend/src/components/RecipeCard.jsx diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx new file mode 100644 index 000000000..cecfdd2ab --- /dev/null +++ b/frontend/src/components/RecipeCard.jsx @@ -0,0 +1,25 @@ +import styled from "styled-components"; + +const Card = styled.div` + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + padding: 1rem; + margin-bottom: 1rem; +`; + +const RecipeCard = ({ recipe }) => ( + +

{recipe.title}

+ {recipe.image && + {recipe.title}} +

{recipe.details}

+
+); + +export default RecipeCard; From 4aab4de79abf2e1718b90acf6af5099f9a4a493a Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 17 Feb 2026 12:17:34 +0100 Subject: [PATCH 040/127] Add initial RecipeList component to display a list of recipes --- frontend/src/components/RecipeList.jsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 frontend/src/components/RecipeList.jsx diff --git a/frontend/src/components/RecipeList.jsx b/frontend/src/components/RecipeList.jsx new file mode 100644 index 000000000..0568005b4 --- /dev/null +++ b/frontend/src/components/RecipeList.jsx @@ -0,0 +1,14 @@ +import RecipeCard from "./RecipeCard"; + +const RecipeList = ({ recipes }) => { + if (!recipes || recipes.length === 0) return

No recipes found.

; + return ( +
+ {recipes.map(recipe => ( + + ))} +
+ ); +}; + +export default RecipeList; From 793d495291d6c3fe73df770c5dc3eed704e883ed Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 17 Feb 2026 12:22:40 +0100 Subject: [PATCH 041/127] Add useRecipeActions custom hook for managing recipe search functionality --- frontend/src/hooks/useRecipeActions.js | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 frontend/src/hooks/useRecipeActions.js diff --git a/frontend/src/hooks/useRecipeActions.js b/frontend/src/hooks/useRecipeActions.js new file mode 100644 index 000000000..134478516 --- /dev/null +++ b/frontend/src/hooks/useRecipeActions.js @@ -0,0 +1,29 @@ +import { useRecipeStore } from "../stores/recipeStore"; +import { fetchRecipeByIngredients } from "../api/api"; + +export const useRecipeActions = () => { + const { + ingredients, recipes, mode, loading, error, + setRecipes, setLoading, setError, setMode, addIngredient, + } = useRecipeStore(); + + + const searchRecipes = async () => { + setLoading(true); + setError(null); + try { + const data = await fetchRecipeByIngredients(ingredients, mode); + setRecipes(data); + } catch (err) { + setError(err.message); + setRecipes([]); + } finally { + setLoading(false); + } + }; + + return { + ingredients, recipes, mode, loading, error, + setMode, addIngredient, searchRecipes + }; +}; \ No newline at end of file From f9139dff1a5be5829f0cd0ca02db90552620bedd Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 17 Feb 2026 12:23:07 +0100 Subject: [PATCH 042/127] Implement recipe search functionality with ingredient management and filtering options --- frontend/src/components/SearchRecipe.jsx | 80 ++++++++++++++++++------ 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index 0467f228d..339964138 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -1,4 +1,6 @@ import { useState } from "react"; +import { useRecipeActions } from "../hooks/useRecipeActions"; +import RecipeList from "./RecipeList"; import styled from "styled-components"; import { media } from "../styles/media"; @@ -76,7 +78,7 @@ const AddButton = styled.button` const FilterSection = styled.div` margin-bottom: 2rem; - background: #f7f7f7; + background: #fff; border-radius: 8px; padding: 1.2rem 1rem; `; @@ -114,13 +116,43 @@ const ShowButton = styled.button` background: #119a65; } `; +const IngredientsInfo = styled.div` + margin-bottom: 1rem; +`; +const ErrorMsg = styled.p` + color: #d32f2f; + font-weight: 500; + margin: 1rem 0; +`; const SearchRecipe = () => { const [input, setInput] = useState(""); + const { + ingredients, recipes, mode, loading, error, + setMode, addIngredient, searchRecipes + } = useRecipeActions(); + + const handleAdd = () => { + const trimmed = input.trim(); + if (trimmed && !ingredients.includes(trimmed)) { + addIngredient(trimmed); + setInput(""); + } + }; + + const handleSearch = () => { + if (ingredients.length > 0) { + searchRecipes(); + } + }; + + const handleModeChange = (e) => { + setMode(e.target.value); + }; return (
-
+ { e.preventDefault(); handleAdd(); }}> { onChange={(e) => setInput(e.target.value)} /> + type="submit"> Add -
+ {ingredients.length > 0 && ( + + Ingredients: {ingredients.join(", ")} + + )} Filter - - Use only these ingredients (no extras allowed) + + Use only these ingredients (no extras allowed) - - Allow extra ingredients (recipe may contain more) + + Allow extra ingredients (recipe may contain more) - - - Show recipes + + Show recipes + {loading &&

Loading...

} + {error && {error}} + {recipes && recipes.length > 0 && }
); }; From 64b986670d087e0037997cccdf256abec0b57809 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 17 Feb 2026 12:42:37 +0100 Subject: [PATCH 043/127] add todos --- frontend/src/components/RecipeCard.jsx | 3 +++ frontend/src/components/RecipeList.jsx | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index cecfdd2ab..5a17eddd6 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -10,6 +10,9 @@ const Card = styled.div` const RecipeCard = ({ recipe }) => ( + {/* TODO: show image, title, used/missing ingredients, servings, summary */} + {/* TODO: add Show more/Show less and expand the card */} + {/* TODO: When expanded, show all ingredients, instructions, and save button */}

{recipe.title}

{recipe.image && { if (!recipes || recipes.length === 0) return

No recipes found.

; return ( From 17bc992380fa4d04efdfcc875307206871516cf3 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 16:13:36 +0100 Subject: [PATCH 044/127] Refactor recipe search route, add recipe details endpoint and add error for unauthorized user after debug api call fails --- backend/routes/recipeRoutes.js | 86 ++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index 894026c7c..582bdd07e 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -21,7 +21,6 @@ router.get("/search", async (req, res) => { // split ingredients by comma and trim whitespace, also filter out empty strings const ingredientList = ingredients.split(",").map(i => i.trim()).filter(Boolean); - if (ingredientList.length < 1) { return res.status(400).json({ success: false, @@ -29,52 +28,37 @@ router.get("/search", async (req, res) => { response: null, }); } - // ingredient list for the API call try { const params = { - includeIngredients: ingredients, + ingredients: ingredientList.join(","), number: 15, - instructionsRequired: true, - addRecipeInformation: true, - ranking: 2, // 1 = maximize used ingredients, 2 = minimize missing ingredients + ranking: 2, + ignorePantry: mode === "exact", apiKey: process.env.SPOONACULAR_API_KEY, }; - - if (mode === "exact") { - params.fillIngredients = false; - params.ignorePantry = false; - } - const response = await axios.get( - "https://api.spoonacular.com/recipes/complexSearch", + "https://api.spoonacular.com/recipes/findByIngredients", { params } ); - // Extract recipes from response, handle case where data or results might be missing - const recipes = response.data.results || []; - // Format the recipes to only include necessary fields for the frontend - const formattedRecipes = recipes.map(recipe => ({ + const recipes = response.data.map(recipe => ({ id: recipe.id, title: recipe.title, image: recipe.image, - usedIngredients: recipe.usedIngredients || [], - missedIngredients: recipe.missedIngredients || [], - summary: recipe.summary, readyInMinutes: recipe.readyInMinutes, servings: recipe.servings, - dishTypes: recipe.dishTypes?.join(", "), - cuisines: recipe.cuisines?.join(", "), + usedIngredients: recipe.usedIngredients || [], + missedIngredients: recipe.missedIngredients || [], })); res.status(200).json({ success: true, message: "Recipes fetched from Spoonacular", - response: formattedRecipes, + response: recipes, }); } catch (error) { - console.log(error.response?.data || error.message || error); res.status(500).json({ success: false, message: "Failed to fetch recipes from Spoonacular", @@ -83,6 +67,45 @@ router.get("/search", async (req, res) => { } }); +// (GET) Get recipe details by Spoonacular ID +router.get("/details/:id", async (req, res) => { + const { id } = req.params; + + try { + const response = await axios.get( + `https://api.spoonacular.com/recipes/${id}/information`, + { + params: { + apiKey: process.env.SPOONACULAR_API_KEY, + includeNutrition: false + } + } + ); + + const details = response.data; + + res.status(200).json({ + success: true, + response: { + id: details.id, + title: details.title, + summary: details.summary, + instructions: details.instructions, + extendedIngredients: details.extendedIngredients, + readyInMinutes: details.readyInMinutes, + servings: details.servings, + } + }); + } catch (error) { + console.error("Spoonacular details error:", error.response?.data || error.message); + res.status(500).json({ + success: false, + message: "Failed to fetch recipe details", + response: error.response?.data || error.message, + }); + } +}); + // (GET) Get all saved recipes for logged-in user router.get("/", authenticateUser, async (req, res) => { try { @@ -102,8 +125,9 @@ router.get("/", authenticateUser, async (req, res) => { }); // (GET) Get recipe by ID -router.get("/:id", async (req, res) => { +router.get("/:id", authenticateUser, async (req, res) => { const { id } = req.params; + try { if (!mongoose.Types.ObjectId.isValid(id)) { return res.status(400).json({ @@ -112,7 +136,9 @@ router.get("/:id", async (req, res) => { response: null, }); } + const recipe = await Recipe.findById(id); + if (!recipe) { return res.status(404).json({ success: false, @@ -120,6 +146,15 @@ router.get("/:id", async (req, res) => { response: null, }); } + + if (recipe.userId.toString() !== req.user._id.toString()) { + return res.status(403).json({ + success: false, + message: "Not authorized to access this recipe", + response: null, + }); + } + res.status(200).json({ success: true, message: "Recipe found", @@ -184,6 +219,7 @@ router.delete("/:id", authenticateUser, async (req, res) => { return res.status(404).json({ success: false, message: "Recipe not found or you don't have permission to delete it", + response: null, }); } From e8bbb8a5f226246e398f7513bbc895aae00d8157 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 16:15:23 +0100 Subject: [PATCH 045/127] Refactor Recipe model to include detailed ingredient schema --- backend/model/Recipe.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/model/Recipe.js b/backend/model/Recipe.js index 297d8a0e7..d4b9af6ff 100644 --- a/backend/model/Recipe.js +++ b/backend/model/Recipe.js @@ -1,22 +1,29 @@ import mongoose from "mongoose"; +const ingredientSchema = new mongoose.Schema({ + name: String, + amount: Number, + unit: String, + original: String +}, { _id: false }); + const recipeSchema = new mongoose.Schema({ userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, }, - spoonacularId: { type: Number, required: true, }, - title: String, image: String, - ingredients: [String], - instructions: String, - + summary: String, + readyInMinutes: Number, + servings: Number, + extendedIngredients: [ingredientSchema], // detailed ingredient info from Spoonacular + instructions: String, // HTML or plain text from Spoonacular createdAt: { type: Date, default: Date.now, From e5fe5ee5806f01291699891df6dd824b134b253a Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 16:24:26 +0100 Subject: [PATCH 046/127] refactor APi calls to use consistent API_URL and improve error handling for recipe fetching' --- frontend/src/api/api.js | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index f1a766a72..64d2768c4 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -1,6 +1,6 @@ export const API_URL = 'https://pantrymatch.onrender.com'; - +//export const API_URL = 'http://localhost:8080'; // Helper to handle fetch responses async function handleResponse(response) { @@ -13,30 +13,33 @@ async function handleResponse(response) { // Fetch recipes by ingredients from backend API export async function fetchRecipeByIngredients(ingredients, mode = "allowExtra") { - if (!ingredients || ingredients.length < 1) { - throw new Error("Please add at least 1 ingredient."); - } - // Create query string const query = ingredients.join(","); - const params = new URLSearchParams({ ingredients: query, mode }); + const params = new URLSearchParams({ + ingredients: query, + mode: mode + }); const res = await fetch(`${API_URL}/recipes/search?${params}`); const data = await handleResponse(res); return data.response; } // Fetch recipe details by ID from Spoonacular API (public) -export async function fetchRecipeDetails(recipeId) { - const API_KEY = import.meta.env.VITE_SPOONACULAR_API_KEY; - const url = `https://api.spoonacular.com/recipes/${recipeId}/information?apiKey=${API_KEY}`; - const res = await fetch(url); - if (!res.ok) - throw new Error(`Failed to fetch recipe details: ${res.status}`); - return await res.json(); +export async function fetchRecipeDetails(id) { + const res = await fetch(`${API_URL}/recipes/details/${id}`); + const data = await handleResponse(res); + return data.response; +} + +// get details for a single recipe +export async function fetchSavedRecipeDetails(id) { + const res = await fetch(`${API_URL}/recipes/saved/${id}`); + const data = await handleResponse(res); + return data.response; } // Save a recipe to the database for logged-in user (auth required) export async function saveRecipe(recipeData, token) { - const res = await fetch('/api/recipes', { + const res = await fetch(`${API_URL}/recipes`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -49,10 +52,9 @@ export async function saveRecipe(recipeData, token) { // Get all saved recipes for logged-in user (auth required) export async function getSavedRecipes(token) { - const res = await fetch('/api/recipes', { - method: 'GET', + const res = await fetch(`${API_URL}/recipes`, { headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${token}` }, }); const data = await handleResponse(res); @@ -61,10 +63,10 @@ export async function getSavedRecipes(token) { // Delete a saved recipe by ID (auth required) export async function deleteRecipe(recipeId, token) { - const res = await fetch(`/api/recipes/${recipeId}`, { + const res = await fetch(`${API_URL}/recipes/${recipeId}`, { method: 'DELETE', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${token}` }, }); return handleResponse(res); From 988fcd04d50623d5a691fe6999e71d41c49d5418 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 16:29:38 +0100 Subject: [PATCH 047/127] add mode --- frontend/src/stores/recipeStore.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/stores/recipeStore.js b/frontend/src/stores/recipeStore.js index 500ad5450..37e038dd5 100644 --- a/frontend/src/stores/recipeStore.js +++ b/frontend/src/stores/recipeStore.js @@ -1,14 +1,13 @@ import { create } from "zustand"; - export const useRecipeStore = create((set) => ({ ingredients: [], recipes: [], - mode: "allowExtra", + mode: "allowExtra", loading: false, error: null, - setMode: (mode) => set({ mode }), + setMode: (mode) => set({ mode }), addIngredient: (ing) => set((state) => ({ From 6ff92b3753b25a53f2df789a208b3c60b4f55ba2 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 16:32:45 +0100 Subject: [PATCH 048/127] add setError for minimun ingredient input and add remove ingredient --- frontend/src/hooks/useRecipeActions.js | 33 +++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/src/hooks/useRecipeActions.js b/frontend/src/hooks/useRecipeActions.js index 134478516..83d69f2bc 100644 --- a/frontend/src/hooks/useRecipeActions.js +++ b/frontend/src/hooks/useRecipeActions.js @@ -3,16 +3,30 @@ import { fetchRecipeByIngredients } from "../api/api"; export const useRecipeActions = () => { const { - ingredients, recipes, mode, loading, error, - setRecipes, setLoading, setError, setMode, addIngredient, + ingredients, + recipes, + mode, + loading, + error, + setRecipes, + setLoading, + setError, + setMode, + addIngredient, + removeIngredient, } = useRecipeStore(); - const searchRecipes = async () => { + if (ingredients.length < 1) { + setError("Add at least 1 ingredient"); + return; + } + setLoading(true); setError(null); + try { - const data = await fetchRecipeByIngredients(ingredients, mode); + const data = await fetchRecipeByIngredients(ingredients, mode); // send mode to API setRecipes(data); } catch (err) { setError(err.message); @@ -23,7 +37,14 @@ export const useRecipeActions = () => { }; return { - ingredients, recipes, mode, loading, error, - setMode, addIngredient, searchRecipes + ingredients, + recipes, + mode, + loading, + error, + setMode, + addIngredient, + removeIngredient, + searchRecipes, }; }; \ No newline at end of file From c434143f8c148ad81ea7c0e1868ceb990b07f011 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 16:36:17 +0100 Subject: [PATCH 049/127] Refactor SearchRecipe component to improve ingredient handling, add media query, removeIngredient,mode and handlesumbit --- frontend/src/components/SearchRecipe.jsx | 118 ++++++++++++++--------- 1 file changed, 72 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index 339964138..aab681fd9 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -1,7 +1,7 @@ import { useState } from "react"; +import styled from "styled-components"; import { useRecipeActions } from "../hooks/useRecipeActions"; import RecipeList from "./RecipeList"; -import styled from "styled-components"; import { media } from "../styles/media"; const Section = styled.section` @@ -38,16 +38,10 @@ const Input = styled.input` font-size: 1rem; width: 100%; min-width: 0; - max-width: 100%; @media ${media.tablet} { min-width: 350px; width: auto; - font-size: 1.05rem; - max-width: none; - } - @media ${media.desktop} { - font-size: 1.1rem; } `; @@ -60,8 +54,8 @@ const AddButton = styled.button` cursor: pointer; font-size: 1rem; width: 100%; - margin-left: 0; transition: background 0.2s; + &:hover { background: #119a65; } @@ -69,10 +63,26 @@ const AddButton = styled.button` @media ${media.tablet} { width: auto; margin-left: 0.5rem; - font-size: 1.05rem; } - @media ${media.desktop} { - font-size: 1.1rem; +`; + +const IngredientsInfo = styled.div` + margin-bottom: 1rem; + font-size: 0.95rem; +`; + +const IngredientTag = styled.span` + display: inline-block; + background: #e8f5e9; + color: #2e8b57; + padding: 0.3rem 0.6rem; + border-radius: 12px; + margin: 0.2rem; + font-size: 0.9rem; + cursor: pointer; + + &:hover { + background: #c8e6c9; } `; @@ -95,10 +105,12 @@ const FilterLabel = styled.label` align-items: center; font-size: 1rem; margin-bottom: 0.5rem; + cursor: pointer; `; const Radio = styled.input` margin-right: 0.5rem; + cursor: pointer; `; const ShowButton = styled.button` @@ -110,15 +122,19 @@ const ShowButton = styled.button` cursor: pointer; font-size: 1.1rem; margin-top: 1rem; - box-shadow: 0 4px 16px rgba(46,139,87,0.15); + box-shadow: 0 4px 16px rgba(46, 139, 87, 0.15); transition: background 0.2s; + &:hover { background: #119a65; } + + &:disabled { + background: #ccc; + cursor: not-allowed; + } `; -const IngredientsInfo = styled.div` - margin-bottom: 1rem; -`; + const ErrorMsg = styled.p` color: #d32f2f; font-weight: 500; @@ -128,56 +144,62 @@ const ErrorMsg = styled.p` const SearchRecipe = () => { const [input, setInput] = useState(""); const { - ingredients, recipes, mode, loading, error, - setMode, addIngredient, searchRecipes + ingredients, + recipes, + mode, + loading, + error, + setMode, + addIngredient, + removeIngredient, + searchRecipes, } = useRecipeActions(); const handleAdd = () => { - const trimmed = input.trim(); + const trimmed = input.trim().toLowerCase(); if (trimmed && !ingredients.includes(trimmed)) { addIngredient(trimmed); setInput(""); } }; - const handleSearch = () => { - if (ingredients.length > 0) { - searchRecipes(); - } - }; - - const handleModeChange = (e) => { - setMode(e.target.value); + const handleSubmit = (e) => { + e.preventDefault(); + handleAdd(); }; return (
-
{ e.preventDefault(); handleAdd(); }}> + setInput(e.target.value)} /> - - Add - + Add
- {ingredients.length > 0 && ( - - Ingredients: {ingredients.join(", ")} - - )} + + {ingredients.length > 0 && ( + + Ingredients:{" "} + {ingredients.map((ing) => ( + removeIngredient(ing)}> + {ing} × + + ))} + + )} + Filter setMode("exact")} /> Use only these ingredients (no extras allowed) @@ -187,19 +209,23 @@ const SearchRecipe = () => { name="filter" value="allowExtra" checked={mode === "allowExtra"} - onChange={handleModeChange} + onChange={() => setMode("allowExtra")} /> Allow extra ingredients (recipe may contain more) - - Show recipes + + + {loading ? "Searching..." : "Show recipes"} - {loading &&

Loading...

} - {error && {error}} - {recipes && recipes.length > 0 && } + + {error && {error}} + {recipes && recipes.length > 0 && } + {!loading && recipes.length === 0 && ingredients.length > 0 && !error && ( +

No recipes found. Try different ingredients!

+ )}
); }; -export default SearchRecipe; +export default SearchRecipe; \ No newline at end of file From 3d1e1e98375888e9436bd4e0fb86fbc6ed8c5021 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 17:29:20 +0100 Subject: [PATCH 050/127] add expandable details, fetch add display instructions and ingredients, show matched/missing ingredients, and improve UI with styled-components --- frontend/src/components/RecipeCard.jsx | 164 +++++++++++++++++++++---- 1 file changed, 142 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 5a17eddd6..131ce87cc 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -1,28 +1,148 @@ +import { useState } from "react"; import styled from "styled-components"; +import { fetchRecipeDetails } from "../api/api"; const Card = styled.div` background: #fff; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0,0,0,0.08); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); padding: 1rem; - margin-bottom: 1rem; -`; - -const RecipeCard = ({ recipe }) => ( - - {/* TODO: show image, title, used/missing ingredients, servings, summary */} - {/* TODO: add Show more/Show less and expand the card */} - {/* TODO: When expanded, show all ingredients, instructions, and save button */} -

{recipe.title}

- {recipe.image && - {recipe.title}} -

{recipe.details}

-
-); - -export default RecipeCard; + margin-bottom: 1.5rem; +`; + +const Image = styled.img` + width: 100%; + border-radius: 6px; + margin-bottom: 0.5rem; +`; + +const Title = styled.h3` + margin: 0.5rem 0; + color: #222; +`; + +const Info = styled.p` + font-size: 0.9rem; + color: #666; + margin: 0.3rem 0; +`; + +const IngredientInfo = styled.p` + font-size: 0.9rem; + margin: 0.5rem 0; + + strong { + font-weight: 600; + } +`; + +const Matched = styled.span` + color: #2e8b57; +`; + +const Missing = styled.span` + color: #ff6b6b; +`; + +const ToggleBtn = styled.button` + background: none; + border: 1px solid #2e8b57; + color: #2e8b57; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; + font-size: 0.95rem; + transition: all 0.2s; + + &:hover { + background: #2e8b57; + color: white; + } +`; + +const Details = styled.div` + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #eee; + + h4 { + margin-top: 1rem; + color: #222; + } + + ul { + padding-left: 1.5rem; + } + + li { + margin: 0.3rem 0; + } +`; + +const RecipeCard = ({ recipe }) => { + const [isOpen, setIsOpen] = useState(false); + const [details, setDetails] = useState(null); + const [loadingDetails, setLoadingDetails] = useState(false); + + const handleToggle = async () => { + if (!isOpen && !details) { + setLoadingDetails(true); + try { + const data = await fetchRecipeDetails(recipe.id); + setDetails(data); + } catch (err) { + console.error("Failed to fetch details:", err); + } finally { + setLoadingDetails(false); + } + } + setIsOpen(!isOpen); + }; + + return ( + + {recipe.image && {recipe.title}} + {recipe.title} + + ⏱️ {recipe.readyInMinutes} min | 🍽️ {recipe.servings} servings + + + + + ✅ Matched: {recipe.usedIngredients?.map((i) => i.name).join(", ") || "None"} + + + + + + ❌ Missing: {recipe.missedIngredients?.map((i) => i.name).join(", ") || "None"} + + + + + {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} + + + {isOpen && details && ( +
+ {details.summary && ( +
+ )} + +

All ingredients:

+
    + {details.extendedIngredients?.map((ing, index) => ( +
  • {ing.original}
  • + ))} +
+ +

Instructions:

+

{details.instructions || "No instructions available"}

+
+ )} +
+ ); +}; + +export default RecipeCard; \ No newline at end of file From ee39fc060a1bc786ed5562bf5aa1844dc31fd98b Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 17:29:52 +0100 Subject: [PATCH 051/127] add syled components and improve layout and remove unnecessary elements --- frontend/src/components/RecipeList.jsx | 27 ++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/RecipeList.jsx b/frontend/src/components/RecipeList.jsx index b41a00847..f5deccabb 100644 --- a/frontend/src/components/RecipeList.jsx +++ b/frontend/src/components/RecipeList.jsx @@ -1,23 +1,26 @@ import RecipeCard from "./RecipeCard"; +import styled from "styled-components"; +const Container = styled.div` + margin-top: 2rem; +`; -// TODO: show list of recipes with basic info: -// - image -// - title -// - ingredients used (those that match the user's search) -// - missing ingredients -// - servings -// - summary +const Title = styled.h2` + margin-bottom: 1rem; + color: #222; +`; const RecipeList = ({ recipes }) => { - if (!recipes || recipes.length === 0) return

No recipes found.

; + if (!recipes || recipes.length === 0) return null; + return ( -
- {recipes.map(recipe => ( + + Found {recipes.length} recipe{recipes.length !== 1 ? "s" : ""} + {recipes.map((recipe) => ( ))} -
+ ); }; -export default RecipeList; +export default RecipeList; \ No newline at end of file From 68a46df12ad5332a8795cab967ba3d502b074be4 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 19:19:24 +0100 Subject: [PATCH 052/127] debug spoonacular extra API calls for readyInMinutes and servings by putting these in show more mode --- backend/routes/recipeRoutes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index 582bdd07e..8cafd10f4 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -47,8 +47,8 @@ router.get("/search", async (req, res) => { id: recipe.id, title: recipe.title, image: recipe.image, - readyInMinutes: recipe.readyInMinutes, - servings: recipe.servings, + readyInMinutes: recipe.readyInMinutes ?? null, + servings: recipe.servings ?? null, usedIngredients: recipe.usedIngredients || [], missedIngredients: recipe.missedIngredients || [], })); From 9a368846946d9244bc6c83f804db6534e138fc2f Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 19:21:04 +0100 Subject: [PATCH 053/127] improve ingredient display by filtering out unwanted ''other things'' as ingredients and add handling for unknown cooking time and servings --- frontend/src/components/RecipeCard.jsx | 27 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 131ce87cc..0b5468a0e 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -104,19 +104,15 @@ const RecipeCard = ({ recipe }) => { {recipe.image && {recipe.title}} {recipe.title} - - ⏱️ {recipe.readyInMinutes} min | 🍽️ {recipe.servings} servings - - - ✅ Matched: {recipe.usedIngredients?.map((i) => i.name).join(", ") || "None"} + ✅ Matched: {recipe.usedIngredients?.map((i) => i.name).join(", ") || "No matched ingredients"} - ❌ Missing: {recipe.missedIngredients?.map((i) => i.name).join(", ") || "None"} + ❌ Missing: {recipe.missedIngredients?.map((i) => i.name).join(", ") || "No missing ingredients"} @@ -125,16 +121,27 @@ const RecipeCard = ({ recipe }) => { {isOpen && details && ( -
+
+ + ⏱️ {details.readyInMinutes !== null && details.readyInMinutes !== undefined ? `${details.readyInMinutes} min` : 'unknown time'} + {' | '} + 🍽️ {details.servings !== null && details.servings !== undefined ? `${details.servings} servings` : 'unknown servings'} + {details.summary && (
)}

All ingredients:

    - {details.extendedIngredients?.map((ing, index) => ( -
  • {ing.original}
  • - ))} + {details.extendedIngredients + ?.filter( + (ing) => + ing.original && + !/other (things|ingredients) needed/i.test(ing.original) + ) + .map((ing, index) => ( +
  • {ing.original}
  • + ))}

Instructions:

From f6553926fe6d229140609d6f6094776c425205c0 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Wed, 18 Feb 2026 19:21:29 +0100 Subject: [PATCH 054/127] add logging for fetchRecipeByIngredients response --- frontend/src/api/api.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 64d2768c4..6b41bc990 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -1,6 +1,6 @@ -export const API_URL = 'https://pantrymatch.onrender.com'; -//export const API_URL = 'http://localhost:8080'; +//export const API_URL = 'https://pantrymatch.onrender.com'; +export const API_URL = 'http://localhost:8080'; // Helper to handle fetch responses async function handleResponse(response) { @@ -20,6 +20,7 @@ export async function fetchRecipeByIngredients(ingredients, mode = "allowExtra") }); const res = await fetch(`${API_URL}/recipes/search?${params}`); const data = await handleResponse(res); + console.log("[fetchRecipeByIngredients] API response:", data.response); return data.response; } From 57df2b0cbf5fefbef94fec8e5bf941b55fce03aa Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 22 Feb 2026 17:42:49 +0100 Subject: [PATCH 055/127] add save button to recipeCard --- frontend/src/components/RecipeCard.jsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 0b5468a0e..8c3fd84bc 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -80,6 +80,21 @@ const Details = styled.div` } `; +const SaveBtn = styled.button` + background: #ff9800; + border: none; + color: white; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; + + &:hover { + background: #e68900; + } +`; + + const RecipeCard = ({ recipe }) => { const [isOpen, setIsOpen] = useState(false); const [details, setDetails] = useState(null); @@ -120,6 +135,9 @@ const RecipeCard = ({ recipe }) => { {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} + Save Recipe + + {isOpen && details && (
From ed22408c1a63a6d9c9dc4757f6a0203847e64628 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 23 Feb 2026 10:05:59 +0100 Subject: [PATCH 056/127] add save functionality: redirect to login if user not authenticated, add saving recipe to database --- frontend/src/components/RecipeCard.jsx | 37 +++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 8c3fd84bc..4956fc494 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -1,6 +1,9 @@ import { useState } from "react"; import styled from "styled-components"; +import { useUserStore } from "../stores/userStore"; +import { useNavigate } from "react-router-dom"; import { fetchRecipeDetails } from "../api/api"; +import { saveRecipe } from "../api/api"; const Card = styled.div` background: #fff; @@ -96,6 +99,8 @@ const SaveBtn = styled.button` const RecipeCard = ({ recipe }) => { + const { user } = useUserStore(); + const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); const [details, setDetails] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); @@ -115,6 +120,36 @@ const RecipeCard = ({ recipe }) => { setIsOpen(!isOpen); }; +const handleSave = async () => { + if (!user) { + navigate("/member"); + return; + } + + try { + const recipeDetails = details || await fetchRecipeDetails(recipe.id); + + await saveRecipe( + { + spoonacularId: recipeDetails.id, + title: recipeDetails.title, + image: recipe.image, + summary: recipeDetails.summary, + readyInMinutes: recipeDetails.readyInMinutes, + servings: recipeDetails.servings, + extendedIngredients: recipeDetails.extendedIngredients, + instructions: recipeDetails.instructions, + }, + user.accessToken + ); + + alert("Recipe saved!"); + } catch (error) { + alert(error.message); + } +}; + + return ( {recipe.image && {recipe.title}} @@ -135,7 +170,7 @@ const RecipeCard = ({ recipe }) => { {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} - Save Recipe + Save Recipe {isOpen && details && ( From 9c4bcafe58aac49524970d57d0bbf55eecf32700 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 23 Feb 2026 10:06:53 +0100 Subject: [PATCH 057/127] update saved recipes route to render SavedRecipes component --- frontend/src/App.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f7e3b4e4f..7748bcb13 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import Home from "./pages/home"; import { useUserStore } from "./stores/userStore"; import About from "./pages/about"; import GlobalStyles from "./GlobalStyles"; +import SavedRecipes from "./pages/SavedRecipes"; export const App = () => { @@ -42,7 +43,7 @@ export const App = () => { Saved Recipes : + user ? : } /> From 37bf7538f9aec7ec317d726d9792c462c2c3ba5c Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 23 Feb 2026 10:08:47 +0100 Subject: [PATCH 058/127] add basic SavedRecipes component to display and manage saved recipes --- frontend/src/pages/savedRecipes.jsx | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 frontend/src/pages/savedRecipes.jsx diff --git a/frontend/src/pages/savedRecipes.jsx b/frontend/src/pages/savedRecipes.jsx new file mode 100644 index 000000000..3114435bd --- /dev/null +++ b/frontend/src/pages/savedRecipes.jsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { getSavedRecipes, deleteRecipe } from "../api/api"; +import { useUserStore } from "../stores/userStore"; + +const SavedRecipes = () => { + const { user } = useUserStore(); + const [recipes, setRecipes] = useState([]); + + useEffect(() => { + const fetchData = async () => { + try { + const data = await getSavedRecipes(user.accessToken); + setRecipes(data); + } catch (err) { + console.error(err); + } + }; + + fetchData(); + }, [user]); + + const handleDelete = async (id) => { + await deleteRecipe(id, user.accessToken); + setRecipes(recipes.filter(r => r._id !== id)); + }; + + return ( +
+

Saved Recipes

+ {recipes.map(recipe => ( +
+

{recipe.title}

+ +
+ ))} +
+ ); +}; + +export default SavedRecipes; From bc8282b483e9a6316c63bed7f9b8a4d3675d9bc4 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 24 Feb 2026 11:03:50 +0100 Subject: [PATCH 059/127] enhance design with styled components, add loading,error,expand states, and improve recipe details display --- frontend/src/pages/savedRecipes.jsx | 278 +++++++++++++++++++++++++--- 1 file changed, 252 insertions(+), 26 deletions(-) diff --git a/frontend/src/pages/savedRecipes.jsx b/frontend/src/pages/savedRecipes.jsx index 3114435bd..e8c64138d 100644 --- a/frontend/src/pages/savedRecipes.jsx +++ b/frontend/src/pages/savedRecipes.jsx @@ -1,42 +1,268 @@ import { useEffect, useState } from "react"; -import { getSavedRecipes, deleteRecipe } from "../api/api"; +import styled from "styled-components"; import { useUserStore } from "../stores/userStore"; +import { getSavedRecipes, deleteRecipe } from "../api/api"; +import { media } from "../styles/media"; + +const Container = styled.div` + max-width: 900px; + margin: 3rem auto; + padding: 2rem; + background: #ffffff; + border-radius: 1.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + + @media (max-width: 600px) { + padding: 1rem 0.5rem; + max-width: 98vw; + } +`; + +const Title = styled.h1` + text-align: center; + font-size: 2.5rem; + margin-bottom: 1rem; + color: #2e8b57; +`; + +const Subtitle = styled.p` + text-align: center; + font-size: 1.1rem; + color: #666; + margin-bottom: 2.5rem; +`; + +const RecipeGrid = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + + @media ${media.tablet} { + grid-template-columns: repeat(2, 1fr); + } +`; + +const Card = styled.div` + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + padding: 1rem; +`; + +const Image = styled.img` + width: 100%; + height: 200px; + object-fit: cover; + border-radius: 6px; + margin-bottom: 0.5rem; +`; + +const RecipeTitle = styled.h3` + margin: 0.5rem 0; + color: #222; +`; + +const Info = styled.p` + font-size: 0.9rem; + color: #666; + margin: 0.3rem 0; +`; + +const ButtonRow = styled.div` + display: flex; + gap: 0.5rem; + margin-top: 1rem; +`; + +const ToggleBtn = styled.button` + background: none; + border: 1px solid #2e8b57; + color: #2e8b57; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; + font-size: 0.95rem; + transition: all 0.2s; + + &:hover { + background: #2e8b57; + color: white; + } +`; + +const DeleteBtn = styled.button` + background: #c0392b; + border: none; + color: white; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; + + &:hover { + background: #a93226; + } +`; + +const EmptyState = styled.div` + text-align: center; + padding: 3rem 1rem; + color: #666; + + h3 { + margin-bottom: 1rem; + } +`; + +const Details = styled.div` + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #eee; + + h4 { + margin-top: 1rem; + color: #222; + } + + ul { + padding-left: 1.5rem; + } + + li { + margin: 0.3rem 0; + } +`; const SavedRecipes = () => { const { user } = useUserStore(); const [recipes, setRecipes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); useEffect(() => { - const fetchData = async () => { - try { - const data = await getSavedRecipes(user.accessToken); - setRecipes(data); - } catch (err) { - console.error(err); - } - }; - - fetchData(); + if (user) { + fetchSavedRecipes(); + } }, [user]); - const handleDelete = async (id) => { - await deleteRecipe(id, user.accessToken); - setRecipes(recipes.filter(r => r._id !== id)); + const fetchSavedRecipes = async () => { + try { + setLoading(true); + const data = await getSavedRecipes(user.accessToken); + console.log("Fetched saved recipes:", data); // Debug + setRecipes(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (recipeId) => { + if (!window.confirm("Are you sure you want to delete this recipe?")) { + return; + } + + try { + await deleteRecipe(recipeId, user.accessToken); + setRecipes(recipes.filter(r => r._id !== recipeId)); + } catch (err) { + alert("Failed to delete recipe"); + } + }; + + const toggleDetails = (id) => { + setExpandedId(expandedId === id ? null : id); }; + if (loading) { + return ( + + Saved Recipes +

Loading...

+
+ ); + } + + if (error) { + return ( + + Saved Recipes +

Error: {error}

+
+ ); + } + return ( -
-

Saved Recipes

- {recipes.map(recipe => ( -
-

{recipe.title}

- -
- ))} -
+ + Saved Recipes + Your recipe collection + + {recipes.length === 0 ? ( + +

No saved recipes yet

+

Start searching for recipes and save your favorites!

+
+ ) : ( + + {recipes.map((recipe) => { + const isExpanded = expandedId === recipe._id; + + return ( + + {recipe.image && {recipe.title}} + {recipe.title} + + + {recipe.readyInMinutes && `⏱️ ${recipe.readyInMinutes} min`} + {recipe.servings && ` | 🍽️ ${recipe.servings} servings`} + + + + toggleDetails(recipe._id)}> + {isExpanded ? "Show less" : "Show more"} + + handleDelete(recipe._id)}> + Delete + + + + {isExpanded && ( +
+ {recipe.summary && ( +
+ )} + +

Ingredients:

+
    + {recipe.extendedIngredients?.map((ing, index) => ( +
  • {ing.original || ing.name}
  • + ))} +
+ +

Instructions:

+ {recipe.analyzedInstructions?.length > 0 ? ( +
    + {recipe.analyzedInstructions[0].steps.map((step) => ( +
  1. {step.step}
  2. + ))} +
+ ) : recipe.instructions ? ( +
+ ) : ( +

No instructions available

+ )} +
+ )} +
+ ); + })} +
+ )} +
); }; -export default SavedRecipes; +export default SavedRecipes; \ No newline at end of file From 3e63a2435e0f97bac5fd1354cb0f6c8b466c22b5 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 24 Feb 2026 11:04:13 +0100 Subject: [PATCH 060/127] add analyzedInstructions to recipe details response --- backend/routes/recipeRoutes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index 8cafd10f4..0c9c8e0ff 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -91,6 +91,7 @@ router.get("/details/:id", async (req, res) => { title: details.title, summary: details.summary, instructions: details.instructions, + analyzedInstructions: details.analyzedInstructions, extendedIngredients: details.extendedIngredients, readyInMinutes: details.readyInMinutes, servings: details.servings, From deca57caf8b674138745e81e1bf4d3b5f96bbf42 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 24 Feb 2026 11:04:41 +0100 Subject: [PATCH 061/127] update recipe instructions display to show analyzed instructions if available, fallback to HTML or default message --- frontend/src/components/RecipeCard.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 4956fc494..fbe86bc22 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -198,7 +198,17 @@ const handleSave = async () => {

Instructions:

-

{details.instructions || "No instructions available"}

+ {details.analyzedInstructions?.length > 0 ? ( +
    + {details.analyzedInstructions[0].steps.map((step) => ( +
  1. {step.step}
  2. + ))} +
+ ) : details.instructions ? ( +
+ ) : ( +

No instructions available

+ )}
)} From ec731240e20ecb696af5de7a9193bf375eb4d583 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 24 Feb 2026 13:55:59 +0100 Subject: [PATCH 062/127] update grid and button styling for RecipeCard ,RecipeList and savedRecipes components --- frontend/src/components/RecipeCard.jsx | 43 ++++++++++++++++---------- frontend/src/components/RecipeList.jsx | 24 ++++++++++++-- frontend/src/pages/savedRecipes.jsx | 8 ++--- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index fbe86bc22..fb39f1be5 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -4,11 +4,12 @@ import { useUserStore } from "../stores/userStore"; import { useNavigate } from "react-router-dom"; import { fetchRecipeDetails } from "../api/api"; import { saveRecipe } from "../api/api"; +import { media } from "../styles/media"; const Card = styled.div` background: #fff; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); padding: 1rem; margin-bottom: 1.5rem; `; @@ -47,6 +48,13 @@ const Missing = styled.span` color: #ff6b6b; `; +const BtnRow = styled.div` + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin-top: 1rem; +`; + const ToggleBtn = styled.button` background: none; border: 1px solid #2e8b57; @@ -64,6 +72,22 @@ const ToggleBtn = styled.button` } `; +const SaveBtn = styled.button` + background: #ff9800; + border: none; + color: white; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; + font-size: 0.95rem; + transition: all 0.2s; + + &:hover { + background: #e68900; + } +`; + const Details = styled.div` margin-top: 1rem; padding-top: 1rem; @@ -83,20 +107,6 @@ const Details = styled.div` } `; -const SaveBtn = styled.button` - background: #ff9800; - border: none; - color: white; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 0.5rem; - - &:hover { - background: #e68900; - } -`; - const RecipeCard = ({ recipe }) => { const { user } = useUserStore(); @@ -166,12 +176,13 @@ const handleSave = async () => { + {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} Save Recipe - + {isOpen && details && (
diff --git a/frontend/src/components/RecipeList.jsx b/frontend/src/components/RecipeList.jsx index f5deccabb..76ce706bf 100644 --- a/frontend/src/components/RecipeList.jsx +++ b/frontend/src/components/RecipeList.jsx @@ -1,5 +1,6 @@ import RecipeCard from "./RecipeCard"; import styled from "styled-components"; +import { media } from "../styles/media"; const Container = styled.div` margin-top: 2rem; @@ -10,15 +11,32 @@ const Title = styled.h2` color: #222; `; +const RecipeGrid = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; + margin: 2rem 0; + + @media ${media.tablet} { + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-items: stretch; + } +`; + + const RecipeList = ({ recipes }) => { if (!recipes || recipes.length === 0) return null; return ( Found {recipes.length} recipe{recipes.length !== 1 ? "s" : ""} - {recipes.map((recipe) => ( - - ))} + + {recipes.map((recipe) => ( + + ))} + ); }; diff --git a/frontend/src/pages/savedRecipes.jsx b/frontend/src/pages/savedRecipes.jsx index e8c64138d..02e0b0188 100644 --- a/frontend/src/pages/savedRecipes.jsx +++ b/frontend/src/pages/savedRecipes.jsx @@ -31,11 +31,11 @@ const Subtitle = styled.p` color: #666; margin-bottom: 2.5rem; `; - const RecipeGrid = styled.div` display: grid; - grid-template-columns: 1fr; - gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 2rem; + margin: 2rem 0; @media ${media.tablet} { grid-template-columns: repeat(2, 1fr); @@ -45,7 +45,7 @@ const RecipeGrid = styled.div` const Card = styled.div` background: #fff; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); padding: 1rem; `; From 427b19097597d385131463d74021cb8d73f6142f Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 24 Feb 2026 16:16:49 +0100 Subject: [PATCH 063/127] add diet and intolerance filters to recipe search functionality --- backend/routes/recipeRoutes.js | 40 ++++++++++++++------ frontend/src/api/api.js | 23 +++++++----- frontend/src/components/SearchRecipe.jsx | 48 ++++++++++++++++++++++++ frontend/src/hooks/useRecipeActions.js | 17 +++++++-- frontend/src/stores/recipeStore.js | 12 +++++- 5 files changed, 112 insertions(+), 28 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index 0c9c8e0ff..c6ae357ae 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -7,9 +7,9 @@ import authenticateUser from "../middleware/authMiddleware.js"; const router = express.Router(); -// (GET)Search for recipes based on ingredients and mode (allowExtra or exact) +// (GET) Search for recipes based on ingredients and mode (allowExtra or exact), diet, intolerances complexSearch endpoint router.get("/search", async (req, res) => { - const { ingredients, mode } = req.query; + const { ingredients, mode, lactoseFree, dairyFree, glutenFree, vegetarian, vegan } = req.query; if (!ingredients) { return res.status(400).json({ @@ -28,37 +28,53 @@ router.get("/search", async (req, res) => { response: null, }); } - // ingredient list for the API call + + // Diet filters + let dietFilters = []; + if (vegetarian === "true") dietFilters.push("vegetarian"); + if (vegan === "true") dietFilters.push("vegan"); + + // Intolerance filters + let intoleranceFilters = []; + if (lactoseFree === "true") intoleranceFilters.push("lactose"); + if (dairyFree === "true") intoleranceFilters.push("dairy"); + if (glutenFree === "true") intoleranceFilters.push("gluten"); + + // query params for complexSearch try { const params = { - ingredients: ingredientList.join(","), + includeIngredients: ingredientList.join(","), number: 15, - ranking: 2, - ignorePantry: mode === "exact", + diet: dietFilters.join(","), + intolerances: intoleranceFilters.join(","), + sort: mode === "exact" ? "min-missing-ingredients" : "max-used-ingredients", + addRecipeInformation: true, apiKey: process.env.SPOONACULAR_API_KEY, }; + const response = await axios.get( - "https://api.spoonacular.com/recipes/findByIngredients", + "https://api.spoonacular.com/recipes/complexSearch", { params } ); - // Extract recipes from response, handle case where data or results might be missing - const recipes = response.data.map(recipe => ({ + // Extract and format recipes with usedIngredients and missedIngredients arrays + const recipes = response.data.results.map(recipe => ({ id: recipe.id, title: recipe.title, image: recipe.image, - readyInMinutes: recipe.readyInMinutes ?? null, - servings: recipe.servings ?? null, + readyInMinutes: recipe.readyInMinutes || null, + servings: recipe.servings || null, usedIngredients: recipe.usedIngredients || [], missedIngredients: recipe.missedIngredients || [], })); res.status(200).json({ success: true, - message: "Recipes fetched from Spoonacular", + message: "Recipes fetched from Spoonacular complexSearch", response: recipes, }); } catch (error) { + console.error("Spoonacular complexSearch error:", error.response?.data || error.message); res.status(500).json({ success: false, message: "Failed to fetch recipes from Spoonacular", diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 6b41bc990..a5b31b704 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -1,6 +1,6 @@ -//export const API_URL = 'https://pantrymatch.onrender.com'; -export const API_URL = 'http://localhost:8080'; +export const API_URL = 'https://pantrymatch.onrender.com'; +//export const API_URL = 'http://localhost:8080'; // Helper to handle fetch responses async function handleResponse(response) { @@ -11,16 +11,19 @@ async function handleResponse(response) { return response.json(); } -// Fetch recipes by ingredients from backend API -export async function fetchRecipeByIngredients(ingredients, mode = "allowExtra") { - const query = ingredients.join(","); - const params = new URLSearchParams({ - ingredients: query, - mode: mode +// Fetch recipes by ingredients with filters from backend API +export async function fetchRecipeByIngredients(ingredients, mode = "allowExtra", filters = {}) { + const params = new URLSearchParams({ + ingredients: ingredients.join(","), + mode, + vegetarian: filters.vegetarian ? "true" : "false", + vegan: filters.vegan ? "true" : "false", + lactoseFree: filters.lactoseFree ? "true" : "false", + dairyFree: filters.dairyFree ? "true" : "false", + glutenFree: filters.glutenFree ? "true" : "false", }); - const res = await fetch(`${API_URL}/recipes/search?${params}`); + const res = await fetch(`${API_URL}/recipes/search?${params.toString()}`); const data = await handleResponse(res); - console.log("[fetchRecipeByIngredients] API response:", data.response); return data.response; } diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index aab681fd9..befdc9d46 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -149,6 +149,8 @@ const SearchRecipe = () => { mode, loading, error, + filters, + setFilters, setMode, addIngredient, removeIngredient, @@ -168,6 +170,10 @@ const SearchRecipe = () => { handleAdd(); }; + const toggleFilter = (key) => { + setFilters({ ...filters, [key]: !filters[key] }); + }; + return (
@@ -213,6 +219,48 @@ const SearchRecipe = () => { /> Allow extra ingredients (recipe may contain more) + + {/* Diet and Intolerance filters */} + + toggleFilter("vegetarian")} + /> + Vegetarian + + + toggleFilter("vegan")} + /> + Vegan + + + toggleFilter("lactoseFree")} + /> + Lactose Free + + + toggleFilter("dairyFree")} + /> + Dairy Free + + + toggleFilter("glutenFree")} + /> + Gluten Free + diff --git a/frontend/src/hooks/useRecipeActions.js b/frontend/src/hooks/useRecipeActions.js index 83d69f2bc..2f53c9568 100644 --- a/frontend/src/hooks/useRecipeActions.js +++ b/frontend/src/hooks/useRecipeActions.js @@ -1,11 +1,12 @@ import { useRecipeStore } from "../stores/recipeStore"; +import { useState } from "react"; import { fetchRecipeByIngredients } from "../api/api"; export const useRecipeActions = () => { const { ingredients, recipes, - mode, + mode, loading, error, setRecipes, @@ -16,17 +17,23 @@ export const useRecipeActions = () => { removeIngredient, } = useRecipeStore(); + const [filters, setFilters] = useState({ + vegetarian: false, + vegan: false, + lactoseFree: false, + dairyFree: false, + glutenFree: false, + }); + const searchRecipes = async () => { if (ingredients.length < 1) { setError("Add at least 1 ingredient"); return; } - setLoading(true); setError(null); - try { - const data = await fetchRecipeByIngredients(ingredients, mode); // send mode to API + const data = await fetchRecipeByIngredients(ingredients, mode, filters); setRecipes(data); } catch (err) { setError(err.message); @@ -42,6 +49,8 @@ export const useRecipeActions = () => { mode, loading, error, + filters, + setFilters, setMode, addIngredient, removeIngredient, diff --git a/frontend/src/stores/recipeStore.js b/frontend/src/stores/recipeStore.js index 37e038dd5..0c26f309f 100644 --- a/frontend/src/stores/recipeStore.js +++ b/frontend/src/stores/recipeStore.js @@ -3,11 +3,19 @@ import { create } from "zustand"; export const useRecipeStore = create((set) => ({ ingredients: [], recipes: [], - mode: "allowExtra", + mode: "allowExtra", loading: false, error: null, + filters: { + vegetarian: false, + vegan: false, + lactoseFree: false, + dairyFree: false, + glutenFree: false, + }, - setMode: (mode) => set({ mode }), + setMode: (mode) => set({ mode }), + setFilters: (filters) => set({ filters }), addIngredient: (ing) => set((state) => ({ From 1c91fa7f1d0bc8d42c9d28df16b83887b81d3ad2 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 24 Feb 2026 16:18:31 +0100 Subject: [PATCH 064/127] update navigation to display saved recipes link and logout button when logged in --- frontend/src/App.jsx | 14 ++++---- frontend/src/components/Navigation.jsx | 46 ++++++++++++++++---------- frontend/src/pages/member.jsx | 4 +-- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7748bcb13..63228b61d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -40,12 +40,14 @@ export const App = () => { /> {/* Protected/Auth route */} - : - } - /> + + : + } + /> ( - - - - Home - - - Saved Recipes - - - About - - - Login - - - -); +const Navigation = () => { + const { user, logout } = useUserStore(); + return ( + + + + Home + + + About + + {user && ( + + Saved Recipes + + )} + + {user ? ( + + ) : ( + Login + )} + + + + ); +}; export default Navigation; diff --git a/frontend/src/pages/member.jsx b/frontend/src/pages/member.jsx index 47519951b..0dc0641ae 100644 --- a/frontend/src/pages/member.jsx +++ b/frontend/src/pages/member.jsx @@ -43,9 +43,7 @@ const Member = ({ user, isSigningUp, setIsSigningUp, handleLogin, handleLogout }

Sign up or log in and start discovering recipes based on what you have at home

And save your favorite recipes

- {user ? ( - Logout - ) : ( + {!user && ( {isSigningUp ? ( setIsSigningUp(false)} /> From a51279f8d098c61eef7e43015f785a2d8ed70f0b Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 24 Feb 2026 16:42:58 +0100 Subject: [PATCH 065/127] add title for diet filters and debug netlify deploy --- frontend/src/components/SearchRecipe.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index befdc9d46..ae583b66c 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -221,6 +221,7 @@ const SearchRecipe = () => { {/* Diet and Intolerance filters */} + Diets Date: Tue, 24 Feb 2026 16:46:39 +0100 Subject: [PATCH 066/127] add redirect rule to serve index.html for all routes --- frontend/public/_redirects | 1 + 1 file changed, 1 insertion(+) create mode 100644 frontend/public/_redirects diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 000000000..50a463356 --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file From baf1b7828e84766d9d2c7eb7f3b3c041c66a27c8 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 24 Feb 2026 17:06:17 +0100 Subject: [PATCH 067/127] add dist folder in git ignore for deployment to netlify --- .gitignore | 2 + frontend/dist/PantryMatch.svg | 36 ++ frontend/dist/_redirects | 1 + frontend/dist/assets/index-CJAUjfMN.js | 791 +++++++++++++++++++++++++ frontend/dist/index.html | 13 + frontend/dist/recipe3.png | Bin 0 -> 23917 bytes frontend/dist/vite.svg | 1 + 7 files changed, 844 insertions(+) create mode 100644 frontend/dist/PantryMatch.svg create mode 100644 frontend/dist/_redirects create mode 100644 frontend/dist/assets/index-CJAUjfMN.js create mode 100644 frontend/dist/index.html create mode 100644 frontend/dist/recipe3.png create mode 100644 frontend/dist/vite.svg diff --git a/.gitignore b/.gitignore index 3d70248ba..ba6ce8e67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +# Ignore Vite/React build output +frontend/dist/ node_modules .DS_Store .env diff --git a/frontend/dist/PantryMatch.svg b/frontend/dist/PantryMatch.svg new file mode 100644 index 000000000..d0ebedf25 --- /dev/null +++ b/frontend/dist/PantryMatch.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/dist/_redirects b/frontend/dist/_redirects new file mode 100644 index 000000000..50a463356 --- /dev/null +++ b/frontend/dist/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/frontend/dist/assets/index-CJAUjfMN.js b/frontend/dist/assets/index-CJAUjfMN.js new file mode 100644 index 000000000..b3bdc736f --- /dev/null +++ b/frontend/dist/assets/index-CJAUjfMN.js @@ -0,0 +1,791 @@ +(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))s(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const f of c.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&s(f)}).observe(document,{childList:!0,subtree:!0});function i(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function s(l){if(l.ep)return;l.ep=!0;const c=i(l);fetch(l.href,c)}})();function kg(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var ku={exports:{}},Ji={},Eu={exports:{}},de={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Ep;function H1(){if(Ep)return de;Ep=1;var t=Symbol.for("react.element"),r=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),l=Symbol.for("react.profiler"),c=Symbol.for("react.provider"),f=Symbol.for("react.context"),h=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),m=Symbol.for("react.memo"),g=Symbol.for("react.lazy"),v=Symbol.iterator;function w(D){return D===null||typeof D!="object"?null:(D=v&&D[v]||D["@@iterator"],typeof D=="function"?D:null)}var S={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},T=Object.assign,k={};function C(D,O,le){this.props=D,this.context=O,this.refs=k,this.updater=le||S}C.prototype.isReactComponent={},C.prototype.setState=function(D,O){if(typeof D!="object"&&typeof D!="function"&&D!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,D,O,"setState")},C.prototype.forceUpdate=function(D){this.updater.enqueueForceUpdate(this,D,"forceUpdate")};function j(){}j.prototype=C.prototype;function N(D,O,le){this.props=D,this.context=O,this.refs=k,this.updater=le||S}var M=N.prototype=new j;M.constructor=N,T(M,C.prototype),M.isPureReactComponent=!0;var V=Array.isArray,F=Object.prototype.hasOwnProperty,W={current:null},B={key:!0,ref:!0,__self:!0,__source:!0};function z(D,O,le){var ue,pe={},he=null,xe=null;if(O!=null)for(ue in O.ref!==void 0&&(xe=O.ref),O.key!==void 0&&(he=""+O.key),O)F.call(O,ue)&&!B.hasOwnProperty(ue)&&(pe[ue]=O[ue]);var me=arguments.length-2;if(me===1)pe.children=le;else if(1>>1,O=K[D];if(0>>1;Dl(pe,Y))hel(xe,pe)?(K[D]=xe,K[he]=Y,D=he):(K[D]=pe,K[ue]=Y,D=ue);else if(hel(xe,Y))K[D]=xe,K[he]=Y,D=he;else break e}}return Q}function l(K,Q){var Y=K.sortIndex-Q.sortIndex;return Y!==0?Y:K.id-Q.id}if(typeof performance=="object"&&typeof performance.now=="function"){var c=performance;t.unstable_now=function(){return c.now()}}else{var f=Date,h=f.now();t.unstable_now=function(){return f.now()-h}}var p=[],m=[],g=1,v=null,w=3,S=!1,T=!1,k=!1,C=typeof setTimeout=="function"?setTimeout:null,j=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function M(K){for(var Q=i(m);Q!==null;){if(Q.callback===null)s(m);else if(Q.startTime<=K)s(m),Q.sortIndex=Q.expirationTime,r(p,Q);else break;Q=i(m)}}function V(K){if(k=!1,M(K),!T)if(i(p)!==null)T=!0,Je(F);else{var Q=i(m);Q!==null&&se(V,Q.startTime-K)}}function F(K,Q){T=!1,k&&(k=!1,j(z),z=-1),S=!0;var Y=w;try{for(M(Q),v=i(p);v!==null&&(!(v.expirationTime>Q)||K&&!ae());){var D=v.callback;if(typeof D=="function"){v.callback=null,w=v.priorityLevel;var O=D(v.expirationTime<=Q);Q=t.unstable_now(),typeof O=="function"?v.callback=O:v===i(p)&&s(p),M(Q)}else s(p);v=i(p)}if(v!==null)var le=!0;else{var ue=i(m);ue!==null&&se(V,ue.startTime-Q),le=!1}return le}finally{v=null,w=Y,S=!1}}var W=!1,B=null,z=-1,ie=5,ce=-1;function ae(){return!(t.unstable_now()-ceK||125D?(K.sortIndex=Y,r(m,K),i(p)===null&&K===i(m)&&(k?(j(z),z=-1):k=!0,se(V,Y-D))):(K.sortIndex=O,r(p,K),T||S||(T=!0,Je(F))),K},t.unstable_shouldYield=ae,t.unstable_wrapCallback=function(K){var Q=w;return function(){var Y=w;w=Q;try{return K.apply(this,arguments)}finally{w=Y}}}})(Pu)),Pu}var Ap;function X1(){return Ap||(Ap=1,Tu.exports=Y1()),Tu.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Dp;function Q1(){if(Dp)return vt;Dp=1;var t=_c(),r=X1();function i(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,o=1;o"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),p=Object.prototype.hasOwnProperty,m=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,g={},v={};function w(e){return p.call(v,e)?!0:p.call(g,e)?!1:m.test(e)?v[e]=!0:(g[e]=!0,!1)}function S(e,n,o,a){if(o!==null&&o.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return a?!1:o!==null?!o.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function T(e,n,o,a){if(n===null||typeof n>"u"||S(e,n,o,a))return!0;if(a)return!1;if(o!==null)switch(o.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function k(e,n,o,a,u,d,y){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=a,this.attributeNamespace=u,this.mustUseProperty=o,this.propertyName=e,this.type=n,this.sanitizeURL=d,this.removeEmptyString=y}var C={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){C[e]=new k(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];C[n]=new k(n,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){C[e]=new k(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){C[e]=new k(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){C[e]=new k(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){C[e]=new k(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){C[e]=new k(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){C[e]=new k(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){C[e]=new k(e,5,!1,e.toLowerCase(),null,!1,!1)});var j=/[\-:]([a-z])/g;function N(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(j,N);C[n]=new k(n,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(j,N);C[n]=new k(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(j,N);C[n]=new k(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){C[e]=new k(e,1,!1,e.toLowerCase(),null,!1,!1)}),C.xlinkHref=new k("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){C[e]=new k(e,1,!1,e.toLowerCase(),null,!0,!0)});function M(e,n,o,a){var u=C.hasOwnProperty(n)?C[n]:null;(u!==null?u.type!==0:a||!(2x||u[y]!==d[x]){var E=` +`+u[y].replace(" at new "," at ");return e.displayName&&E.includes("")&&(E=E.replace("",e.displayName)),E}while(1<=y&&0<=x);break}}}finally{le=!1,Error.prepareStackTrace=o}return(e=e?e.displayName||e.name:"")?O(e):""}function pe(e){switch(e.tag){case 5:return O(e.type);case 16:return O("Lazy");case 13:return O("Suspense");case 19:return O("SuspenseList");case 0:case 2:case 15:return e=ue(e.type,!1),e;case 11:return e=ue(e.type.render,!1),e;case 1:return e=ue(e.type,!0),e;default:return""}}function he(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case B:return"Fragment";case W:return"Portal";case ie:return"Profiler";case z:return"StrictMode";case ke:return"Suspense";case $e:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case ae:return(e.displayName||"Context")+".Consumer";case ce:return(e._context.displayName||"Context")+".Provider";case Te:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ye:return n=e.displayName||null,n!==null?n:he(e.type)||"Memo";case Je:n=e._payload,e=e._init;try{return he(e(n))}catch{}}return null}function xe(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return he(n);case 8:return n===z?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function me(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ve(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function Xe(e){var n=ve(e)?"checked":"value",o=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),a=""+e[n];if(!e.hasOwnProperty(n)&&typeof o<"u"&&typeof o.get=="function"&&typeof o.set=="function"){var u=o.get,d=o.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return u.call(this)},set:function(y){a=""+y,d.call(this,y)}}),Object.defineProperty(e,n,{enumerable:o.enumerable}),{getValue:function(){return a},setValue:function(y){a=""+y},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function Yt(e){e._valueTracker||(e._valueTracker=Xe(e))}function Tt(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var o=n.getValue(),a="";return e&&(a=ve(e)?e.checked?"true":"false":e.value),e=a,e!==o?(n.setValue(e),!0):!1}function xn(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Sn(e,n){var o=n.checked;return Y({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:o??e._wrapperState.initialChecked})}function sn(e,n){var o=n.defaultValue==null?"":n.defaultValue,a=n.checked!=null?n.checked:n.defaultChecked;o=me(n.value!=null?n.value:o),e._wrapperState={initialChecked:a,initialValue:o,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}function Mf(e,n){n=n.checked,n!=null&&M(e,"checked",n,!1)}function La(e,n){Mf(e,n);var o=me(n.value),a=n.type;if(o!=null)a==="number"?(o===0&&e.value===""||e.value!=o)&&(e.value=""+o):e.value!==""+o&&(e.value=""+o);else if(a==="submit"||a==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?Ma(e,n.type,o):n.hasOwnProperty("defaultValue")&&Ma(e,n.type,me(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function If(e,n,o){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var a=n.type;if(!(a!=="submit"&&a!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,o||n===e.value||(e.value=n),e.defaultValue=n}o=e.name,o!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,o!==""&&(e.name=o)}function Ma(e,n,o){(n!=="number"||xn(e.ownerDocument)!==e)&&(o==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+o&&(e.defaultValue=""+o))}var pi=Array.isArray;function Sr(e,n,o,a){if(e=e.options,n){n={};for(var u=0;u"+n.valueOf().toString()+"",n=Do.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function mi(e,n){if(n){var o=e.firstChild;if(o&&o===e.lastChild&&o.nodeType===3){o.nodeValue=n;return}}e.textContent=n}var gi={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Xv=["Webkit","ms","Moz","O"];Object.keys(gi).forEach(function(e){Xv.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),gi[n]=gi[e]})});function Of(e,n,o){return n==null||typeof n=="boolean"||n===""?"":o||typeof n!="number"||n===0||gi.hasOwnProperty(e)&&gi[e]?(""+n).trim():n+"px"}function zf(e,n){e=e.style;for(var o in n)if(n.hasOwnProperty(o)){var a=o.indexOf("--")===0,u=Of(o,n[o],a);o==="float"&&(o="cssFloat"),a?e.setProperty(o,u):e[o]=u}}var Qv=Y({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function _a(e,n){if(n){if(Qv[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(i(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(i(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(i(61))}if(n.style!=null&&typeof n.style!="object")throw Error(i(62))}}function Na(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Va=null;function Fa(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Oa=null,kr=null,Er=null;function bf(e){if(e=Oi(e)){if(typeof Oa!="function")throw Error(i(280));var n=e.stateNode;n&&(n=qo(n),Oa(e.stateNode,e.type,n))}}function Bf(e){kr?Er?Er.push(e):Er=[e]:kr=e}function $f(){if(kr){var e=kr,n=Er;if(Er=kr=null,bf(e),n)for(e=0;e>>=0,e===0?32:31-(a0(e)/l0|0)|0}var _o=64,No=4194304;function xi(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Vo(e,n){var o=e.pendingLanes;if(o===0)return 0;var a=0,u=e.suspendedLanes,d=e.pingedLanes,y=o&268435455;if(y!==0){var x=y&~u;x!==0?a=xi(x):(d&=y,d!==0&&(a=xi(d)))}else y=o&~u,y!==0?a=xi(y):d!==0&&(a=xi(d));if(a===0)return 0;if(n!==0&&n!==a&&(n&u)===0&&(u=a&-a,d=n&-n,u>=d||u===16&&(d&4194240)!==0))return n;if((a&4)!==0&&(a|=o&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=a;0o;o++)n.push(e);return n}function Si(e,n,o){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-Nt(n),e[n]=o}function d0(e,n){var o=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var a=e.eventTimes;for(e=e.expirationTimes;0=Di),gd=" ",yd=!1;function vd(e,n){switch(e){case"keyup":return b0.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function wd(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Pr=!1;function $0(e,n){switch(e){case"compositionend":return wd(n);case"keypress":return n.which!==32?null:(yd=!0,gd);case"textInput":return e=n.data,e===gd&&yd?null:e;default:return null}}function U0(e,n){if(Pr)return e==="compositionend"||!nl&&vd(e,n)?(e=cd(),Bo=Qa=Pn=null,Pr=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:o,offset:n-e};e=a}e:{for(;o;){if(o.nextSibling){o=o.nextSibling;break e}o=o.parentNode}o=void 0}o=Pd(o)}}function Ad(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?Ad(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function Dd(){for(var e=window,n=xn();n instanceof e.HTMLIFrameElement;){try{var o=typeof n.contentWindow.location.href=="string"}catch{o=!1}if(o)e=n.contentWindow;else break;n=xn(e.document)}return n}function ol(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function q0(e){var n=Dd(),o=e.focusedElem,a=e.selectionRange;if(n!==o&&o&&o.ownerDocument&&Ad(o.ownerDocument.documentElement,o)){if(a!==null&&ol(o)){if(n=a.start,e=a.end,e===void 0&&(e=n),"selectionStart"in o)o.selectionStart=n,o.selectionEnd=Math.min(e,o.value.length);else if(e=(n=o.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var u=o.textContent.length,d=Math.min(a.start,u);a=a.end===void 0?d:Math.min(a.end,u),!e.extend&&d>a&&(u=a,a=d,d=u),u=Rd(o,d);var y=Rd(o,a);u&&y&&(e.rangeCount!==1||e.anchorNode!==u.node||e.anchorOffset!==u.offset||e.focusNode!==y.node||e.focusOffset!==y.offset)&&(n=n.createRange(),n.setStart(u.node,u.offset),e.removeAllRanges(),d>a?(e.addRange(n),e.extend(y.node,y.offset)):(n.setEnd(y.node,y.offset),e.addRange(n)))}}for(n=[],e=o;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof o.focus=="function"&&o.focus(),o=0;o=document.documentMode,Rr=null,sl=null,ji=null,al=!1;function Ld(e,n,o){var a=o.window===o?o.document:o.nodeType===9?o:o.ownerDocument;al||Rr==null||Rr!==xn(a)||(a=Rr,"selectionStart"in a&&ol(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),ji&&Ii(ji,a)||(ji=a,a=Xo(sl,"onSelect"),0Ir||(e.current=wl[Ir],wl[Ir]=null,Ir--)}function Ee(e,n){Ir++,wl[Ir]=e.current,e.current=n}var Ln={},rt=Dn(Ln),ht=Dn(!1),Jn=Ln;function jr(e,n){var o=e.type.contextTypes;if(!o)return Ln;var a=e.stateNode;if(a&&a.__reactInternalMemoizedUnmaskedChildContext===n)return a.__reactInternalMemoizedMaskedChildContext;var u={},d;for(d in o)u[d]=n[d];return a&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=u),u}function pt(e){return e=e.childContextTypes,e!=null}function Jo(){Re(ht),Re(rt)}function Hd(e,n,o){if(rt.current!==Ln)throw Error(i(168));Ee(rt,n),Ee(ht,o)}function Kd(e,n,o){var a=e.stateNode;if(n=n.childContextTypes,typeof a.getChildContext!="function")return o;a=a.getChildContext();for(var u in a)if(!(u in n))throw Error(i(108,xe(e)||"Unknown",u));return Y({},o,a)}function es(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ln,Jn=rt.current,Ee(rt,e),Ee(ht,ht.current),!0}function Gd(e,n,o){var a=e.stateNode;if(!a)throw Error(i(169));o?(e=Kd(e,n,Jn),a.__reactInternalMemoizedMergedChildContext=e,Re(ht),Re(rt),Ee(rt,e)):Re(ht),Ee(ht,o)}var ln=null,ts=!1,xl=!1;function Yd(e){ln===null?ln=[e]:ln.push(e)}function c1(e){ts=!0,Yd(e)}function Mn(){if(!xl&&ln!==null){xl=!0;var e=0,n=Se;try{var o=ln;for(Se=1;e>=y,u-=y,un=1<<32-Nt(n)+u|o<oe?(qe=re,re=null):qe=re.sibling;var ye=b(L,re,I[oe],H);if(ye===null){re===null&&(re=qe);break}e&&re&&ye.alternate===null&&n(L,re),P=d(ye,P,oe),ne===null?te=ye:ne.sibling=ye,ne=ye,re=qe}if(oe===I.length)return o(L,re),De&&tr(L,oe),te;if(re===null){for(;oeoe?(qe=re,re=null):qe=re.sibling;var bn=b(L,re,ye.value,H);if(bn===null){re===null&&(re=qe);break}e&&re&&bn.alternate===null&&n(L,re),P=d(bn,P,oe),ne===null?te=bn:ne.sibling=bn,ne=bn,re=qe}if(ye.done)return o(L,re),De&&tr(L,oe),te;if(re===null){for(;!ye.done;oe++,ye=I.next())ye=U(L,ye.value,H),ye!==null&&(P=d(ye,P,oe),ne===null?te=ye:ne.sibling=ye,ne=ye);return De&&tr(L,oe),te}for(re=a(L,re);!ye.done;oe++,ye=I.next())ye=X(re,L,oe,ye.value,H),ye!==null&&(e&&ye.alternate!==null&&re.delete(ye.key===null?oe:ye.key),P=d(ye,P,oe),ne===null?te=ye:ne.sibling=ye,ne=ye);return e&&re.forEach(function(W1){return n(L,W1)}),De&&tr(L,oe),te}function Fe(L,P,I,H){if(typeof I=="object"&&I!==null&&I.type===B&&I.key===null&&(I=I.props.children),typeof I=="object"&&I!==null){switch(I.$$typeof){case F:e:{for(var te=I.key,ne=P;ne!==null;){if(ne.key===te){if(te=I.type,te===B){if(ne.tag===7){o(L,ne.sibling),P=u(ne,I.props.children),P.return=L,L=P;break e}}else if(ne.elementType===te||typeof te=="object"&&te!==null&&te.$$typeof===Je&&eh(te)===ne.type){o(L,ne.sibling),P=u(ne,I.props),P.ref=zi(L,ne,I),P.return=L,L=P;break e}o(L,ne);break}else n(L,ne);ne=ne.sibling}I.type===B?(P=ur(I.props.children,L.mode,H,I.key),P.return=L,L=P):(H=Ds(I.type,I.key,I.props,null,L.mode,H),H.ref=zi(L,P,I),H.return=L,L=H)}return y(L);case W:e:{for(ne=I.key;P!==null;){if(P.key===ne)if(P.tag===4&&P.stateNode.containerInfo===I.containerInfo&&P.stateNode.implementation===I.implementation){o(L,P.sibling),P=u(P,I.children||[]),P.return=L,L=P;break e}else{o(L,P);break}else n(L,P);P=P.sibling}P=yu(I,L.mode,H),P.return=L,L=P}return y(L);case Je:return ne=I._init,Fe(L,P,ne(I._payload),H)}if(pi(I))return q(L,P,I,H);if(Q(I))return ee(L,P,I,H);os(L,I)}return typeof I=="string"&&I!==""||typeof I=="number"?(I=""+I,P!==null&&P.tag===6?(o(L,P.sibling),P=u(P,I),P.return=L,L=P):(o(L,P),P=gu(I,L.mode,H),P.return=L,L=P),y(L)):o(L,P)}return Fe}var Fr=th(!0),nh=th(!1),ss=Dn(null),as=null,Or=null,Pl=null;function Rl(){Pl=Or=as=null}function Al(e){var n=ss.current;Re(ss),e._currentValue=n}function Dl(e,n,o){for(;e!==null;){var a=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,a!==null&&(a.childLanes|=n)):a!==null&&(a.childLanes&n)!==n&&(a.childLanes|=n),e===o)break;e=e.return}}function zr(e,n){as=e,Pl=Or=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&n)!==0&&(mt=!0),e.firstContext=null)}function At(e){var n=e._currentValue;if(Pl!==e)if(e={context:e,memoizedValue:n,next:null},Or===null){if(as===null)throw Error(i(308));Or=e,as.dependencies={lanes:0,firstContext:e}}else Or=Or.next=e;return n}var nr=null;function Ll(e){nr===null?nr=[e]:nr.push(e)}function rh(e,n,o,a){var u=n.interleaved;return u===null?(o.next=o,Ll(n)):(o.next=u.next,u.next=o),n.interleaved=o,fn(e,a)}function fn(e,n){e.lanes|=n;var o=e.alternate;for(o!==null&&(o.lanes|=n),o=e,e=e.return;e!==null;)e.childLanes|=n,o=e.alternate,o!==null&&(o.childLanes|=n),o=e,e=e.return;return o.tag===3?o.stateNode:null}var In=!1;function Ml(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ih(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function dn(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function jn(e,n,o){var a=e.updateQueue;if(a===null)return null;if(a=a.shared,(ge&2)!==0){var u=a.pending;return u===null?n.next=n:(n.next=u.next,u.next=n),a.pending=n,fn(e,o)}return u=a.interleaved,u===null?(n.next=n,Ll(a)):(n.next=u.next,u.next=n),a.interleaved=n,fn(e,o)}function ls(e,n,o){if(n=n.updateQueue,n!==null&&(n=n.shared,(o&4194240)!==0)){var a=n.lanes;a&=e.pendingLanes,o|=a,n.lanes=o,Ha(e,o)}}function oh(e,n){var o=e.updateQueue,a=e.alternate;if(a!==null&&(a=a.updateQueue,o===a)){var u=null,d=null;if(o=o.firstBaseUpdate,o!==null){do{var y={eventTime:o.eventTime,lane:o.lane,tag:o.tag,payload:o.payload,callback:o.callback,next:null};d===null?u=d=y:d=d.next=y,o=o.next}while(o!==null);d===null?u=d=n:d=d.next=n}else u=d=n;o={baseState:a.baseState,firstBaseUpdate:u,lastBaseUpdate:d,shared:a.shared,effects:a.effects},e.updateQueue=o;return}e=o.lastBaseUpdate,e===null?o.firstBaseUpdate=n:e.next=n,o.lastBaseUpdate=n}function us(e,n,o,a){var u=e.updateQueue;In=!1;var d=u.firstBaseUpdate,y=u.lastBaseUpdate,x=u.shared.pending;if(x!==null){u.shared.pending=null;var E=x,_=E.next;E.next=null,y===null?d=_:y.next=_,y=E;var $=e.alternate;$!==null&&($=$.updateQueue,x=$.lastBaseUpdate,x!==y&&(x===null?$.firstBaseUpdate=_:x.next=_,$.lastBaseUpdate=E))}if(d!==null){var U=u.baseState;y=0,$=_=E=null,x=d;do{var b=x.lane,X=x.eventTime;if((a&b)===b){$!==null&&($=$.next={eventTime:X,lane:0,tag:x.tag,payload:x.payload,callback:x.callback,next:null});e:{var q=e,ee=x;switch(b=n,X=o,ee.tag){case 1:if(q=ee.payload,typeof q=="function"){U=q.call(X,U,b);break e}U=q;break e;case 3:q.flags=q.flags&-65537|128;case 0:if(q=ee.payload,b=typeof q=="function"?q.call(X,U,b):q,b==null)break e;U=Y({},U,b);break e;case 2:In=!0}}x.callback!==null&&x.lane!==0&&(e.flags|=64,b=u.effects,b===null?u.effects=[x]:b.push(x))}else X={eventTime:X,lane:b,tag:x.tag,payload:x.payload,callback:x.callback,next:null},$===null?(_=$=X,E=U):$=$.next=X,y|=b;if(x=x.next,x===null){if(x=u.shared.pending,x===null)break;b=x,x=b.next,b.next=null,u.lastBaseUpdate=b,u.shared.pending=null}}while(!0);if($===null&&(E=U),u.baseState=E,u.firstBaseUpdate=_,u.lastBaseUpdate=$,n=u.shared.interleaved,n!==null){u=n;do y|=u.lane,u=u.next;while(u!==n)}else d===null&&(u.shared.lanes=0);or|=y,e.lanes=y,e.memoizedState=U}}function sh(e,n,o){if(e=n.effects,n.effects=null,e!==null)for(n=0;no?o:4,e(!0);var a=Vl.transition;Vl.transition={};try{e(!1),n()}finally{Se=o,Vl.transition=a}}function Th(){return Dt().memoizedState}function p1(e,n,o){var a=Fn(e);if(o={lane:a,action:o,hasEagerState:!1,eagerState:null,next:null},Ph(e))Rh(n,o);else if(o=rh(e,n,o,a),o!==null){var u=ct();Bt(o,e,a,u),Ah(o,n,a)}}function m1(e,n,o){var a=Fn(e),u={lane:a,action:o,hasEagerState:!1,eagerState:null,next:null};if(Ph(e))Rh(n,u);else{var d=e.alternate;if(e.lanes===0&&(d===null||d.lanes===0)&&(d=n.lastRenderedReducer,d!==null))try{var y=n.lastRenderedState,x=d(y,o);if(u.hasEagerState=!0,u.eagerState=x,Vt(x,y)){var E=n.interleaved;E===null?(u.next=u,Ll(n)):(u.next=E.next,E.next=u),n.interleaved=u;return}}catch{}finally{}o=rh(e,n,u,a),o!==null&&(u=ct(),Bt(o,e,a,u),Ah(o,n,a))}}function Ph(e){var n=e.alternate;return e===Ie||n!==null&&n===Ie}function Rh(e,n){Ui=ds=!0;var o=e.pending;o===null?n.next=n:(n.next=o.next,o.next=n),e.pending=n}function Ah(e,n,o){if((o&4194240)!==0){var a=n.lanes;a&=e.pendingLanes,o|=a,n.lanes=o,Ha(e,o)}}var ms={readContext:At,useCallback:it,useContext:it,useEffect:it,useImperativeHandle:it,useInsertionEffect:it,useLayoutEffect:it,useMemo:it,useReducer:it,useRef:it,useState:it,useDebugValue:it,useDeferredValue:it,useTransition:it,useMutableSource:it,useSyncExternalStore:it,useId:it,unstable_isNewReconciler:!1},g1={readContext:At,useCallback:function(e,n){return qt().memoizedState=[e,n===void 0?null:n],e},useContext:At,useEffect:yh,useImperativeHandle:function(e,n,o){return o=o!=null?o.concat([e]):null,hs(4194308,4,xh.bind(null,n,e),o)},useLayoutEffect:function(e,n){return hs(4194308,4,e,n)},useInsertionEffect:function(e,n){return hs(4,2,e,n)},useMemo:function(e,n){var o=qt();return n=n===void 0?null:n,e=e(),o.memoizedState=[e,n],e},useReducer:function(e,n,o){var a=qt();return n=o!==void 0?o(n):n,a.memoizedState=a.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},a.queue=e,e=e.dispatch=p1.bind(null,Ie,e),[a.memoizedState,e]},useRef:function(e){var n=qt();return e={current:e},n.memoizedState=e},useState:mh,useDebugValue:Ul,useDeferredValue:function(e){return qt().memoizedState=e},useTransition:function(){var e=mh(!1),n=e[0];return e=h1.bind(null,e[1]),qt().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,o){var a=Ie,u=qt();if(De){if(o===void 0)throw Error(i(407));o=o()}else{if(o=n(),Ze===null)throw Error(i(349));(ir&30)!==0||ch(a,n,o)}u.memoizedState=o;var d={value:o,getSnapshot:n};return u.queue=d,yh(dh.bind(null,a,d,e),[e]),a.flags|=2048,Ki(9,fh.bind(null,a,d,o,n),void 0,null),o},useId:function(){var e=qt(),n=Ze.identifierPrefix;if(De){var o=cn,a=un;o=(a&~(1<<32-Nt(a)-1)).toString(32)+o,n=":"+n+"R"+o,o=Wi++,0<\/script>",e=e.removeChild(e.firstChild)):typeof a.is=="string"?e=y.createElement(o,{is:a.is}):(e=y.createElement(o),o==="select"&&(y=e,a.multiple?y.multiple=!0:a.size&&(y.size=a.size))):e=y.createElementNS(e,o),e[Qt]=n,e[Fi]=a,Yh(e,n,!1,!1),n.stateNode=e;e:{switch(y=Na(o,a),o){case"dialog":Pe("cancel",e),Pe("close",e),u=a;break;case"iframe":case"object":case"embed":Pe("load",e),u=a;break;case"video":case"audio":for(u=0;u<_i.length;u++)Pe(_i[u],e);u=a;break;case"source":Pe("error",e),u=a;break;case"img":case"image":case"link":Pe("error",e),Pe("load",e),u=a;break;case"details":Pe("toggle",e),u=a;break;case"input":sn(e,a),u=Sn(e,a),Pe("invalid",e);break;case"option":u=a;break;case"select":e._wrapperState={wasMultiple:!!a.multiple},u=Y({},a,{value:void 0}),Pe("invalid",e);break;case"textarea":jf(e,a),u=Ia(e,a),Pe("invalid",e);break;default:u=a}_a(o,u),x=u;for(d in x)if(x.hasOwnProperty(d)){var E=x[d];d==="style"?zf(e,E):d==="dangerouslySetInnerHTML"?(E=E?E.__html:void 0,E!=null&&Ff(e,E)):d==="children"?typeof E=="string"?(o!=="textarea"||E!=="")&&mi(e,E):typeof E=="number"&&mi(e,""+E):d!=="suppressContentEditableWarning"&&d!=="suppressHydrationWarning"&&d!=="autoFocus"&&(l.hasOwnProperty(d)?E!=null&&d==="onScroll"&&Pe("scroll",e):E!=null&&M(e,d,E,y))}switch(o){case"input":Yt(e),If(e,a,!1);break;case"textarea":Yt(e),Nf(e);break;case"option":a.value!=null&&e.setAttribute("value",""+me(a.value));break;case"select":e.multiple=!!a.multiple,d=a.value,d!=null?Sr(e,!!a.multiple,d,!1):a.defaultValue!=null&&Sr(e,!!a.multiple,a.defaultValue,!0);break;default:typeof u.onClick=="function"&&(e.onclick=Zo)}switch(o){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break e;case"img":a=!0;break e;default:a=!1}}a&&(n.flags|=4)}n.ref!==null&&(n.flags|=512,n.flags|=2097152)}return ot(n),null;case 6:if(e&&n.stateNode!=null)Qh(e,n,e.memoizedProps,a);else{if(typeof a!="string"&&n.stateNode===null)throw Error(i(166));if(o=rr($i.current),rr(Zt.current),is(n)){if(a=n.stateNode,o=n.memoizedProps,a[Qt]=n,(d=a.nodeValue!==o)&&(e=xt,e!==null))switch(e.tag){case 3:Qo(a.nodeValue,o,(e.mode&1)!==0);break;case 5:e.memoizedProps.suppressHydrationWarning!==!0&&Qo(a.nodeValue,o,(e.mode&1)!==0)}d&&(n.flags|=4)}else a=(o.nodeType===9?o:o.ownerDocument).createTextNode(a),a[Qt]=n,n.stateNode=a}return ot(n),null;case 13:if(Re(Me),a=n.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(De&&St!==null&&(n.mode&1)!==0&&(n.flags&128)===0)Jd(),Vr(),n.flags|=98560,d=!1;else if(d=is(n),a!==null&&a.dehydrated!==null){if(e===null){if(!d)throw Error(i(318));if(d=n.memoizedState,d=d!==null?d.dehydrated:null,!d)throw Error(i(317));d[Qt]=n}else Vr(),(n.flags&128)===0&&(n.memoizedState=null),n.flags|=4;ot(n),d=!1}else Ft!==null&&(fu(Ft),Ft=null),d=!0;if(!d)return n.flags&65536?n:null}return(n.flags&128)!==0?(n.lanes=o,n):(a=a!==null,a!==(e!==null&&e.memoizedState!==null)&&a&&(n.child.flags|=8192,(n.mode&1)!==0&&(e===null||(Me.current&1)!==0?We===0&&(We=3):pu())),n.updateQueue!==null&&(n.flags|=4),ot(n),null);case 4:return br(),eu(e,n),e===null&&Ni(n.stateNode.containerInfo),ot(n),null;case 10:return Al(n.type._context),ot(n),null;case 17:return pt(n.type)&&Jo(),ot(n),null;case 19:if(Re(Me),d=n.memoizedState,d===null)return ot(n),null;if(a=(n.flags&128)!==0,y=d.rendering,y===null)if(a)Gi(d,!1);else{if(We!==0||e!==null&&(e.flags&128)!==0)for(e=n.child;e!==null;){if(y=cs(e),y!==null){for(n.flags|=128,Gi(d,!1),a=y.updateQueue,a!==null&&(n.updateQueue=a,n.flags|=4),n.subtreeFlags=0,a=o,o=n.child;o!==null;)d=o,e=a,d.flags&=14680066,y=d.alternate,y===null?(d.childLanes=0,d.lanes=e,d.child=null,d.subtreeFlags=0,d.memoizedProps=null,d.memoizedState=null,d.updateQueue=null,d.dependencies=null,d.stateNode=null):(d.childLanes=y.childLanes,d.lanes=y.lanes,d.child=y.child,d.subtreeFlags=0,d.deletions=null,d.memoizedProps=y.memoizedProps,d.memoizedState=y.memoizedState,d.updateQueue=y.updateQueue,d.type=y.type,e=y.dependencies,d.dependencies=e===null?null:{lanes:e.lanes,firstContext:e.firstContext}),o=o.sibling;return Ee(Me,Me.current&1|2),n.child}e=e.sibling}d.tail!==null&&Ve()>Wr&&(n.flags|=128,a=!0,Gi(d,!1),n.lanes=4194304)}else{if(!a)if(e=cs(y),e!==null){if(n.flags|=128,a=!0,o=e.updateQueue,o!==null&&(n.updateQueue=o,n.flags|=4),Gi(d,!0),d.tail===null&&d.tailMode==="hidden"&&!y.alternate&&!De)return ot(n),null}else 2*Ve()-d.renderingStartTime>Wr&&o!==1073741824&&(n.flags|=128,a=!0,Gi(d,!1),n.lanes=4194304);d.isBackwards?(y.sibling=n.child,n.child=y):(o=d.last,o!==null?o.sibling=y:n.child=y,d.last=y)}return d.tail!==null?(n=d.tail,d.rendering=n,d.tail=n.sibling,d.renderingStartTime=Ve(),n.sibling=null,o=Me.current,Ee(Me,a?o&1|2:o&1),n):(ot(n),null);case 22:case 23:return hu(),a=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==a&&(n.flags|=8192),a&&(n.mode&1)!==0?(kt&1073741824)!==0&&(ot(n),n.subtreeFlags&6&&(n.flags|=8192)):ot(n),null;case 24:return null;case 25:return null}throw Error(i(156,n.tag))}function C1(e,n){switch(kl(n),n.tag){case 1:return pt(n.type)&&Jo(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return br(),Re(ht),Re(rt),Nl(),e=n.flags,(e&65536)!==0&&(e&128)===0?(n.flags=e&-65537|128,n):null;case 5:return jl(n),null;case 13:if(Re(Me),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(i(340));Vr()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return Re(Me),null;case 4:return br(),null;case 10:return Al(n.type._context),null;case 22:case 23:return hu(),null;case 24:return null;default:return null}}var ws=!1,st=!1,T1=typeof WeakSet=="function"?WeakSet:Set,Z=null;function $r(e,n){var o=e.ref;if(o!==null)if(typeof o=="function")try{o(null)}catch(a){_e(e,n,a)}else o.current=null}function tu(e,n,o){try{o()}catch(a){_e(e,n,a)}}var Zh=!1;function P1(e,n){if(hl=zo,e=Dd(),ol(e)){if("selectionStart"in e)var o={start:e.selectionStart,end:e.selectionEnd};else e:{o=(o=e.ownerDocument)&&o.defaultView||window;var a=o.getSelection&&o.getSelection();if(a&&a.rangeCount!==0){o=a.anchorNode;var u=a.anchorOffset,d=a.focusNode;a=a.focusOffset;try{o.nodeType,d.nodeType}catch{o=null;break e}var y=0,x=-1,E=-1,_=0,$=0,U=e,b=null;t:for(;;){for(var X;U!==o||u!==0&&U.nodeType!==3||(x=y+u),U!==d||a!==0&&U.nodeType!==3||(E=y+a),U.nodeType===3&&(y+=U.nodeValue.length),(X=U.firstChild)!==null;)b=U,U=X;for(;;){if(U===e)break t;if(b===o&&++_===u&&(x=y),b===d&&++$===a&&(E=y),(X=U.nextSibling)!==null)break;U=b,b=U.parentNode}U=X}o=x===-1||E===-1?null:{start:x,end:E}}else o=null}o=o||{start:0,end:0}}else o=null;for(pl={focusedElem:e,selectionRange:o},zo=!1,Z=n;Z!==null;)if(n=Z,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,Z=e;else for(;Z!==null;){n=Z;try{var q=n.alternate;if((n.flags&1024)!==0)switch(n.tag){case 0:case 11:case 15:break;case 1:if(q!==null){var ee=q.memoizedProps,Fe=q.memoizedState,L=n.stateNode,P=L.getSnapshotBeforeUpdate(n.elementType===n.type?ee:Ot(n.type,ee),Fe);L.__reactInternalSnapshotBeforeUpdate=P}break;case 3:var I=n.stateNode.containerInfo;I.nodeType===1?I.textContent="":I.nodeType===9&&I.documentElement&&I.removeChild(I.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(i(163))}}catch(H){_e(n,n.return,H)}if(e=n.sibling,e!==null){e.return=n.return,Z=e;break}Z=n.return}return q=Zh,Zh=!1,q}function Yi(e,n,o){var a=n.updateQueue;if(a=a!==null?a.lastEffect:null,a!==null){var u=a=a.next;do{if((u.tag&e)===e){var d=u.destroy;u.destroy=void 0,d!==void 0&&tu(n,o,d)}u=u.next}while(u!==a)}}function xs(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var o=n=n.next;do{if((o.tag&e)===e){var a=o.create;o.destroy=a()}o=o.next}while(o!==n)}}function nu(e){var n=e.ref;if(n!==null){var o=e.stateNode;switch(e.tag){case 5:e=o;break;default:e=o}typeof n=="function"?n(e):n.current=e}}function qh(e){var n=e.alternate;n!==null&&(e.alternate=null,qh(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[Qt],delete n[Fi],delete n[vl],delete n[l1],delete n[u1])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Jh(e){return e.tag===5||e.tag===3||e.tag===4}function ep(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Jh(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function ru(e,n,o){var a=e.tag;if(a===5||a===6)e=e.stateNode,n?o.nodeType===8?o.parentNode.insertBefore(e,n):o.insertBefore(e,n):(o.nodeType===8?(n=o.parentNode,n.insertBefore(e,o)):(n=o,n.appendChild(e)),o=o._reactRootContainer,o!=null||n.onclick!==null||(n.onclick=Zo));else if(a!==4&&(e=e.child,e!==null))for(ru(e,n,o),e=e.sibling;e!==null;)ru(e,n,o),e=e.sibling}function iu(e,n,o){var a=e.tag;if(a===5||a===6)e=e.stateNode,n?o.insertBefore(e,n):o.appendChild(e);else if(a!==4&&(e=e.child,e!==null))for(iu(e,n,o),e=e.sibling;e!==null;)iu(e,n,o),e=e.sibling}var et=null,zt=!1;function _n(e,n,o){for(o=o.child;o!==null;)tp(e,n,o),o=o.sibling}function tp(e,n,o){if(Xt&&typeof Xt.onCommitFiberUnmount=="function")try{Xt.onCommitFiberUnmount(jo,o)}catch{}switch(o.tag){case 5:st||$r(o,n);case 6:var a=et,u=zt;et=null,_n(e,n,o),et=a,zt=u,et!==null&&(zt?(e=et,o=o.stateNode,e.nodeType===8?e.parentNode.removeChild(o):e.removeChild(o)):et.removeChild(o.stateNode));break;case 18:et!==null&&(zt?(e=et,o=o.stateNode,e.nodeType===8?yl(e.parentNode,o):e.nodeType===1&&yl(e,o),Pi(e)):yl(et,o.stateNode));break;case 4:a=et,u=zt,et=o.stateNode.containerInfo,zt=!0,_n(e,n,o),et=a,zt=u;break;case 0:case 11:case 14:case 15:if(!st&&(a=o.updateQueue,a!==null&&(a=a.lastEffect,a!==null))){u=a=a.next;do{var d=u,y=d.destroy;d=d.tag,y!==void 0&&((d&2)!==0||(d&4)!==0)&&tu(o,n,y),u=u.next}while(u!==a)}_n(e,n,o);break;case 1:if(!st&&($r(o,n),a=o.stateNode,typeof a.componentWillUnmount=="function"))try{a.props=o.memoizedProps,a.state=o.memoizedState,a.componentWillUnmount()}catch(x){_e(o,n,x)}_n(e,n,o);break;case 21:_n(e,n,o);break;case 22:o.mode&1?(st=(a=st)||o.memoizedState!==null,_n(e,n,o),st=a):_n(e,n,o);break;default:_n(e,n,o)}}function np(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var o=e.stateNode;o===null&&(o=e.stateNode=new T1),n.forEach(function(a){var u=N1.bind(null,e,a);o.has(a)||(o.add(a),a.then(u,u))})}}function bt(e,n){var o=n.deletions;if(o!==null)for(var a=0;au&&(u=y),a&=~d}if(a=u,a=Ve()-a,a=(120>a?120:480>a?480:1080>a?1080:1920>a?1920:3e3>a?3e3:4320>a?4320:1960*A1(a/1960))-a,10e?16:e,Vn===null)var a=!1;else{if(e=Vn,Vn=null,Ts=0,(ge&6)!==0)throw Error(i(331));var u=ge;for(ge|=4,Z=e.current;Z!==null;){var d=Z,y=d.child;if((Z.flags&16)!==0){var x=d.deletions;if(x!==null){for(var E=0;EVe()-au?ar(e,0):su|=o),yt(e,n)}function mp(e,n){n===0&&((e.mode&1)===0?n=1:(n=No,No<<=1,(No&130023424)===0&&(No=4194304)));var o=ct();e=fn(e,n),e!==null&&(Si(e,n,o),yt(e,o))}function _1(e){var n=e.memoizedState,o=0;n!==null&&(o=n.retryLane),mp(e,o)}function N1(e,n){var o=0;switch(e.tag){case 13:var a=e.stateNode,u=e.memoizedState;u!==null&&(o=u.retryLane);break;case 19:a=e.stateNode;break;default:throw Error(i(314))}a!==null&&a.delete(n),mp(e,o)}var gp;gp=function(e,n,o){if(e!==null)if(e.memoizedProps!==n.pendingProps||ht.current)mt=!0;else{if((e.lanes&o)===0&&(n.flags&128)===0)return mt=!1,k1(e,n,o);mt=(e.flags&131072)!==0}else mt=!1,De&&(n.flags&1048576)!==0&&Xd(n,rs,n.index);switch(n.lanes=0,n.tag){case 2:var a=n.type;vs(e,n),e=n.pendingProps;var u=jr(n,rt.current);zr(n,o),u=Ol(null,n,a,e,u,o);var d=zl();return n.flags|=1,typeof u=="object"&&u!==null&&typeof u.render=="function"&&u.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,pt(a)?(d=!0,es(n)):d=!1,n.memoizedState=u.state!==null&&u.state!==void 0?u.state:null,Ml(n),u.updater=gs,n.stateNode=u,u._reactInternals=n,Hl(n,a,e,o),n=Xl(null,n,a,!0,d,o)):(n.tag=0,De&&d&&Sl(n),ut(null,n,u,o),n=n.child),n;case 16:a=n.elementType;e:{switch(vs(e,n),e=n.pendingProps,u=a._init,a=u(a._payload),n.type=a,u=n.tag=F1(a),e=Ot(a,e),u){case 0:n=Yl(null,n,a,e,o);break e;case 1:n=$h(null,n,a,e,o);break e;case 11:n=Fh(null,n,a,e,o);break e;case 14:n=Oh(null,n,a,Ot(a.type,e),o);break e}throw Error(i(306,a,""))}return n;case 0:return a=n.type,u=n.pendingProps,u=n.elementType===a?u:Ot(a,u),Yl(e,n,a,u,o);case 1:return a=n.type,u=n.pendingProps,u=n.elementType===a?u:Ot(a,u),$h(e,n,a,u,o);case 3:e:{if(Uh(n),e===null)throw Error(i(387));a=n.pendingProps,d=n.memoizedState,u=d.element,ih(e,n),us(n,a,null,o);var y=n.memoizedState;if(a=y.element,d.isDehydrated)if(d={element:a,isDehydrated:!1,cache:y.cache,pendingSuspenseBoundaries:y.pendingSuspenseBoundaries,transitions:y.transitions},n.updateQueue.baseState=d,n.memoizedState=d,n.flags&256){u=Br(Error(i(423)),n),n=Wh(e,n,a,o,u);break e}else if(a!==u){u=Br(Error(i(424)),n),n=Wh(e,n,a,o,u);break e}else for(St=An(n.stateNode.containerInfo.firstChild),xt=n,De=!0,Ft=null,o=nh(n,null,a,o),n.child=o;o;)o.flags=o.flags&-3|4096,o=o.sibling;else{if(Vr(),a===u){n=hn(e,n,o);break e}ut(e,n,a,o)}n=n.child}return n;case 5:return ah(n),e===null&&Cl(n),a=n.type,u=n.pendingProps,d=e!==null?e.memoizedProps:null,y=u.children,ml(a,u)?y=null:d!==null&&ml(a,d)&&(n.flags|=32),Bh(e,n),ut(e,n,y,o),n.child;case 6:return e===null&&Cl(n),null;case 13:return Hh(e,n,o);case 4:return Il(n,n.stateNode.containerInfo),a=n.pendingProps,e===null?n.child=Fr(n,null,a,o):ut(e,n,a,o),n.child;case 11:return a=n.type,u=n.pendingProps,u=n.elementType===a?u:Ot(a,u),Fh(e,n,a,u,o);case 7:return ut(e,n,n.pendingProps,o),n.child;case 8:return ut(e,n,n.pendingProps.children,o),n.child;case 12:return ut(e,n,n.pendingProps.children,o),n.child;case 10:e:{if(a=n.type._context,u=n.pendingProps,d=n.memoizedProps,y=u.value,Ee(ss,a._currentValue),a._currentValue=y,d!==null)if(Vt(d.value,y)){if(d.children===u.children&&!ht.current){n=hn(e,n,o);break e}}else for(d=n.child,d!==null&&(d.return=n);d!==null;){var x=d.dependencies;if(x!==null){y=d.child;for(var E=x.firstContext;E!==null;){if(E.context===a){if(d.tag===1){E=dn(-1,o&-o),E.tag=2;var _=d.updateQueue;if(_!==null){_=_.shared;var $=_.pending;$===null?E.next=E:(E.next=$.next,$.next=E),_.pending=E}}d.lanes|=o,E=d.alternate,E!==null&&(E.lanes|=o),Dl(d.return,o,n),x.lanes|=o;break}E=E.next}}else if(d.tag===10)y=d.type===n.type?null:d.child;else if(d.tag===18){if(y=d.return,y===null)throw Error(i(341));y.lanes|=o,x=y.alternate,x!==null&&(x.lanes|=o),Dl(y,o,n),y=d.sibling}else y=d.child;if(y!==null)y.return=d;else for(y=d;y!==null;){if(y===n){y=null;break}if(d=y.sibling,d!==null){d.return=y.return,y=d;break}y=y.return}d=y}ut(e,n,u.children,o),n=n.child}return n;case 9:return u=n.type,a=n.pendingProps.children,zr(n,o),u=At(u),a=a(u),n.flags|=1,ut(e,n,a,o),n.child;case 14:return a=n.type,u=Ot(a,n.pendingProps),u=Ot(a.type,u),Oh(e,n,a,u,o);case 15:return zh(e,n,n.type,n.pendingProps,o);case 17:return a=n.type,u=n.pendingProps,u=n.elementType===a?u:Ot(a,u),vs(e,n),n.tag=1,pt(a)?(e=!0,es(n)):e=!1,zr(n,o),Lh(n,a,u),Hl(n,a,u,o),Xl(null,n,a,!0,e,o);case 19:return Gh(e,n,o);case 22:return bh(e,n,o)}throw Error(i(156,n.tag))};function yp(e,n){return Qf(e,n)}function V1(e,n,o,a){this.tag=e,this.key=o,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=a,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Mt(e,n,o,a){return new V1(e,n,o,a)}function mu(e){return e=e.prototype,!(!e||!e.isReactComponent)}function F1(e){if(typeof e=="function")return mu(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Te)return 11;if(e===Ye)return 14}return 2}function zn(e,n){var o=e.alternate;return o===null?(o=Mt(e.tag,n,e.key,e.mode),o.elementType=e.elementType,o.type=e.type,o.stateNode=e.stateNode,o.alternate=e,e.alternate=o):(o.pendingProps=n,o.type=e.type,o.flags=0,o.subtreeFlags=0,o.deletions=null),o.flags=e.flags&14680064,o.childLanes=e.childLanes,o.lanes=e.lanes,o.child=e.child,o.memoizedProps=e.memoizedProps,o.memoizedState=e.memoizedState,o.updateQueue=e.updateQueue,n=e.dependencies,o.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},o.sibling=e.sibling,o.index=e.index,o.ref=e.ref,o}function Ds(e,n,o,a,u,d){var y=2;if(a=e,typeof e=="function")mu(e)&&(y=1);else if(typeof e=="string")y=5;else e:switch(e){case B:return ur(o.children,u,d,n);case z:y=8,u|=8;break;case ie:return e=Mt(12,o,n,u|2),e.elementType=ie,e.lanes=d,e;case ke:return e=Mt(13,o,n,u),e.elementType=ke,e.lanes=d,e;case $e:return e=Mt(19,o,n,u),e.elementType=$e,e.lanes=d,e;case se:return Ls(o,u,d,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ce:y=10;break e;case ae:y=9;break e;case Te:y=11;break e;case Ye:y=14;break e;case Je:y=16,a=null;break e}throw Error(i(130,e==null?e:typeof e,""))}return n=Mt(y,o,n,u),n.elementType=e,n.type=a,n.lanes=d,n}function ur(e,n,o,a){return e=Mt(7,e,a,n),e.lanes=o,e}function Ls(e,n,o,a){return e=Mt(22,e,a,n),e.elementType=se,e.lanes=o,e.stateNode={isHidden:!1},e}function gu(e,n,o){return e=Mt(6,e,null,n),e.lanes=o,e}function yu(e,n,o){return n=Mt(4,e.children!==null?e.children:[],e.key,n),n.lanes=o,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function O1(e,n,o,a,u){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Wa(0),this.expirationTimes=Wa(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Wa(0),this.identifierPrefix=a,this.onRecoverableError=u,this.mutableSourceEagerHydrationData=null}function vu(e,n,o,a,u,d,y,x,E){return e=new O1(e,n,o,x,E),n===1?(n=1,d===!0&&(n|=8)):n=0,d=Mt(3,null,null,n),e.current=d,d.stateNode=e,d.memoizedState={element:a,isDehydrated:o,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ml(d),e}function z1(e,n,o){var a=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(r){console.error(r)}}return t(),Cu.exports=Q1(),Cu.exports}var Mp;function q1(){if(Mp)return Fs;Mp=1;var t=Z1();return Fs.createRoot=t.createRoot,Fs.hydrateRoot=t.hydrateRoot,Fs}var J1=q1();const ew=kg(J1);/** + * react-router v7.13.0 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var Ip="popstate";function tw(t={}){function r(s,l){let{pathname:c,search:f,hash:h}=s.location;return tc("",{pathname:c,search:f,hash:h},l.state&&l.state.usr||null,l.state&&l.state.key||"default")}function i(s,l){return typeof l=="string"?l:ho(l)}return rw(r,i,null,t)}function Le(t,r){if(t===!1||t===null||typeof t>"u")throw new Error(r)}function Gt(t,r){if(!t){typeof console<"u"&&console.warn(r);try{throw new Error(r)}catch{}}}function nw(){return Math.random().toString(36).substring(2,10)}function jp(t,r){return{usr:t.state,key:t.key,idx:r}}function tc(t,r,i=null,s){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof r=="string"?ai(r):r,state:i,key:r&&r.key||s||nw()}}function ho({pathname:t="/",search:r="",hash:i=""}){return r&&r!=="?"&&(t+=r.charAt(0)==="?"?r:"?"+r),i&&i!=="#"&&(t+=i.charAt(0)==="#"?i:"#"+i),t}function ai(t){let r={};if(t){let i=t.indexOf("#");i>=0&&(r.hash=t.substring(i),t=t.substring(0,i));let s=t.indexOf("?");s>=0&&(r.search=t.substring(s),t=t.substring(0,s)),t&&(r.pathname=t)}return r}function rw(t,r,i,s={}){let{window:l=document.defaultView,v5Compat:c=!1}=s,f=l.history,h="POP",p=null,m=g();m==null&&(m=0,f.replaceState({...f.state,idx:m},""));function g(){return(f.state||{idx:null}).idx}function v(){h="POP";let C=g(),j=C==null?null:C-m;m=C,p&&p({action:h,location:k.location,delta:j})}function w(C,j){h="PUSH";let N=tc(k.location,C,j);m=g()+1;let M=jp(N,m),V=k.createHref(N);try{f.pushState(M,"",V)}catch(F){if(F instanceof DOMException&&F.name==="DataCloneError")throw F;l.location.assign(V)}c&&p&&p({action:h,location:k.location,delta:1})}function S(C,j){h="REPLACE";let N=tc(k.location,C,j);m=g();let M=jp(N,m),V=k.createHref(N);f.replaceState(M,"",V),c&&p&&p({action:h,location:k.location,delta:0})}function T(C){return iw(C)}let k={get action(){return h},get location(){return t(l,f)},listen(C){if(p)throw new Error("A history only accepts one active listener");return l.addEventListener(Ip,v),p=C,()=>{l.removeEventListener(Ip,v),p=null}},createHref(C){return r(l,C)},createURL:T,encodeLocation(C){let j=T(C);return{pathname:j.pathname,search:j.search,hash:j.hash}},push:w,replace:S,go(C){return f.go(C)}};return k}function iw(t,r=!1){let i="http://localhost";typeof window<"u"&&(i=window.location.origin!=="null"?window.location.origin:window.location.href),Le(i,"No window.location.(origin|href) available to create URL");let s=typeof t=="string"?t:ho(t);return s=s.replace(/ $/,"%20"),!r&&s.startsWith("//")&&(s=i+s),new URL(s,i)}function Eg(t,r,i="/"){return ow(t,r,i,!1)}function ow(t,r,i,s){let l=typeof r=="string"?ai(r):r,c=vn(l.pathname||"/",i);if(c==null)return null;let f=Cg(t);sw(f);let h=null;for(let p=0;h==null&&p{let g={relativePath:m===void 0?f.path||"":m,caseSensitive:f.caseSensitive===!0,childrenIndex:h,route:f};if(g.relativePath.startsWith("/")){if(!g.relativePath.startsWith(s)&&p)return;Le(g.relativePath.startsWith(s),`Absolute route path "${g.relativePath}" nested under path "${s}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),g.relativePath=g.relativePath.slice(s.length)}let v=yn([s,g.relativePath]),w=i.concat(g);f.children&&f.children.length>0&&(Le(f.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${v}".`),Cg(f.children,r,w,v,p)),!(f.path==null&&!f.index)&&r.push({path:v,score:hw(v,f.index),routesMeta:w})};return t.forEach((f,h)=>{var p;if(f.path===""||!((p=f.path)!=null&&p.includes("?")))c(f,h);else for(let m of Tg(f.path))c(f,h,!0,m)}),r}function Tg(t){let r=t.split("/");if(r.length===0)return[];let[i,...s]=r,l=i.endsWith("?"),c=i.replace(/\?$/,"");if(s.length===0)return l?[c,""]:[c];let f=Tg(s.join("/")),h=[];return h.push(...f.map(p=>p===""?c:[c,p].join("/"))),l&&h.push(...f),h.map(p=>t.startsWith("/")&&p===""?"/":p)}function sw(t){t.sort((r,i)=>r.score!==i.score?i.score-r.score:pw(r.routesMeta.map(s=>s.childrenIndex),i.routesMeta.map(s=>s.childrenIndex)))}var aw=/^:[\w-]+$/,lw=3,uw=2,cw=1,fw=10,dw=-2,_p=t=>t==="*";function hw(t,r){let i=t.split("/"),s=i.length;return i.some(_p)&&(s+=dw),r&&(s+=uw),i.filter(l=>!_p(l)).reduce((l,c)=>l+(aw.test(c)?lw:c===""?cw:fw),s)}function pw(t,r){return t.length===r.length&&t.slice(0,-1).every((s,l)=>s===r[l])?t[t.length-1]-r[r.length-1]:0}function mw(t,r,i=!1){let{routesMeta:s}=t,l={},c="/",f=[];for(let h=0;h{if(g==="*"){let T=h[w]||"";f=c.slice(0,c.length-T.length).replace(/(.)\/+$/,"$1")}const S=h[w];return v&&!S?m[g]=void 0:m[g]=(S||"").replace(/%2F/g,"/"),m},{}),pathname:c,pathnameBase:f,pattern:t}}function gw(t,r=!1,i=!0){Gt(t==="*"||!t.endsWith("*")||t.endsWith("/*"),`Route path "${t}" will be treated as if it were "${t.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${t.replace(/\*$/,"/*")}".`);let s=[],l="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(f,h,p)=>(s.push({paramName:h,isOptional:p!=null}),p?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(s.push({paramName:"*"}),l+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):i?l+="\\/*$":t!==""&&t!=="/"&&(l+="(?:(?=\\/|$))"),[new RegExp(l,r?void 0:"i"),s]}function yw(t){try{return t.split("/").map(r=>decodeURIComponent(r).replace(/\//g,"%2F")).join("/")}catch(r){return Gt(!1,`The URL path "${t}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${r}).`),t}}function vn(t,r){if(r==="/")return t;if(!t.toLowerCase().startsWith(r.toLowerCase()))return null;let i=r.endsWith("/")?r.length-1:r.length,s=t.charAt(i);return s&&s!=="/"?null:t.slice(i)||"/"}var vw=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function ww(t,r="/"){let{pathname:i,search:s="",hash:l=""}=typeof t=="string"?ai(t):t,c;return i?(i=i.replace(/\/\/+/g,"/"),i.startsWith("/")?c=Np(i.substring(1),"/"):c=Np(i,r)):c=r,{pathname:c,search:kw(s),hash:Ew(l)}}function Np(t,r){let i=r.replace(/\/+$/,"").split("/");return t.split("/").forEach(l=>{l===".."?i.length>1&&i.pop():l!=="."&&i.push(l)}),i.length>1?i.join("/"):"/"}function Ru(t,r,i,s){return`Cannot include a '${t}' character in a manually specified \`to.${r}\` field [${JSON.stringify(s)}]. Please separate it out to the \`to.${i}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function xw(t){return t.filter((r,i)=>i===0||r.route.path&&r.route.path.length>0)}function Nc(t){let r=xw(t);return r.map((i,s)=>s===r.length-1?i.pathname:i.pathnameBase)}function Vc(t,r,i,s=!1){let l;typeof t=="string"?l=ai(t):(l={...t},Le(!l.pathname||!l.pathname.includes("?"),Ru("?","pathname","search",l)),Le(!l.pathname||!l.pathname.includes("#"),Ru("#","pathname","hash",l)),Le(!l.search||!l.search.includes("#"),Ru("#","search","hash",l)));let c=t===""||l.pathname==="",f=c?"/":l.pathname,h;if(f==null)h=i;else{let v=r.length-1;if(!s&&f.startsWith("..")){let w=f.split("/");for(;w[0]==="..";)w.shift(),v-=1;l.pathname=w.join("/")}h=v>=0?r[v]:"/"}let p=ww(l,h),m=f&&f!=="/"&&f.endsWith("/"),g=(c||f===".")&&i.endsWith("/");return!p.pathname.endsWith("/")&&(m||g)&&(p.pathname+="/"),p}var yn=t=>t.join("/").replace(/\/\/+/g,"/"),Sw=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),kw=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,Ew=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,Cw=class{constructor(t,r,i,s=!1){this.status=t,this.statusText=r||"",this.internal=s,i instanceof Error?(this.data=i.toString(),this.error=i):this.data=i}};function Tw(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function Pw(t){return t.map(r=>r.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var Pg=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function Rg(t,r){let i=t;if(typeof i!="string"||!vw.test(i))return{absoluteURL:void 0,isExternal:!1,to:i};let s=i,l=!1;if(Pg)try{let c=new URL(window.location.href),f=i.startsWith("//")?new URL(c.protocol+i):new URL(i),h=vn(f.pathname,r);f.origin===c.origin&&h!=null?i=h+f.search+f.hash:l=!0}catch{Gt(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:s,isExternal:l,to:i}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var Ag=["POST","PUT","PATCH","DELETE"];new Set(Ag);var Rw=["GET",...Ag];new Set(Rw);var li=A.createContext(null);li.displayName="DataRouter";var va=A.createContext(null);va.displayName="DataRouterState";var Aw=A.createContext(!1),Dg=A.createContext({isTransitioning:!1});Dg.displayName="ViewTransition";var Dw=A.createContext(new Map);Dw.displayName="Fetchers";var Lw=A.createContext(null);Lw.displayName="Await";var Ct=A.createContext(null);Ct.displayName="Navigation";var So=A.createContext(null);So.displayName="Location";var on=A.createContext({outlet:null,matches:[],isDataRoute:!1});on.displayName="Route";var Fc=A.createContext(null);Fc.displayName="RouteError";var Lg="REACT_ROUTER_ERROR",Mw="REDIRECT",Iw="ROUTE_ERROR_RESPONSE";function jw(t){if(t.startsWith(`${Lg}:${Mw}:{`))try{let r=JSON.parse(t.slice(28));if(typeof r=="object"&&r&&typeof r.status=="number"&&typeof r.statusText=="string"&&typeof r.location=="string"&&typeof r.reloadDocument=="boolean"&&typeof r.replace=="boolean")return r}catch{}}function _w(t){if(t.startsWith(`${Lg}:${Iw}:{`))try{let r=JSON.parse(t.slice(40));if(typeof r=="object"&&r&&typeof r.status=="number"&&typeof r.statusText=="string")return new Cw(r.status,r.statusText,r.data)}catch{}}function Nw(t,{relative:r}={}){Le(ui(),"useHref() may be used only in the context of a component.");let{basename:i,navigator:s}=A.useContext(Ct),{hash:l,pathname:c,search:f}=ko(t,{relative:r}),h=c;return i!=="/"&&(h=c==="/"?i:yn([i,c])),s.createHref({pathname:h,search:f,hash:l})}function ui(){return A.useContext(So)!=null}function Xn(){return Le(ui(),"useLocation() may be used only in the context of a component."),A.useContext(So).location}var Mg="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function Ig(t){A.useContext(Ct).static||A.useLayoutEffect(t)}function Oc(){let{isDataRoute:t}=A.useContext(on);return t?Yw():Vw()}function Vw(){Le(ui(),"useNavigate() may be used only in the context of a component.");let t=A.useContext(li),{basename:r,navigator:i}=A.useContext(Ct),{matches:s}=A.useContext(on),{pathname:l}=Xn(),c=JSON.stringify(Nc(s)),f=A.useRef(!1);return Ig(()=>{f.current=!0}),A.useCallback((p,m={})=>{if(Gt(f.current,Mg),!f.current)return;if(typeof p=="number"){i.go(p);return}let g=Vc(p,JSON.parse(c),l,m.relative==="path");t==null&&r!=="/"&&(g.pathname=g.pathname==="/"?r:yn([r,g.pathname])),(m.replace?i.replace:i.push)(g,m.state,m)},[r,i,c,l,t])}A.createContext(null);function ko(t,{relative:r}={}){let{matches:i}=A.useContext(on),{pathname:s}=Xn(),l=JSON.stringify(Nc(i));return A.useMemo(()=>Vc(t,JSON.parse(l),s,r==="path"),[t,l,s,r])}function Fw(t,r){return jg(t,r)}function jg(t,r,i,s,l){var N;Le(ui(),"useRoutes() may be used only in the context of a component.");let{navigator:c}=A.useContext(Ct),{matches:f}=A.useContext(on),h=f[f.length-1],p=h?h.params:{},m=h?h.pathname:"/",g=h?h.pathnameBase:"/",v=h&&h.route;{let M=v&&v.path||"";Ng(m,!v||M.endsWith("*")||M.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${m}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let w=Xn(),S;if(r){let M=typeof r=="string"?ai(r):r;Le(g==="/"||((N=M.pathname)==null?void 0:N.startsWith(g)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${g}" but pathname "${M.pathname}" was given in the \`location\` prop.`),S=M}else S=w;let T=S.pathname||"/",k=T;if(g!=="/"){let M=g.replace(/^\//,"").split("/");k="/"+T.replace(/^\//,"").split("/").slice(M.length).join("/")}let C=Eg(t,{pathname:k});Gt(v||C!=null,`No routes matched location "${S.pathname}${S.search}${S.hash}" `),Gt(C==null||C[C.length-1].route.element!==void 0||C[C.length-1].route.Component!==void 0||C[C.length-1].route.lazy!==void 0,`Matched leaf route at location "${S.pathname}${S.search}${S.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let j=$w(C&&C.map(M=>Object.assign({},M,{params:Object.assign({},p,M.params),pathname:yn([g,c.encodeLocation?c.encodeLocation(M.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:M.pathname]),pathnameBase:M.pathnameBase==="/"?g:yn([g,c.encodeLocation?c.encodeLocation(M.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:M.pathnameBase])})),f,i,s,l);return r&&j?A.createElement(So.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...S},navigationType:"POP"}},j):j}function Ow(){let t=Gw(),r=Tw(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),i=t instanceof Error?t.stack:null,s="rgba(200,200,200, 0.5)",l={padding:"0.5rem",backgroundColor:s},c={padding:"2px 4px",backgroundColor:s},f=null;return console.error("Error handled by React Router default ErrorBoundary:",t),f=A.createElement(A.Fragment,null,A.createElement("p",null,"💿 Hey developer 👋"),A.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",A.createElement("code",{style:c},"ErrorBoundary")," or"," ",A.createElement("code",{style:c},"errorElement")," prop on your route.")),A.createElement(A.Fragment,null,A.createElement("h2",null,"Unexpected Application Error!"),A.createElement("h3",{style:{fontStyle:"italic"}},r),i?A.createElement("pre",{style:l},i):null,f)}var zw=A.createElement(Ow,null),_g=class extends A.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,r){return r.location!==t.location||r.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:r.error,location:r.location,revalidation:t.revalidation||r.revalidation}}componentDidCatch(t,r){this.props.onError?this.props.onError(t,r):console.error("React Router caught the following error during render",t)}render(){let t=this.state.error;if(this.context&&typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){const i=_w(t.digest);i&&(t=i)}let r=t!==void 0?A.createElement(on.Provider,{value:this.props.routeContext},A.createElement(Fc.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?A.createElement(bw,{error:t},r):r}};_g.contextType=Aw;var Au=new WeakMap;function bw({children:t,error:r}){let{basename:i}=A.useContext(Ct);if(typeof r=="object"&&r&&"digest"in r&&typeof r.digest=="string"){let s=jw(r.digest);if(s){let l=Au.get(r);if(l)throw l;let c=Rg(s.location,i);if(Pg&&!Au.get(r))if(c.isExternal||s.reloadDocument)window.location.href=c.absoluteURL||c.to;else{const f=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(c.to,{replace:s.replace}));throw Au.set(r,f),f}return A.createElement("meta",{httpEquiv:"refresh",content:`0;url=${c.absoluteURL||c.to}`})}}return t}function Bw({routeContext:t,match:r,children:i}){let s=A.useContext(li);return s&&s.static&&s.staticContext&&(r.route.errorElement||r.route.ErrorBoundary)&&(s.staticContext._deepestRenderedBoundaryId=r.route.id),A.createElement(on.Provider,{value:t},i)}function $w(t,r=[],i=null,s=null,l=null){if(t==null){if(!i)return null;if(i.errors)t=i.matches;else if(r.length===0&&!i.initialized&&i.matches.length>0)t=i.matches;else return null}let c=t,f=i==null?void 0:i.errors;if(f!=null){let g=c.findIndex(v=>v.route.id&&(f==null?void 0:f[v.route.id])!==void 0);Le(g>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(f).join(",")}`),c=c.slice(0,Math.min(c.length,g+1))}let h=!1,p=-1;if(i)for(let g=0;g=0?c=c.slice(0,p+1):c=[c[0]];break}}}let m=i&&s?(g,v)=>{var w,S;s(g,{location:i.location,params:((S=(w=i.matches)==null?void 0:w[0])==null?void 0:S.params)??{},unstable_pattern:Pw(i.matches),errorInfo:v})}:void 0;return c.reduceRight((g,v,w)=>{let S,T=!1,k=null,C=null;i&&(S=f&&v.route.id?f[v.route.id]:void 0,k=v.route.errorElement||zw,h&&(p<0&&w===0?(Ng("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),T=!0,C=null):p===w&&(T=!0,C=v.route.hydrateFallbackElement||null)));let j=r.concat(c.slice(0,w+1)),N=()=>{let M;return S?M=k:T?M=C:v.route.Component?M=A.createElement(v.route.Component,null):v.route.element?M=v.route.element:M=g,A.createElement(Bw,{match:v,routeContext:{outlet:g,matches:j,isDataRoute:i!=null},children:M})};return i&&(v.route.ErrorBoundary||v.route.errorElement||w===0)?A.createElement(_g,{location:i.location,revalidation:i.revalidation,component:k,error:S,children:N(),routeContext:{outlet:null,matches:j,isDataRoute:!0},onError:m}):N()},null)}function zc(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Uw(t){let r=A.useContext(li);return Le(r,zc(t)),r}function Ww(t){let r=A.useContext(va);return Le(r,zc(t)),r}function Hw(t){let r=A.useContext(on);return Le(r,zc(t)),r}function bc(t){let r=Hw(t),i=r.matches[r.matches.length-1];return Le(i.route.id,`${t} can only be used on routes that contain a unique "id"`),i.route.id}function Kw(){return bc("useRouteId")}function Gw(){var s;let t=A.useContext(Fc),r=Ww("useRouteError"),i=bc("useRouteError");return t!==void 0?t:(s=r.errors)==null?void 0:s[i]}function Yw(){let{router:t}=Uw("useNavigate"),r=bc("useNavigate"),i=A.useRef(!1);return Ig(()=>{i.current=!0}),A.useCallback(async(l,c={})=>{Gt(i.current,Mg),i.current&&(typeof l=="number"?await t.navigate(l):await t.navigate(l,{fromRouteId:r,...c}))},[t,r])}var Vp={};function Ng(t,r,i){!r&&!Vp[t]&&(Vp[t]=!0,Gt(!1,i))}A.memo(Xw);function Xw({routes:t,future:r,state:i,onError:s}){return jg(t,void 0,i,s,r)}function Qw({to:t,replace:r,state:i,relative:s}){Le(ui()," may be used only in the context of a component.");let{static:l}=A.useContext(Ct);Gt(!l," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:c}=A.useContext(on),{pathname:f}=Xn(),h=Oc(),p=Vc(t,Nc(c),f,s==="path"),m=JSON.stringify(p);return A.useEffect(()=>{h(JSON.parse(m),{replace:r,state:i,relative:s})},[h,m,s,r,i]),null}function ro(t){Le(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Zw({basename:t="/",children:r=null,location:i,navigationType:s="POP",navigator:l,static:c=!1,unstable_useTransitions:f}){Le(!ui(),"You cannot render a inside another . You should never have more than one in your app.");let h=t.replace(/^\/*/,"/"),p=A.useMemo(()=>({basename:h,navigator:l,static:c,unstable_useTransitions:f,future:{}}),[h,l,c,f]);typeof i=="string"&&(i=ai(i));let{pathname:m="/",search:g="",hash:v="",state:w=null,key:S="default"}=i,T=A.useMemo(()=>{let k=vn(m,h);return k==null?null:{location:{pathname:k,search:g,hash:v,state:w,key:S},navigationType:s}},[h,m,g,v,w,S,s]);return Gt(T!=null,` is not able to match the URL "${m}${g}${v}" because it does not start with the basename, so the won't render anything.`),T==null?null:A.createElement(Ct.Provider,{value:p},A.createElement(So.Provider,{children:r,value:T}))}function qw({children:t,location:r}){return Fw(nc(t),r)}function nc(t,r=[]){let i=[];return A.Children.forEach(t,(s,l)=>{if(!A.isValidElement(s))return;let c=[...r,l];if(s.type===A.Fragment){i.push.apply(i,nc(s.props.children,c));return}Le(s.type===ro,`[${typeof s.type=="string"?s.type:s.type.name}] is not a component. All component children of must be a or `),Le(!s.props.index||!s.props.children,"An index route cannot have child routes.");let f={id:s.props.id||c.join("-"),caseSensitive:s.props.caseSensitive,element:s.props.element,Component:s.props.Component,index:s.props.index,path:s.props.path,middleware:s.props.middleware,loader:s.props.loader,action:s.props.action,hydrateFallbackElement:s.props.hydrateFallbackElement,HydrateFallback:s.props.HydrateFallback,errorElement:s.props.errorElement,ErrorBoundary:s.props.ErrorBoundary,hasErrorBoundary:s.props.hasErrorBoundary===!0||s.props.ErrorBoundary!=null||s.props.errorElement!=null,shouldRevalidate:s.props.shouldRevalidate,handle:s.props.handle,lazy:s.props.lazy};s.props.children&&(f.children=nc(s.props.children,c)),i.push(f)}),i}var Ks="get",Gs="application/x-www-form-urlencoded";function wa(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function Jw(t){return wa(t)&&t.tagName.toLowerCase()==="button"}function ex(t){return wa(t)&&t.tagName.toLowerCase()==="form"}function tx(t){return wa(t)&&t.tagName.toLowerCase()==="input"}function nx(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function rx(t,r){return t.button===0&&(!r||r==="_self")&&!nx(t)}var Os=null;function ix(){if(Os===null)try{new FormData(document.createElement("form"),0),Os=!1}catch{Os=!0}return Os}var ox=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Du(t){return t!=null&&!ox.has(t)?(Gt(!1,`"${t}" is not a valid \`encType\` for \`\`/\`\` and will default to "${Gs}"`),null):t}function sx(t,r){let i,s,l,c,f;if(ex(t)){let h=t.getAttribute("action");s=h?vn(h,r):null,i=t.getAttribute("method")||Ks,l=Du(t.getAttribute("enctype"))||Gs,c=new FormData(t)}else if(Jw(t)||tx(t)&&(t.type==="submit"||t.type==="image")){let h=t.form;if(h==null)throw new Error('Cannot submit a
diff --git a/frontend/src/hooks/useRecipeActions.js b/frontend/src/hooks/useRecipeActions.js index 4ac3930c3..4d7cfcc16 100644 --- a/frontend/src/hooks/useRecipeActions.js +++ b/frontend/src/hooks/useRecipeActions.js @@ -14,6 +14,8 @@ export const useRecipeActions = () => { setError, setMode, setFilters, + hasSearched, + setHasSearched, addIngredient, removeIngredient, } = useRecipeStore(); @@ -26,6 +28,7 @@ export const useRecipeActions = () => { } setLoading(true); setError(null); + setHasSearched(true); try { const data = await fetchRecipeByIngredients(ingredients, mode, filters); setRecipes(data); @@ -44,7 +47,7 @@ export const useRecipeActions = () => { filters, loading, error, - filters, + hasSearched, setFilters, setMode, addIngredient, diff --git a/frontend/src/stores/recipeStore.js b/frontend/src/stores/recipeStore.js index d9922151a..cce6dee49 100644 --- a/frontend/src/stores/recipeStore.js +++ b/frontend/src/stores/recipeStore.js @@ -5,6 +5,7 @@ export const useRecipeStore = create((set) => ({ recipes: [], mode: "allowExtra", loading: false, + hasSearched: false, error: null, filters: { vegetarian: false, @@ -28,6 +29,7 @@ export const useRecipeStore = create((set) => ({ setRecipes: (recipes) => set({ recipes }), setLoading: (loading) => set({ loading }), + setHasSearched: (hasSearched) => set({ hasSearched }), setError: (error) => set({ error }), clearRecipes: () => set({ recipes: [] }), })); \ No newline at end of file From ff6b981162e254af689426aa8a259f256df98172 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 1 Mar 2026 20:31:02 +0100 Subject: [PATCH 084/127] fix: added filledIngredients parameter --- backend/routes/recipeRoutes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index e08bc5951..ba841a4a9 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -49,7 +49,6 @@ router.get("/search", async (req, res) => { .filter(Boolean) .join(","); - // query params for complexSearch try { const params = { includeIngredients: ingredientList.join(","), @@ -58,6 +57,7 @@ router.get("/search", async (req, res) => { intolerances, sort: mode === "exact" ? "min-missing-ingredients" : "max-used-ingredients", addRecipeInformation: true, + fillIngredients: true, apiKey: process.env.SPOONACULAR_API_KEY, }; From 07546477dfd3f568d3342e2757ab41d81e256763 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 11:04:44 +0100 Subject: [PATCH 085/127] feat: add sourceUrl to recipeRoutes for the copy url button --- backend/routes/recipeRoutes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index ba841a4a9..2f7cbbc78 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -125,6 +125,7 @@ router.get("/details/:id", async (req, res) => { extendedIngredients: details.extendedIngredients, readyInMinutes: details.readyInMinutes, servings: details.servings, + sourceUrl: details.sourceUrl, } }); From e5e0f377398bbadbf6c84ff8618347d4986304b8 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 11:06:00 +0100 Subject: [PATCH 086/127] install lucide-react --- frontend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/package.json b/frontend/package.json index 701d4bc53..5bbddae2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "axios": "^1.13.5", + "lucide-react": "^0.575.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^7.13.0", From e31b44235fefe7e1156beada5cb6e694a83af293 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 11:06:42 +0100 Subject: [PATCH 087/127] fix: remove unused code --- frontend/src/api/api.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 56d0223e7..48aa767a8 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -33,12 +33,6 @@ export async function fetchRecipeDetails(id) { return data.response; } -// get details for a single recipe -export async function fetchSavedRecipeDetails(id) { - const res = await fetch(`${API_URL}/recipes/saved/${id}`); - const data = await handleResponse(res); - return data.response; -} // Save a recipe to the database for logged-in user (auth required) export async function saveRecipe(recipeData, token) { From 9952eb4b825bd92b5d70bcb91ab486c516f9ff0a Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 11:07:35 +0100 Subject: [PATCH 088/127] feat: add copy link function to RecipeCard --- frontend/src/components/RecipeCard.jsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 9c95142f0..2d38c378a 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import styled from "styled-components"; +import { Copy, Check } from "lucide-react"; import { useUserStore } from "../stores/userStore"; import { useNavigate } from "react-router-dom"; import { fetchRecipeDetails } from "../api/api"; @@ -107,6 +108,14 @@ const Details = styled.div` } `; +const CopyBtn = styled.button` + background: none; + border: none; + cursor: pointer; + margin-left: 0.9rem; + vertical-align: middle; +`; + const RecipeCard = ({ recipe }) => { const { user } = useUserStore(); @@ -114,6 +123,7 @@ const RecipeCard = ({ recipe }) => { const [isOpen, setIsOpen] = useState(false); const [details, setDetails] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); + const [copied, setCopied] = useState(false); const handleToggle = async () => { if (!isOpen && !details) { @@ -182,6 +192,7 @@ const handleSave = async () => { Save Recipe + {isOpen && details && ( @@ -190,6 +201,20 @@ const handleSave = async () => { ⏱️ {details.readyInMinutes !== null && details.readyInMinutes !== undefined ? `${details.readyInMinutes} min` : 'unknown time'} {' | '} 🍽️ {details.servings !== null && details.servings !== undefined ? `${details.servings} servings` : 'unknown servings'} + { + let url = `https://spoonacular.com/recipes/${recipe.title.toLowerCase().replace(/\s+/g, "-")}-${recipe.id}`; + if (details?.sourceUrl) { + url = details.sourceUrl; + } + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }} + title={copied ? "Copied!" : "Copy link"} + > + {copied ? : } + {details.summary && (
From bc008c2418aae184c64dfe1a34ae52c521b4fb28 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 11:07:55 +0100 Subject: [PATCH 089/127] feat: add loading spinner --- frontend/src/pages/SavedRecipes.jsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index 02e0b0188..7dcba3699 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -134,6 +134,28 @@ const Details = styled.div` } `; +const LoadingSpinner = styled.div` + border: 4px solid #f3f3f3; + border-top: 4px solid #2e8b57; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 2rem auto; + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + @media ${media.tablet} { + width: 50px; + height: 50px; + } + @media ${media.desktop} { + width: 60px; + height: 60px; + } +`; + const SavedRecipes = () => { const { user } = useUserStore(); const [recipes, setRecipes] = useState([]); @@ -181,7 +203,7 @@ const SavedRecipes = () => { return ( Saved Recipes -

Loading...

+
); } From acf197c19b94e8124c4b5f76b4ba226ae730d97e Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 11:08:18 +0100 Subject: [PATCH 090/127] fix: add unused code --- frontend/src/stores/recipeStore.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/stores/recipeStore.js b/frontend/src/stores/recipeStore.js index cce6dee49..958504c3c 100644 --- a/frontend/src/stores/recipeStore.js +++ b/frontend/src/stores/recipeStore.js @@ -31,5 +31,4 @@ export const useRecipeStore = create((set) => ({ setLoading: (loading) => set({ loading }), setHasSearched: (hasSearched) => set({ hasSearched }), setError: (error) => set({ error }), - clearRecipes: () => set({ recipes: [] }), })); \ No newline at end of file From db3460495b9998401da9eb4dc7003be672830d9f Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 11:27:56 +0100 Subject: [PATCH 091/127] refactor: move copy and loading spinner to own components and import to other components for cleaner code --- frontend/src/components/CopyButton.jsx | 33 ++++++++++++++++++++++ frontend/src/components/LoadingSpinner.jsx | 31 ++++++++++++++++++++ frontend/src/components/RecipeCard.jsx | 27 ++---------------- frontend/src/components/SearchRecipe.jsx | 2 ++ frontend/src/pages/SavedRecipes.jsx | 25 ++-------------- 5 files changed, 71 insertions(+), 47 deletions(-) create mode 100644 frontend/src/components/CopyButton.jsx create mode 100644 frontend/src/components/LoadingSpinner.jsx diff --git a/frontend/src/components/CopyButton.jsx b/frontend/src/components/CopyButton.jsx new file mode 100644 index 000000000..ea61828ff --- /dev/null +++ b/frontend/src/components/CopyButton.jsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import styled from "styled-components"; +import { Copy, Check } from "lucide-react"; + +const Button = styled.button` + background: none; + border: none; + cursor: pointer; + margin-left: 0.5rem; + vertical-align: middle; +`; + +const CopyButton = ({ url }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy: ", err); + } + }; + + return ( + + ); +}; + +export default CopyButton; diff --git a/frontend/src/components/LoadingSpinner.jsx b/frontend/src/components/LoadingSpinner.jsx new file mode 100644 index 000000000..04dfa913b --- /dev/null +++ b/frontend/src/components/LoadingSpinner.jsx @@ -0,0 +1,31 @@ +import styled from "styled-components"; +import { media } from "../styles/media"; + +const Spinner = styled.div` + border: 4px solid #f3f3f3; + border-top: 4px solid #2e8b57; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 2rem auto; + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + @media ${media.tablet} { + width: 50px; + height: 50px; + } + + @media ${media.desktop} { + width: 60px; + height: 60px; + } +`; + +const LoadingSpinner = () => ; + +export default LoadingSpinner; diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 2d38c378a..0cac710c0 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -1,11 +1,10 @@ import { useState } from "react"; import styled from "styled-components"; -import { Copy, Check } from "lucide-react"; import { useUserStore } from "../stores/userStore"; import { useNavigate } from "react-router-dom"; import { fetchRecipeDetails } from "../api/api"; import { saveRecipe } from "../api/api"; -import { media } from "../styles/media"; +import CopyButton from "./CopyButton"; const Card = styled.div` background: #fff; @@ -108,14 +107,6 @@ const Details = styled.div` } `; -const CopyBtn = styled.button` - background: none; - border: none; - cursor: pointer; - margin-left: 0.9rem; - vertical-align: middle; -`; - const RecipeCard = ({ recipe }) => { const { user } = useUserStore(); @@ -123,7 +114,6 @@ const RecipeCard = ({ recipe }) => { const [isOpen, setIsOpen] = useState(false); const [details, setDetails] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); - const [copied, setCopied] = useState(false); const handleToggle = async () => { if (!isOpen && !details) { @@ -201,20 +191,7 @@ const handleSave = async () => { ⏱️ {details.readyInMinutes !== null && details.readyInMinutes !== undefined ? `${details.readyInMinutes} min` : 'unknown time'} {' | '} 🍽️ {details.servings !== null && details.servings !== undefined ? `${details.servings} servings` : 'unknown servings'} - { - let url = `https://spoonacular.com/recipes/${recipe.title.toLowerCase().replace(/\s+/g, "-")}-${recipe.id}`; - if (details?.sourceUrl) { - url = details.sourceUrl; - } - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }} - title={copied ? "Copied!" : "Copy link"} - > - {copied ? : } - + {details.summary && (
diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index ee1dc57c7..5a0ef65a4 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -2,6 +2,7 @@ import { useState } from "react"; import styled from "styled-components"; import { useRecipeActions } from "../hooks/useRecipeActions"; import RecipeList from "./RecipeList"; +import LoadingSpinner from "./LoadingSpinner"; import { media } from "../styles/media"; const Section = styled.section` @@ -264,6 +265,7 @@ const SearchRecipe = () => { {loading ? "Searching..." : "Show recipes"} + {loading && } {error && {error}} {recipes && recipes.length > 0 && } {hasSearched && !loading && !error && recipes.length === 0 && ( diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index 7dcba3699..deb11fe76 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -3,6 +3,8 @@ import styled from "styled-components"; import { useUserStore } from "../stores/userStore"; import { getSavedRecipes, deleteRecipe } from "../api/api"; import { media } from "../styles/media"; +import CopyButton from "../components/CopyButton"; +import LoadingSpinner from "../components/LoadingSpinner"; const Container = styled.div` max-width: 900px; @@ -134,28 +136,6 @@ const Details = styled.div` } `; -const LoadingSpinner = styled.div` - border: 4px solid #f3f3f3; - border-top: 4px solid #2e8b57; - border-radius: 50%; - width: 40px; - height: 40px; - animation: spin 1s linear infinite; - margin: 2rem auto; - @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - @media ${media.tablet} { - width: 50px; - height: 50px; - } - @media ${media.desktop} { - width: 60px; - height: 60px; - } -`; - const SavedRecipes = () => { const { user } = useUserStore(); const [recipes, setRecipes] = useState([]); @@ -253,6 +233,7 @@ const SavedRecipes = () => { {isExpanded && (
+ {recipe.summary && (
)} From ada70fc7b7106827971080bce53afb4aef0aff8b Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 13:30:24 +0100 Subject: [PATCH 092/127] fix: add loading lazy to improve score in lighthouse --- frontend/src/components/Hero.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Hero.jsx b/frontend/src/components/Hero.jsx index acb625a2a..7e7035548 100644 --- a/frontend/src/components/Hero.jsx +++ b/frontend/src/components/Hero.jsx @@ -146,7 +146,7 @@ const Hero = () => { return ( - PantryMatch Logo + PantryMatch Logo From 18c2c5e39cb7d33c8dc16bb9e2349e70bc08a950 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 13:30:49 +0100 Subject: [PATCH 093/127] fix: add meta description to improve score in lighthouse --- frontend/index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/index.html b/frontend/index.html index 774f1a408..e202ff3b4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,10 @@ + PantryMatch From ad270f3705b6a64f9a12c06980c328dd55035b78 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 13:48:03 +0100 Subject: [PATCH 094/127] style: change RecipeCard, RecipeList, and SavedRecipes components layout --- frontend/src/components/RecipeCard.jsx | 37 +++++++++++++--- frontend/src/components/RecipeList.jsx | 19 +++----- frontend/src/pages/SavedRecipes.jsx | 61 +++++++++++++++----------- 3 files changed, 72 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 0cac710c0..5172e14d4 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -5,19 +5,40 @@ import { useNavigate } from "react-router-dom"; import { fetchRecipeDetails } from "../api/api"; import { saveRecipe } from "../api/api"; import CopyButton from "./CopyButton"; +import { media } from "../styles/media"; const Card = styled.div` background: #fff; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - padding: 1rem; - margin-bottom: 1.5rem; + padding: 1.5rem 1rem; + border-bottom: 1px solid #e0e0e0; +`; + +const CardTop = styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; + + @media ${media.tablet}, ${media.desktop} { + flex-direction: row; + } `; const Image = styled.img` width: 100%; border-radius: 6px; - margin-bottom: 0.5rem; + + @media ${media.tablet}, ${media.desktop} { + width: 250px; + height: 170px; + object-fit: cover; + flex-shrink: 0; + } +`; + +const CardContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; `; const Title = styled.h3` @@ -162,7 +183,9 @@ const handleSave = async () => { return ( - {recipe.image && {recipe.title}} + + {recipe.image && {recipe.title}} + {recipe.title} @@ -184,6 +207,8 @@ const handleSave = async () => { Save Recipe + + {isOpen && details && (
diff --git a/frontend/src/components/RecipeList.jsx b/frontend/src/components/RecipeList.jsx index c3bb66ecf..d40fe1fb8 100644 --- a/frontend/src/components/RecipeList.jsx +++ b/frontend/src/components/RecipeList.jsx @@ -1,6 +1,5 @@ import RecipeCard from "./RecipeCard"; import styled from "styled-components"; -import { media } from "../styles/media"; const Container = styled.div` margin-top: 2rem; @@ -12,19 +11,11 @@ const Title = styled.h2` `; const RecipeGrid = styled.div` - display: grid; - grid-template-columns: 1fr; - gap: 2rem; - margin: 2rem 0; - align-items: start; - - @media ${media.tablet} { - grid-template-columns: repeat(2, 1fr); - } - - @media ${media.desktop} { - grid-template-columns: repeat(3, 1fr); - } + display: flex; + flex-direction: column; + gap: 0; + margin: 2rem auto; + max-width: 900px; `; diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index deb11fe76..dd455eb9f 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -7,17 +7,7 @@ import CopyButton from "../components/CopyButton"; import LoadingSpinner from "../components/LoadingSpinner"; const Container = styled.div` - max-width: 900px; - margin: 3rem auto; - padding: 2rem; - background: #ffffff; - border-radius: 1.5rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); - - @media (max-width: 600px) { - padding: 1rem 0.5rem; - max-width: 98vw; - } + margin-top: 2rem; `; const Title = styled.h1` @@ -34,29 +24,45 @@ const Subtitle = styled.p` margin-bottom: 2.5rem; `; const RecipeGrid = styled.div` - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 2rem; - margin: 2rem 0; - - @media ${media.tablet} { - grid-template-columns: repeat(2, 1fr); - } + display: flex; + flex-direction: column; + gap: 0; + margin: 2rem auto; + max-width: 900px; `; const Card = styled.div` background: #fff; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - padding: 1rem; + padding: 1.5rem 1rem; + border-bottom: 1px solid #e0e0e0; +`; + +const CardTop = styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; + + @media ${media.tablet}, ${media.desktop} { + flex-direction: row; + } `; const Image = styled.img` width: 100%; - height: 200px; - object-fit: cover; border-radius: 6px; - margin-bottom: 0.5rem; + + @media ${media.tablet}, ${media.desktop} { + width: 250px; + height: 170px; + object-fit: cover; + flex-shrink: 0; + } +`; + +const CardContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; `; const RecipeTitle = styled.h3` @@ -72,6 +78,7 @@ const Info = styled.p` const ButtonRow = styled.div` display: flex; + justify-content: space-between; gap: 0.5rem; margin-top: 1rem; `; @@ -214,7 +221,9 @@ const SavedRecipes = () => { return ( + {recipe.image && {recipe.title}} + {recipe.title} @@ -230,6 +239,8 @@ const SavedRecipes = () => { Delete + + {isExpanded && (
From 3a0ea1b63c36baaa38b8dd5a2ec61bb381f11b9b Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 19:23:25 +0100 Subject: [PATCH 095/127] refactor: app and member component to send user when logging in to home page instead --- frontend/src/App.jsx | 8 ++++++-- frontend/src/pages/member.jsx | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 96331afb2..9919efbbb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ export const App = () => { +
{ + ) : ( + ) } /> @@ -57,6 +60,7 @@ export const App = () => { +
); }; diff --git a/frontend/src/pages/member.jsx b/frontend/src/pages/member.jsx index 4ba6484b3..294e062b9 100644 --- a/frontend/src/pages/member.jsx +++ b/frontend/src/pages/member.jsx @@ -20,7 +20,7 @@ const AuthContainer = styled.div` margin-top: 2rem; `; -const Member = ({ user, isSigningUp, setIsSigningUp, handleLogin, handleLogout }) => { +const Member = ({ isSigningUp, setIsSigningUp, handleLogin }) => { return ( <> @@ -28,7 +28,6 @@ const Member = ({ user, isSigningUp, setIsSigningUp, handleLogin, handleLogout }

Sign up or log in and start discovering recipes based on what you have at home

And save your favorite recipes

- {!user && ( {isSigningUp ? ( setIsSigningUp(false)} /> @@ -36,7 +35,6 @@ const Member = ({ user, isSigningUp, setIsSigningUp, handleLogin, handleLogout } setIsSigningUp(true)} /> )} - )} ); }; From 44012cfc93391263635aa25a34c502f6fb6d25b7 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 19:25:13 +0100 Subject: [PATCH 096/127] fix: change colors to pass accessibility score in lighthouse --- frontend/src/components/LoginForm.jsx | 6 +++--- frontend/src/components/SignupForm.jsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx index 998c2bdb4..b77569e77 100644 --- a/frontend/src/components/LoginForm.jsx +++ b/frontend/src/components/LoginForm.jsx @@ -61,7 +61,7 @@ const StyledInput = styled.input` const StyledButton = styled.button` - background: #2e8b57; + background: #256b45; color: #fff; border: none; padding: 0.7rem 1.2rem; @@ -71,7 +71,7 @@ const StyledButton = styled.button` font-size: 1rem; transition: background 0.2s; &:hover { - background: #256b45; + background: #1D5334; } `; @@ -83,7 +83,7 @@ const AuthActions = styled.div` `; const ToggleAuth = styled.span` - color: #2e8b57; + color: #1D5334; cursor: pointer; font-size: 0.95rem; text-decoration: underline; diff --git a/frontend/src/components/SignupForm.jsx b/frontend/src/components/SignupForm.jsx index 818c9e728..2dbd5097b 100644 --- a/frontend/src/components/SignupForm.jsx +++ b/frontend/src/components/SignupForm.jsx @@ -57,7 +57,7 @@ const StyledInput = styled.input` `; const StyledButton = styled.button` - background: #2e8b57; + background: #256b45; color: #fff; border: none; padding: 0.7rem 1.2rem; @@ -67,7 +67,7 @@ const StyledButton = styled.button` font-size: 1rem; transition: background 0.2s; &:hover { - background: #256b45; + background: #1D5334; } `; @@ -79,7 +79,7 @@ const AuthActions = styled.div` `; const ToggleAuth = styled.span` - color: #2e8b57; + color: #1D5334; cursor: pointer; font-size: 0.95rem; text-decoration: underline; From d7a6c85495ed7f1ca0236b43cda7e0a18fafa650 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 19:26:04 +0100 Subject: [PATCH 097/127] style: add styling to logout button in navigation menu --- frontend/src/components/Navigation.jsx | 31 ++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index 1888d8037..f212f3322 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -43,6 +43,8 @@ const NavList = styled.ul` const NavItem = styled.li` font-size: 1.1rem; + display: flex; + align-items: center; `; const NavLink = styled(Link)` @@ -56,7 +58,32 @@ const NavLink = styled(Link)` transition: color 0.2s; padding: 0.2rem 0.3rem; &:hover { - color: #4e8cff; + text-decoration: underline; + } + + @media ${media.tablet} { + font-size: 1.08rem; + padding: 0.3rem 0.5rem; + } + @media ${media.desktop} { + font-size: 1.1rem; + padding: 0.4rem 0.7rem; + } +`; + +const LogoutBtn = styled.button` + background: none; + border: none; + color: #1A6A1A; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + font-family: inherit; + line-height: inherit; + transition: color 0.2s; + padding: 0.2rem 0.3rem; + &:hover { + text-decoration: underline; } @media ${media.tablet} { @@ -87,7 +114,7 @@ const Navigation = () => { )} {user ? ( - + Logout ) : ( Login )} From 7a2953995a735416b5802655a98fb003f81d9db5 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 19:28:34 +0100 Subject: [PATCH 098/127] refactor: change design and content --- frontend/src/pages/about.jsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/about.jsx b/frontend/src/pages/about.jsx index 15b8463db..5daf04764 100644 --- a/frontend/src/pages/about.jsx +++ b/frontend/src/pages/about.jsx @@ -1,7 +1,6 @@ import styled from "styled-components"; -import { motion } from "framer-motion"; -const Container = styled(motion.div)` +const Container = styled.div` max-width: 900px; margin: 3rem auto; padding: 2rem; @@ -63,17 +62,13 @@ const Footer = styled.div` margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid #eee; - color: #777; + color: #232222; font-size: 0.95rem; `; const About = () => { return ( - + About PantryMatch Find recipes with what you already have at home @@ -90,7 +85,7 @@ const About = () => { How It Works Simply enter the ingredients you have in your kitchen, choose your - preferred search mode, and get instant recipe suggestions. You can + preferred search filters, and get instant recipe suggestions. You can explore full cooking instructions and save your favorite recipes for later. @@ -100,7 +95,7 @@ const About = () => { Main Features Search recipes by ingredients - Filter: exact match or allow extra ingredients + Filter: exact match or allow extra ingredients, diets and intolerances View full recipe instructions Save favorite recipes (for logged-in users) Personal recipe history From 8a0eff07ab927f1540039dd0bec0f81f8a5b9453 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 19:29:36 +0100 Subject: [PATCH 099/127] fix: change colors to pass accessibility score in lighthouse --- frontend/src/components/SearchRecipe.jsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index 5a0ef65a4..d813974a9 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -47,7 +47,7 @@ const Input = styled.input` `; const AddButton = styled.button` - background: #2e8b57; + background: #22633E; color: #fff; border: none; padding: 0.5rem 1.2rem; @@ -57,9 +57,6 @@ const AddButton = styled.button` width: 90px; transition: background 0.2s; - &:hover { - background: #119a65; - } @media ${media.tablet}, ${media.desktop} { width: auto; @@ -75,7 +72,7 @@ const IngredientsInfo = styled.div` const IngredientTag = styled.span` display: inline-block; background: #e8f5e9; - color: #2e8b57; + color: #1D5334; padding: 0.3rem 0.6rem; border-radius: 12px; margin: 0.2rem; @@ -115,7 +112,7 @@ const Radio = styled.input` `; const ShowButton = styled.button` - background: #2e8b57; + background: #22633E; color: #fff; border: none; padding: 0.7rem 2rem; @@ -126,9 +123,6 @@ const ShowButton = styled.button` box-shadow: 0 4px 16px rgba(46, 139, 87, 0.15); transition: background 0.2s; - &:hover { - background: #119a65; - } &:disabled { background: #ccc; From 2a583b398595c23c2f381bf405ca20f61922ff7d Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 19:31:32 +0100 Subject: [PATCH 100/127] fix: update colors to pass accissibility score in lighthouse and improve error handling --- frontend/src/pages/SavedRecipes.jsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index dd455eb9f..dd767f185 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -65,7 +65,7 @@ const CardContent = styled.div` flex-direction: column; `; -const RecipeTitle = styled.h3` +const RecipeTitle = styled.h2` margin: 0.5rem 0; color: #222; `; @@ -85,8 +85,8 @@ const ButtonRow = styled.div` const ToggleBtn = styled.button` background: none; - border: 1px solid #2e8b57; - color: #2e8b57; + border: 1px solid #22633E; + color: #22633E; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; @@ -95,7 +95,7 @@ const ToggleBtn = styled.button` transition: all 0.2s; &:hover { - background: #2e8b57; + background: #22633E; color: white; } `; @@ -124,6 +124,13 @@ const EmptyState = styled.div` } `; +const ErrorMsg = styled.p` + color: #990606; + font-weight: 500; + margin: 1rem 0; + text-align: center; +`; + const Details = styled.div` margin-top: 1rem; padding-top: 1rem; @@ -148,6 +155,7 @@ const SavedRecipes = () => { const [recipes, setRecipes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [deleteError, setDeleteError] = useState(null); const [expandedId, setExpandedId] = useState(null); useEffect(() => { @@ -177,8 +185,9 @@ const SavedRecipes = () => { try { await deleteRecipe(recipeId, user.accessToken); setRecipes(recipes.filter(r => r._id !== recipeId)); + setDeleteError(null); } catch (err) { - alert("Failed to delete recipe"); + setDeleteError(err.message); } }; @@ -199,7 +208,7 @@ const SavedRecipes = () => { return ( Saved Recipes -

Error: {error}

+ Error: {error}
); } @@ -208,6 +217,7 @@ const SavedRecipes = () => { Saved Recipes Your recipe collection + {deleteError && {deleteError}} {recipes.length === 0 ? ( From 876a33491f912dd718ae896b5c6c45a309b8f8d7 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Mon, 2 Mar 2026 19:33:10 +0100 Subject: [PATCH 101/127] fix: update colors for accessibility score and improve error handling --- frontend/src/components/RecipeCard.jsx | 55 +++++++++++++++++++------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 5172e14d4..d808bfd7c 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -62,11 +62,11 @@ const IngredientInfo = styled.p` `; const Matched = styled.span` - color: #2e8b57; + color: #1D5334; `; const Missing = styled.span` - color: #ff6b6b; + color: #a40505; `; const BtnRow = styled.div` @@ -78,8 +78,8 @@ const BtnRow = styled.div` const ToggleBtn = styled.button` background: none; - border: 1px solid #2e8b57; - color: #2e8b57; + border: 1px solid #1D5334; + color: #1D5334; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; @@ -88,25 +88,30 @@ const ToggleBtn = styled.button` transition: all 0.2s; &:hover { - background: #2e8b57; + background: #1D5334; color: white; } `; const SaveBtn = styled.button` - background: #ff9800; + background: #FFEACC; border: none; - color: white; + color: #1D5334; padding: 0.5rem 1rem; border-radius: 4px; + border: 1px solid #1D5334; cursor: pointer; margin-top: 0.5rem; font-size: 0.95rem; transition: all 0.2s; - &:hover { - background: #e68900; - } +`; + +const SavedBtn = styled(SaveBtn)` + background: #e8f5e9; + color: #1D5334; + border: 1px solid #1D5334; + cursor: default; `; const Details = styled.div` @@ -128,6 +133,12 @@ const Details = styled.div` } `; +const ErrorMsg = styled.p` + color: #990606; + font-weight: 500; + margin: 1rem 0; +`; + const RecipeCard = ({ recipe }) => { const { user } = useUserStore(); @@ -135,6 +146,8 @@ const RecipeCard = ({ recipe }) => { const [isOpen, setIsOpen] = useState(false); const [details, setDetails] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(""); const handleToggle = async () => { if (!isOpen && !details) { @@ -143,7 +156,7 @@ const RecipeCard = ({ recipe }) => { const data = await fetchRecipeDetails(recipe.id); setDetails(data); } catch (err) { - console.error("Failed to fetch details:", err); + setError("Failed to fetch recipe details"); } finally { setLoadingDetails(false); } @@ -158,8 +171,12 @@ const handleSave = async () => { } try { - const recipeDetails = details || await fetchRecipeDetails(recipe.id); + let recipeDetails = details; + if (!recipeDetails) { + recipeDetails = await fetchRecipeDetails(recipe.id); + setDetails(recipeDetails); + } await saveRecipe( { spoonacularId: recipeDetails.id, @@ -174,9 +191,14 @@ const handleSave = async () => { user.accessToken ); - alert("Recipe saved!"); + setSaved(true); + setError(""); } catch (error) { - alert(error.message); + if (error.message === "Recipe already saved") { + setSaved(true); + } else { + setError(error.message); + } } }; @@ -204,9 +226,14 @@ const handleSave = async () => { {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} + {saved ? ( + Saved + ) : ( Save Recipe + )} + {error && {error}} From 5be16ac50c7213fa2f670da834c6c225e1d9578e Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 3 Mar 2026 10:34:47 +0100 Subject: [PATCH 102/127] fix: replaced clipboard copy functionality with navigator.share() as the copy-to-clipboard was not visable in Firefox and Microsoft Edge. --- frontend/src/components/CopyButton.jsx | 33 ---------------------- frontend/src/components/RecipeCard.jsx | 4 +-- frontend/src/components/ShareButton.jsx | 37 +++++++++++++++++++++++++ frontend/src/pages/SavedRecipes.jsx | 4 +-- 4 files changed, 41 insertions(+), 37 deletions(-) delete mode 100644 frontend/src/components/CopyButton.jsx create mode 100644 frontend/src/components/ShareButton.jsx diff --git a/frontend/src/components/CopyButton.jsx b/frontend/src/components/CopyButton.jsx deleted file mode 100644 index ea61828ff..000000000 --- a/frontend/src/components/CopyButton.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useState } from "react"; -import styled from "styled-components"; -import { Copy, Check } from "lucide-react"; - -const Button = styled.button` - background: none; - border: none; - cursor: pointer; - margin-left: 0.5rem; - vertical-align: middle; -`; - -const CopyButton = ({ url }) => { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy: ", err); - } - }; - - return ( - - ); -}; - -export default CopyButton; diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index d808bfd7c..1a468758e 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -4,7 +4,7 @@ import { useUserStore } from "../stores/userStore"; import { useNavigate } from "react-router-dom"; import { fetchRecipeDetails } from "../api/api"; import { saveRecipe } from "../api/api"; -import CopyButton from "./CopyButton"; +import ShareButton from "./ShareButton"; import { media } from "../styles/media"; const Card = styled.div` @@ -243,7 +243,7 @@ const handleSave = async () => { ⏱️ {details.readyInMinutes !== null && details.readyInMinutes !== undefined ? `${details.readyInMinutes} min` : 'unknown time'} {' | '} 🍽️ {details.servings !== null && details.servings !== undefined ? `${details.servings} servings` : 'unknown servings'} - + {details.summary && (
diff --git a/frontend/src/components/ShareButton.jsx b/frontend/src/components/ShareButton.jsx new file mode 100644 index 000000000..0712fb2f1 --- /dev/null +++ b/frontend/src/components/ShareButton.jsx @@ -0,0 +1,37 @@ +import styled from "styled-components"; +import { Share2 } from "lucide-react"; + +const Button = styled.button` + background: none; + border: none; + cursor: pointer; + margin-left: 0.5rem; + vertical-align: middle; +`; + +const CopyButton = ({ url, title }) => { + const handleShare = async () => { + try { + await navigator.share({ + title: title || "Check out this recipe!", + url, + }); + } catch (err) { + if (err.name !== "AbortError") { + console.error("Share failed:", err); + } + } + }; + + return ( + + ); +}; + +export default CopyButton; diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index dd767f185..04ecab18b 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -3,7 +3,7 @@ import styled from "styled-components"; import { useUserStore } from "../stores/userStore"; import { getSavedRecipes, deleteRecipe } from "../api/api"; import { media } from "../styles/media"; -import CopyButton from "../components/CopyButton"; +import ShareButton from "../components/ShareButton"; import LoadingSpinner from "../components/LoadingSpinner"; const Container = styled.div` @@ -254,7 +254,7 @@ const SavedRecipes = () => { {isExpanded && (
- + {recipe.summary && (
)} From be37c6003517edc7bb3509693e97b7755df15c19 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 3 Mar 2026 11:51:51 +0100 Subject: [PATCH 103/127] fix: removed summary from details and added addRecipeInstructions/instructionsRequired to make sure instructions are fetched properly for search and saved recipes components --- backend/routes/recipeRoutes.js | 3 ++- frontend/src/components/RecipeCard.jsx | 6 +----- frontend/src/pages/SavedRecipes.jsx | 4 ---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index 2f7cbbc78..815933bbb 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -57,6 +57,8 @@ router.get("/search", async (req, res) => { intolerances, sort: mode === "exact" ? "min-missing-ingredients" : "max-used-ingredients", addRecipeInformation: true, + addRecipeInstructions: true, + instructionsRequired: true, fillIngredients: true, apiKey: process.env.SPOONACULAR_API_KEY, }; @@ -119,7 +121,6 @@ router.get("/details/:id", async (req, res) => { response: { id: details.id, title: details.title, - summary: details.summary, instructions: details.instructions, analyzedInstructions: details.analyzedInstructions, extendedIngredients: details.extendedIngredients, diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 1a468758e..0b8c4c686 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -182,11 +182,11 @@ const handleSave = async () => { spoonacularId: recipeDetails.id, title: recipeDetails.title, image: recipe.image, - summary: recipeDetails.summary, readyInMinutes: recipeDetails.readyInMinutes, servings: recipeDetails.servings, extendedIngredients: recipeDetails.extendedIngredients, instructions: recipeDetails.instructions, + analyzedInstructions: recipeDetails.analyzedInstructions, }, user.accessToken ); @@ -245,10 +245,6 @@ const handleSave = async () => { 🍽️ {details.servings !== null && details.servings !== undefined ? `${details.servings} servings` : 'unknown servings'} - {details.summary && ( -
- )} -

All ingredients:

    {details.extendedIngredients diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index 04ecab18b..c775a2d4b 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -255,10 +255,6 @@ const SavedRecipes = () => { {isExpanded && (
    - {recipe.summary && ( -
    - )} -

    Ingredients:

      {recipe.extendedIngredients?.map((ing, index) => ( From c7b77c6a461c10bac80906a3cb984e335b7bdf5b Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 3 Mar 2026 11:52:18 +0100 Subject: [PATCH 104/127] fix: remove summary field from recipe schema and ensure analyzedInstructions structure is correctly defined --- backend/model/Recipe.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/model/Recipe.js b/backend/model/Recipe.js index d4b9af6ff..e4b2c53b9 100644 --- a/backend/model/Recipe.js +++ b/backend/model/Recipe.js @@ -19,7 +19,6 @@ const recipeSchema = new mongoose.Schema({ }, title: String, image: String, - summary: String, readyInMinutes: Number, servings: Number, extendedIngredients: [ingredientSchema], // detailed ingredient info from Spoonacular @@ -28,6 +27,17 @@ const recipeSchema = new mongoose.Schema({ type: Date, default: Date.now, }, + analyzedInstructions: [ + { + name: String, + steps: [ + { + number: Number, + step: String + } + ] + } + ], }); const Recipe = mongoose.model("Recipe", recipeSchema); From e6a6db345c7d244b8e7e3f0ab4ac7c080419f87e Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 3 Mar 2026 21:23:15 +0100 Subject: [PATCH 105/127] clean code: adding clearer comments, aria labels and variable names --- backend/routes/recipeRoutes.js | 10 +++++----- frontend/src/components/SearchRecipe.jsx | 16 ++++++++++++++-- frontend/src/pages/SavedRecipes.jsx | 17 +++++++++++++---- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index 815933bbb..cc4af56d9 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -7,7 +7,7 @@ import authenticateUser from "../middleware/authMiddleware.js"; const router = express.Router(); -// (GET) Search for recipes based on ingredients and mode (allowExtra or exact), diet, intolerances complexSearch endpoint +// (GET) Search recipes router.get("/search", async (req, res) => { const { ingredients, mode, dairyFree, glutenFree, vegetarian, vegan } = req.query; @@ -34,7 +34,7 @@ router.get("/search", async (req, res) => { }); } - // filter and diet strings for Spoonacular API + // filter and diet parameters const diet = [ vegetarian === "true" ? "vegetarian" : null, vegan === "true" ? "vegan" : null @@ -243,12 +243,12 @@ router.post("/", authenticateUser, async (req, res) => { // (DELETE) Delete a saved recipe router.delete("/:id", authenticateUser, async (req, res) => { try { - const recipe = await Recipe.findOneAndDelete({ + const deleted = await Recipe.findOneAndDelete({ _id: req.params.id, userId: req.user._id, // make sure user can only delete their own saved recipes }); - if (!recipe) { + if (!deleted) { return res.status(404).json({ success: false, message: "Recipe not found or you don't have permission to delete it", @@ -259,7 +259,7 @@ router.delete("/:id", authenticateUser, async (req, res) => { res.status(200).json({ success: true, message: "Recipe deleted successfully", - response: recipe, + response: deleted, }); } catch (error) { res.status(500).json({ diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index d813974a9..b350ace2d 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -136,6 +136,7 @@ const ErrorMsg = styled.p` margin: 1rem 0; `; +//states const SearchRecipe = () => { const [input, setInput] = useState(""); const { @@ -153,6 +154,7 @@ const SearchRecipe = () => { searchRecipes, } = useRecipeActions(); + //handlers const handleAdd = () => { const trimmed = input.trim().toLowerCase(); if (trimmed && !ingredients.includes(trimmed)) { @@ -166,6 +168,8 @@ const SearchRecipe = () => { handleAdd(); }; + + // Toggle diet and intolerance filters const toggleFilter = (key) => { setFilters({ ...filters, [key]: !filters[key] }); }; @@ -179,7 +183,11 @@ const SearchRecipe = () => { value={input} onChange={(e) => setInput(e.target.value)} /> - Add + + Add + {ingredients.length > 0 && ( @@ -255,7 +263,10 @@ const SearchRecipe = () => { - + {loading ? "Searching..." : "Show recipes"} @@ -263,6 +274,7 @@ const SearchRecipe = () => { {error && {error}} {recipes && recipes.length > 0 && } {hasSearched && !loading && !error && recipes.length === 0 && ( +

      No recipes found. Try different ingredients!

      )} diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index c775a2d4b..0d6199cb9 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -150,6 +150,11 @@ const Details = styled.div` } `; +const DetailTitle = styled.h4` + margin-top: 1rem; + color: #222; +`; + const SavedRecipes = () => { const { user } = useUserStore(); const [recipes, setRecipes] = useState([]); @@ -242,27 +247,31 @@ const SavedRecipes = () => { - toggleDetails(recipe._id)}> + toggleDetails(recipe._id)}> {isExpanded ? "Show less" : "Show more"} - handleDelete(recipe._id)}> + + handleDelete(recipe._id)}> Delete + {isExpanded && (
      -

      Ingredients:

      + Ingredients:
        {recipe.extendedIngredients?.map((ing, index) => (
      • {ing.original || ing.name}
      • ))}
      -

      Instructions:

      + Instructions: {recipe.analyzedInstructions?.length > 0 ? (
        {recipe.analyzedInstructions[0].steps.map((step) => ( From 20bdb7644077d4b0b8601390a2729a1eaaa8c457 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 3 Mar 2026 21:24:24 +0100 Subject: [PATCH 106/127] refactor: clean code structure, add variable helpers for more readable code --- frontend/src/components/RecipeCard.jsx | 162 ++++++++++++++----------- 1 file changed, 92 insertions(+), 70 deletions(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 0b8c4c686..caef4a0ca 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -139,16 +139,23 @@ const ErrorMsg = styled.p` margin: 1rem 0; `; +const DetailTitle = styled.h4` + margin-top: 1rem; + color: #222; +`; const RecipeCard = ({ recipe }) => { const { user } = useUserStore(); const navigate = useNavigate(); + + // State const [isOpen, setIsOpen] = useState(false); const [details, setDetails] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(""); + // handlers const handleToggle = async () => { if (!isOpen && !details) { setLoadingDetails(true); @@ -164,101 +171,116 @@ const RecipeCard = ({ recipe }) => { setIsOpen(!isOpen); }; -const handleSave = async () => { - if (!user) { - navigate("/member"); - return; - } + const handleSave = async () => { + if (!user) { + navigate("/member"); + return; + } - try { - let recipeDetails = details; + try { + let recipeDetails = details; + + if (!recipeDetails) { + recipeDetails = await fetchRecipeDetails(recipe.id); + setDetails(recipeDetails); + } + + await saveRecipe( + { + spoonacularId: recipeDetails.id, + title: recipeDetails.title, + image: recipe.image, + readyInMinutes: recipeDetails.readyInMinutes, + servings: recipeDetails.servings, + extendedIngredients: recipeDetails.extendedIngredients, + instructions: recipeDetails.instructions, + analyzedInstructions: recipeDetails.analyzedInstructions, + }, + user.accessToken + ); - if (!recipeDetails) { - recipeDetails = await fetchRecipeDetails(recipe.id); - setDetails(recipeDetails); - } - await saveRecipe( - { - spoonacularId: recipeDetails.id, - title: recipeDetails.title, - image: recipe.image, - readyInMinutes: recipeDetails.readyInMinutes, - servings: recipeDetails.servings, - extendedIngredients: recipeDetails.extendedIngredients, - instructions: recipeDetails.instructions, - analyzedInstructions: recipeDetails.analyzedInstructions, - }, - user.accessToken - ); - - setSaved(true); - setError(""); - } catch (error) { - if (error.message === "Recipe already saved") { setSaved(true); - } else { - setError(error.message); + setError(""); + } catch (error) { + if (error.message === "Recipe already saved") { + setSaved(true); + } else { + setError(error.message); + } } - } -}; + }; + + // helper variables + const matchedIngredients = recipe.usedIngredients + ?.map((i) => i.name || i.original) + .join(", ") || "No matched ingredients"; + + const missingIngredients = recipe.missedIngredients + ?.map((i) => i.name || i.original) + .join(", ") || "No missing ingredients"; + const displayTime = details?.readyInMinutes + ? `${details.readyInMinutes} min` + : "unknown time"; + + const displayServings = details?.servings + ? `${details.servings} servings` + : "unknown servings"; + + const recipeUrl = details?.sourceUrl || + `https://spoonacular.com/recipes/${recipe.title.toLowerCase().replace(/\s+/g, "-")}-${recipe.id}`; return ( - {recipe.image && {recipe.title}} + {recipe.title} + - {recipe.title} - - - ✅ Matched: {recipe.usedIngredients?.map((i) => i.name || i.original).join(", ") || "No matched ingredients"} - - - - - - ❌ Missing: {recipe.missedIngredients?.map((i) => i.name || i.original).join(", ") || "No missing ingredients"} - - - - - - {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} - - - {saved ? ( - Saved - ) : ( - Save Recipe - )} + {recipe.title} + + + ✅ Matched: {matchedIngredients} + + + + ❌ Missing: {missingIngredients} + - - {error && {error}} + + + {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} + + + {saved ? ( + Saved + ) : ( + Save Recipe + )} + + + {error && {error}} {isOpen && details && ( -
        +
        - ⏱️ {details.readyInMinutes !== null && details.readyInMinutes !== undefined ? `${details.readyInMinutes} min` : 'unknown time'} - {' | '} - 🍽️ {details.servings !== null && details.servings !== undefined ? `${details.servings} servings` : 'unknown servings'} - + ⏱️ {displayTime} | 🍽️ {displayServings} + -

        All ingredients:

        + + All ingredients:
          {details.extendedIngredients - ?.filter( - (ing) => - ing.original && - !/other (things|ingredients) needed/i.test(ing.original) + ?.filter((ing) => + ing.original && !/other (things|ingredients) needed/i.test(ing.original) ) .map((ing, index) => (
        • {ing.original}
        • ))}
        -

        Instructions:

        + Instructions: {details.analyzedInstructions?.length > 0 ? (
          {details.analyzedInstructions[0].steps.map((step) => ( From 6113a49fdfe60d339d29add4e224233c4cfa289e Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Tue, 3 Mar 2026 21:25:05 +0100 Subject: [PATCH 107/127] fix: change name from cpy to share and size changes --- frontend/src/components/ShareButton.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ShareButton.jsx b/frontend/src/components/ShareButton.jsx index 0712fb2f1..a5b18b7af 100644 --- a/frontend/src/components/ShareButton.jsx +++ b/frontend/src/components/ShareButton.jsx @@ -9,7 +9,7 @@ const Button = styled.button` vertical-align: middle; `; -const CopyButton = ({ url, title }) => { +const ShareButton = ({ url, title }) => { const handleShare = async () => { try { await navigator.share({ @@ -29,9 +29,9 @@ const CopyButton = ({ url, title }) => { title="Share recipe" aria-label="Share recipe" > - + ); }; -export default CopyButton; +export default ShareButton; From c96868d607f2cea7be9e721fdc303cfd6b463857 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Thu, 5 Mar 2026 09:59:42 +0100 Subject: [PATCH 108/127] remove /add space and comments --- backend/routes/recipeRoutes.js | 14 +++++--------- frontend/src/hooks/useRecipeActions.js | 2 +- frontend/src/stores/recipeStore.js | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index cc4af56d9..75be1aba3 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -18,7 +18,6 @@ router.get("/search", async (req, res) => { response: null, }); } - // split ingredients by comma and trim whitespace, also filter out empty strings const ingredientList = ingredients.split(",") @@ -33,7 +32,6 @@ router.get("/search", async (req, res) => { response: null, }); } - // filter and diet parameters const diet = [ vegetarian === "true" ? "vegetarian" : null, @@ -63,13 +61,11 @@ router.get("/search", async (req, res) => { apiKey: process.env.SPOONACULAR_API_KEY, }; - const response = await axios.get( "https://api.spoonacular.com/recipes/complexSearch", { params } ); - // fetching usedIngredients, missedIngredients, unusedIngredients, extendedIngredients and normalizing them const recipes = response.data.results.map(recipe => ({ ...recipe, usedIngredients: recipe.usedIngredients || [], @@ -99,7 +95,7 @@ router.get("/search", async (req, res) => { } }); -// (GET) Get recipe details by Spoonacular ID +// (GET) Get recipe details router.get("/details/:id", async (req, res) => { const { id } = req.params; @@ -109,13 +105,13 @@ router.get("/details/:id", async (req, res) => { { params: { apiKey: process.env.SPOONACULAR_API_KEY, - includeNutrition: false + } } ); const details = response.data; - + res.status(200).json({ success: true, response: { @@ -157,7 +153,7 @@ router.get("/", authenticateUser, async (req, res) => { } }); -// (GET) Get recipe by ID +// (GET) Get single saved recipe by ID router.get("/:id", authenticateUser, async (req, res) => { const { id } = req.params; @@ -196,7 +192,7 @@ router.get("/:id", authenticateUser, async (req, res) => { } catch (error) { res.status(500).json({ success: false, - message: "Recipe couldn't be found", + message: "Failed to fetch recipe", response: error.message || error, }); } diff --git a/frontend/src/hooks/useRecipeActions.js b/frontend/src/hooks/useRecipeActions.js index 4d7cfcc16..27695b382 100644 --- a/frontend/src/hooks/useRecipeActions.js +++ b/frontend/src/hooks/useRecipeActions.js @@ -20,7 +20,6 @@ export const useRecipeActions = () => { removeIngredient, } = useRecipeStore(); - const searchRecipes = async () => { if (ingredients.length < 1) { setError("Add at least 1 ingredient"); @@ -32,6 +31,7 @@ export const useRecipeActions = () => { try { const data = await fetchRecipeByIngredients(ingredients, mode, filters); setRecipes(data); + } catch (err) { setError(err.message); setRecipes([]); diff --git a/frontend/src/stores/recipeStore.js b/frontend/src/stores/recipeStore.js index 958504c3c..211239ca2 100644 --- a/frontend/src/stores/recipeStore.js +++ b/frontend/src/stores/recipeStore.js @@ -26,7 +26,7 @@ export const useRecipeStore = create((set) => ({ set((state) => ({ ingredients: state.ingredients.filter((i) => i !== ing), })), - + setRecipes: (recipes) => set({ recipes }), setLoading: (loading) => set({ loading }), setHasSearched: (hasSearched) => set({ hasSearched }), From 33f6810ac93131e53469314d4c6b292fd64cee21 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Thu, 5 Mar 2026 10:08:29 +0100 Subject: [PATCH 109/127] style: update color scheme for accessibility and improve comments for clarity --- frontend/src/components/Hero.jsx | 15 --------------- frontend/src/components/Navigation.jsx | 2 +- frontend/src/components/SearchRecipe.jsx | 2 +- frontend/src/pages/home.jsx | 4 ++-- 4 files changed, 4 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/Hero.jsx b/frontend/src/components/Hero.jsx index 7e7035548..8c25747d7 100644 --- a/frontend/src/components/Hero.jsx +++ b/frontend/src/components/Hero.jsx @@ -13,18 +13,6 @@ const fadeInUp = keyframes` } `; -const gradientShift = keyframes` - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -`; - const float = keyframes` 0%, 100% { transform: translateY(0px); @@ -105,8 +93,6 @@ const MainTitle = styled.h1` background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; - animation: ${gradientShift} 6s ease infinite, ${fadeInUp} 0.8s ease-out 0.2s - both; letter-spacing: -0.02em; line-height: 1.1; @@ -121,7 +107,6 @@ const Subtitle = styled.p` margin: 1.5rem 0 0; font-weight: 400; max-width: 600px; - animation: ${fadeInUp} 0.8s ease-out 0.4s both; line-height: 1.6; span { diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index f212f3322..87f2c43f7 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -73,8 +73,8 @@ const NavLink = styled(Link)` const LogoutBtn = styled.button` background: none; + color: #ab0303; border: none; - color: #1A6A1A; cursor: pointer; font-size: 1rem; font-weight: 600; diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index b350ace2d..d3354e77e 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -224,7 +224,7 @@ const SearchRecipe = () => { Allow extra ingredients (recipe may contain more) - {/* Diet and filters */} + {/* Diet and intolerance filters */} Diets Date: Thu, 5 Mar 2026 10:22:26 +0100 Subject: [PATCH 110/127] refactor: consolidate API imports, update styles for improved readability/accessibility, and enhance ingredient/instruction display, add variable helpers och more readable code. --- frontend/src/components/RecipeCard.jsx | 215 ++++++++++++++++--------- 1 file changed, 141 insertions(+), 74 deletions(-) diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index caef4a0ca..43cc43baa 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -2,8 +2,7 @@ import { useState } from "react"; import styled from "styled-components"; import { useUserStore } from "../stores/userStore"; import { useNavigate } from "react-router-dom"; -import { fetchRecipeDetails } from "../api/api"; -import { saveRecipe } from "../api/api"; +import { fetchRecipeDetails, saveRecipe } from "../api/api"; import ShareButton from "./ShareButton"; import { media } from "../styles/media"; @@ -48,7 +47,7 @@ const Title = styled.h3` const Info = styled.p` font-size: 0.9rem; - color: #666; + color: #353535; margin: 0.3rem 0; `; @@ -66,7 +65,7 @@ const Matched = styled.span` `; const Missing = styled.span` - color: #a40505; + color: #990606; `; const BtnRow = styled.div` @@ -95,7 +94,6 @@ const ToggleBtn = styled.button` const SaveBtn = styled.button` background: #FFEACC; - border: none; color: #1D5334; padding: 0.5rem 1rem; border-radius: 4px; @@ -112,6 +110,10 @@ const SavedBtn = styled(SaveBtn)` color: #1D5334; border: 1px solid #1D5334; cursor: default; + + &:disabled { + cursor: not-allowed; + } `; const Details = styled.div` @@ -119,11 +121,6 @@ const Details = styled.div` padding-top: 1rem; border-top: 1px solid #eee; - h4 { - margin-top: 1rem; - color: #222; - } - ul { padding-left: 1.5rem; } @@ -140,8 +137,60 @@ const ErrorMsg = styled.p` `; const DetailTitle = styled.h4` - margin-top: 1rem; + margin: 0; color: #222; + font-weight: bold; + font-size: 1.1rem; +`; + +const IngredientContainer = styled.div` + background: #e8f5e9; + padding: 0.5rem 1rem; + border-radius: 6px; + margin-top: 1rem; + +`; + +const InstructionList = styled.ol` + padding-left: 0.1rem; + margin-top: 1rem; + margin-bottom: 1rem; + counter-reset: step; +`; + +const InstructionStep = styled.li` + margin-bottom: 0.7rem; + line-height: 1.5; + position: relative; + list-style: none; + counter-increment: step; + + &::before { + content: counter(step); + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.7rem; + height: 1.7rem; + margin-right: 0.7rem; + background: #22633E; + color: #fff; + border-radius: 50%; + font-weight: bold; + font-size: 1rem; + } +`; + +const InstructionHtml = styled.div` + margin-top: 1rem; + margin-bottom: 1rem; + line-height: 1.5; +`; + +const InstructionContainer = styled.div` + padding: 0.5rem 1rem; + border-radius: 6px; + margin-top: 1rem; `; const RecipeCard = ({ recipe }) => { @@ -150,8 +199,10 @@ const RecipeCard = ({ recipe }) => { // State const [isOpen, setIsOpen] = useState(false); + const [details, setDetails] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); + const [saved, setSaved] = useState(false); const [error, setError] = useState(""); @@ -159,6 +210,8 @@ const RecipeCard = ({ recipe }) => { const handleToggle = async () => { if (!isOpen && !details) { setLoadingDetails(true); + setError(""); + try { const data = await fetchRecipeDetails(recipe.id); setDetails(data); @@ -201,9 +254,11 @@ const RecipeCard = ({ recipe }) => { setSaved(true); setError(""); + } catch (error) { if (error.message === "Recipe already saved") { setSaved(true); + setError("You have already saved this recipe."); // specific message for duplicate save } else { setError(error.message); } @@ -227,75 +282,87 @@ const RecipeCard = ({ recipe }) => { ? `${details.servings} servings` : "unknown servings"; - const recipeUrl = details?.sourceUrl || - `https://spoonacular.com/recipes/${recipe.title.toLowerCase().replace(/\s+/g, "-")}-${recipe.id}`; + const recipeUrl = details?.sourceUrl || `https://spoonacular.com/recipes/${recipe.id}`; return ( {recipe.title} - - {recipe.title} - - - ✅ Matched: {matchedIngredients} - - - - ❌ Missing: {missingIngredients} - - - - - {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} - - - {saved ? ( - Saved + + {recipe.title} + + + ✅ Matched: {matchedIngredients} + + + + ❌ Missing: {missingIngredients} + + + + + {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} + + + {saved ? ( + + Saved + + ) : ( + + Save Recipe + + )} + + + {error && {error}} + + + + {isOpen && details && ( +
          + + ⏱️ {displayTime} | 🍽️ {displayServings} + + + + + All ingredients: +
            + {details.extendedIngredients + ?.filter((ing) => + ing.original && !/other (things|ingredients) needed/i.test(ing.original) + ) + .map((ing, index) => ( +
          • {ing.original}
          • + ))} +
          +
          + + + Instructions: + {recipe.analyzedInstructions?.length > 0 ? ( + + {recipe.analyzedInstructions[0].steps.map((step) => ( + {step.step} + ))} + + ) : recipe.instructions ? ( + ) : ( - Save Recipe +

          No instructions available

          )} - - - {error && {error}} - - - - {isOpen && details && ( -
          - - ⏱️ {displayTime} | 🍽️ {displayServings} - - - - All ingredients: -
            - {details.extendedIngredients - ?.filter((ing) => - ing.original && !/other (things|ingredients) needed/i.test(ing.original) - ) - .map((ing, index) => ( -
          • {ing.original}
          • - ))} -
          - - Instructions: - {details.analyzedInstructions?.length > 0 ? ( -
            - {details.analyzedInstructions[0].steps.map((step) => ( -
          1. {step.step}
          2. - ))} -
          - ) : details.instructions ? ( -
          - ) : ( -

          No instructions available

          - )} -
          - )} - - ); -}; - +
          +
          + )} +
          + ); + }; export default RecipeCard; \ No newline at end of file From 87b2a2757787b5e7f65480af610e06b8b86db9ee Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Thu, 5 Mar 2026 10:24:12 +0100 Subject: [PATCH 111/127] feat: move and import empty state component and enhance recipe display with ingredient and instruction sections add variable helpers --- frontend/src/components/EmptyState.jsx | 32 ++++ frontend/src/pages/SavedRecipes.jsx | 247 +++++++++++++++---------- 2 files changed, 179 insertions(+), 100 deletions(-) create mode 100644 frontend/src/components/EmptyState.jsx diff --git a/frontend/src/components/EmptyState.jsx b/frontend/src/components/EmptyState.jsx new file mode 100644 index 000000000..8e1ea3df6 --- /dev/null +++ b/frontend/src/components/EmptyState.jsx @@ -0,0 +1,32 @@ +import styled from "styled-components"; + +const EmptyContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +const EmptyTitle = styled.h2` + font-size: 24px; + font-weight: bold; + margin-bottom: 10px; + color: #353535; +`; + +const EmptyText = styled.p` + font-size: 16px; + color: #353535; +`; + + +const EmptyState = () => { + return ( + + No saved recipes yet + Start searching for recipes and save your favorites! + + ); +}; + +export default EmptyState; \ No newline at end of file diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index 0d6199cb9..acd49503a 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -5,6 +5,7 @@ import { getSavedRecipes, deleteRecipe } from "../api/api"; import { media } from "../styles/media"; import ShareButton from "../components/ShareButton"; import LoadingSpinner from "../components/LoadingSpinner"; +import EmptyState from "../components/EmptyState"; const Container = styled.div` margin-top: 2rem; @@ -17,12 +18,6 @@ const Title = styled.h1` color: #2e8b57; `; -const Subtitle = styled.p` - text-align: center; - font-size: 1.1rem; - color: #666; - margin-bottom: 2.5rem; -`; const RecipeGrid = styled.div` display: flex; flex-direction: column; @@ -72,7 +67,7 @@ const RecipeTitle = styled.h2` const Info = styled.p` font-size: 0.9rem; - color: #666; + color: #353535; margin: 0.3rem 0; `; @@ -101,7 +96,7 @@ const ToggleBtn = styled.button` `; const DeleteBtn = styled.button` - background: #c0392b; + background: #ab0303; border: none; color: white; padding: 0.5rem 1rem; @@ -109,23 +104,10 @@ const DeleteBtn = styled.button` cursor: pointer; margin-top: 0.5rem; - &:hover { - background: #a93226; - } -`; - -const EmptyState = styled.div` - text-align: center; - padding: 3rem 1rem; - color: #666; - - h3 { - margin-bottom: 1rem; - } `; const ErrorMsg = styled.p` - color: #990606; + color: #ab0303; font-weight: 500; margin: 1rem 0; text-align: center; @@ -136,11 +118,6 @@ const Details = styled.div` padding-top: 1rem; border-top: 1px solid #eee; - h4 { - margin-top: 1rem; - color: #222; - } - ul { padding-left: 1.5rem; } @@ -155,12 +132,65 @@ const DetailTitle = styled.h4` color: #222; `; +const IngredientContainer = styled.div` + background: #e8f5e9; + padding: 0.5rem 1rem; + border-radius: 6px; + margin-top: 1rem; +`; + +const InstructionList = styled.ol` + padding-left: 0.1rem; + margin-top: 1rem; + margin-bottom: 1rem; + counter-reset: step; +`; + +const InstructionStep = styled.li` + margin-bottom: 0.7rem; + line-height: 1.5; + position: relative; + list-style: none; + counter-increment: step; + + &::before { + content: counter(step); + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.7rem; + height: 1.7rem; + margin-right: 0.7rem; + background: #22633E; + color: #fff; + border-radius: 50%; + font-weight: bold; + font-size: 1rem; + } +`; + +const InstructionHtml = styled.div` + margin-top: 1rem; + margin-bottom: 1rem; + line-height: 1.5; +`; + +const InstructionContainer = styled.div` + padding: 0.5rem 1rem; + border-radius: 6px; + margin-top: 1rem; +`; + const SavedRecipes = () => { const { user } = useUserStore(); + + //state const [recipes, setRecipes] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [deleteError, setDeleteError] = useState(null); + const [expandedId, setExpandedId] = useState(null); useEffect(() => { @@ -169,11 +199,13 @@ const SavedRecipes = () => { } }, [user]); + //handlers const fetchSavedRecipes = async () => { try { setLoading(true); + setError(null); + const data = await getSavedRecipes(user.accessToken); - console.log("Fetched saved recipes:", data); // Debug setRecipes(data); } catch (err) { setError(err.message); @@ -199,7 +231,8 @@ const SavedRecipes = () => { const toggleDetails = (id) => { setExpandedId(expandedId === id ? null : id); }; - + + //loading state if (loading) { return ( @@ -208,7 +241,7 @@ const SavedRecipes = () => { ); } - + //error state if (error) { return ( @@ -218,80 +251,94 @@ const SavedRecipes = () => { ); } + // Main render return ( Saved Recipes - Your recipe collection {deleteError && {deleteError}} - - {recipes.length === 0 ? ( - -

          No saved recipes yet

          -

          Start searching for recipes and save your favorites!

          -
          - ) : ( - - {recipes.map((recipe) => { - const isExpanded = expandedId === recipe._id; - - return ( - - - {recipe.image && {recipe.title}} - - {recipe.title} - - - {recipe.readyInMinutes && `⏱️ ${recipe.readyInMinutes} min`} - {recipe.servings && ` | 🍽️ ${recipe.servings} servings`} - - - - toggleDetails(recipe._id)}> - {isExpanded ? "Show less" : "Show more"} - - - handleDelete(recipe._id)}> - Delete - - - - - - - {isExpanded && ( -
          - - Ingredients: -
            - {recipe.extendedIngredients?.map((ing, index) => ( -
          • {ing.original || ing.name}
          • - ))} -
          - - Instructions: - {recipe.analyzedInstructions?.length > 0 ? ( -
            - {recipe.analyzedInstructions[0].steps.map((step) => ( -
          1. {step.step}
          2. + + {/* show empty state if no saved recipes, otherwise show recipe grid */} + {recipes.length === 0 ? ( + + ) : ( + + {recipes.map((recipe) => { + const isExpanded = expandedId === recipe._id; + + //helper variables + const displayTime = recipe.readyInMinutes + ? `${recipe.readyInMinutes} min` + : "unknown time"; + + const displayServings = recipe.servings + ? `${recipe.servings} servings` + : "unknown servings"; + + const recipeUrl = recipe.sourceUrl || + `https://spoonacular.com/recipes/${recipe.title.toLowerCase().replace(/\s+/g, "-")}-${recipe.spoonacularId}`; + + return ( + + + {recipe.title} + + {recipe.title} + + + ⏱️ {displayTime} | 🍽️ {displayServings} + + + + toggleDetails(recipe._id)}> + {isExpanded ? "Show less" : "Show more"} + + + handleDelete(recipe._id)}> + Delete + + + + + + + {isExpanded && ( +
            + + + + Ingredients: +
              + {recipe.extendedIngredients?.map((ing, index) => ( +
            • {ing.original || ing.name}
            • ))} -
          - ) : recipe.instructions ? ( -
          - ) : ( -

          No instructions available

          - )} -
          - )} -
          - ); - })} -
          - )} -
          - ); -}; +
    + + + + Instructions: + {recipe.analyzedInstructions?.length > 0 ? ( + + {recipe.analyzedInstructions[0].steps.map((step) => ( + {step.step} + ))} + + ) : recipe.instructions ? ( + + ) : ( +

    No instructions available

    + )} +
    +
    + )} + + ); + })} + + )} + + ); + }; export default SavedRecipes; \ No newline at end of file From 00febd66086a506fcfdb4d4860bc4a66702b54f2 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 6 Mar 2026 07:19:32 +0100 Subject: [PATCH 112/127] remove error in UI since button is disabled and user can never receive this error --- frontend/src/hooks/useRecipeActions.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/hooks/useRecipeActions.js b/frontend/src/hooks/useRecipeActions.js index 27695b382..4bd2ab4f2 100644 --- a/frontend/src/hooks/useRecipeActions.js +++ b/frontend/src/hooks/useRecipeActions.js @@ -21,16 +21,12 @@ export const useRecipeActions = () => { } = useRecipeStore(); const searchRecipes = async () => { - if (ingredients.length < 1) { - setError("Add at least 1 ingredient"); - return; - } setLoading(true); setError(null); setHasSearched(true); try { const data = await fetchRecipeByIngredients(ingredients, mode, filters); - setRecipes(data); + setRecipes(data); } catch (err) { setError(err.message); From 9f1d2ad6dc25ca2262741ed22cecf2214644db1f Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 6 Mar 2026 07:20:33 +0100 Subject: [PATCH 113/127] refactor: simplify input handling and update comments for clarity --- frontend/src/components/SearchRecipe.jsx | 30 ++++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index d3354e77e..fe393af8c 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -137,7 +137,7 @@ const ErrorMsg = styled.p` `; //states -const SearchRecipe = () => { + const SearchRecipe = () => { const [input, setInput] = useState(""); const { ingredients, @@ -155,19 +155,20 @@ const SearchRecipe = () => { } = useRecipeActions(); //handlers - const handleAdd = () => { - const trimmed = input.trim().toLowerCase(); - if (trimmed && !ingredients.includes(trimmed)) { - addIngredient(trimmed); - setInput(""); - } + const handleInputChange = (e) => { + setInput(e.target.value); }; const handleSubmit = (e) => { - e.preventDefault(); - handleAdd(); - }; + e.preventDefault(); + + const trimmed = input.trim().toLowerCase(); + if (trimmed && !ingredients.includes(trimmed)) { + addIngredient(trimmed); + setInput(""); + } +}; // Toggle diet and intolerance filters const toggleFilter = (key) => { @@ -181,7 +182,7 @@ const SearchRecipe = () => { type="text" placeholder="onion, garlic, chicken..." value={input} - onChange={(e) => setInput(e.target.value)} + onChange={handleInputChange} /> { Allow extra ingredients (recipe may contain more) - {/* Diet and intolerance filters */} + {/* Diet filters */} Diets { {loading && } {error && {error}} + + {/*if recipes are found, show recipe list*/} {recipes && recipes.length > 0 && } + + {/* if search has been performed, is not loading anymore, no error, and no recipes found, show message*/} {hasSearched && !loading && !error && recipes.length === 0 && ( -

    No recipes found. Try different ingredients!

    )} From 718396c294b3a007b8a57121437a18a28ea488a7 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 6 Mar 2026 07:24:37 +0100 Subject: [PATCH 114/127] fix: remove unused logout function from user store since move to navigation component --- frontend/src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9919efbbb..066aea104 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,7 +11,7 @@ import SavedRecipes from "./pages/SavedRecipes"; export const App = () => { - const { user, setUser, logout } = useUserStore(); + const { user, setUser } = useUserStore(); const [isSigningUp, setIsSigningUp] = useState(false); From 68c6ecb53d654d9e4ff0c82ef6a09a9c3cdcdb3c Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 6 Mar 2026 07:31:15 +0100 Subject: [PATCH 115/127] refactor: remove default value for mode in fetchRecipeByIngredients since api receives user mode from hook --- frontend/src/api/api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 48aa767a8..f6cf2ff12 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -12,7 +12,7 @@ async function handleResponse(response) { } // Fetch recipes by ingredients with filters from backend API -export async function fetchRecipeByIngredients(ingredients, mode = "allowExtra", filters = {}) { +export async function fetchRecipeByIngredients(ingredients, mode, filters = {}) { const params = new URLSearchParams({ ingredients: ingredients.join(","), mode, @@ -26,7 +26,7 @@ export async function fetchRecipeByIngredients(ingredients, mode = "allowExtra", return data.response; } -// Fetch recipe details by ID from Spoonacular API (public) +// Fetch recipe details by ID export async function fetchRecipeDetails(id) { const res = await fetch(`${API_URL}/recipes/details/${id}`); const data = await handleResponse(res); From 1d2b2921081d232ca293c38b6413ff64df1f1fb1 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 6 Mar 2026 07:32:32 +0100 Subject: [PATCH 116/127] refactor: improve code readability and validation in recipe search and details routes --- backend/routes/recipeRoutes.js | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js index 75be1aba3..ad9b25dd5 100644 --- a/backend/routes/recipeRoutes.js +++ b/backend/routes/recipeRoutes.js @@ -6,12 +6,11 @@ import authenticateUser from "../middleware/authMiddleware.js"; const router = express.Router(); - // (GET) Search recipes router.get("/search", async (req, res) => { const { ingredients, mode, dairyFree, glutenFree, vegetarian, vegan } = req.query; - - if (!ingredients) { + + if (!ingredients) { return res.status(400).json({ success: false, message: "Ingredients are required", @@ -21,10 +20,11 @@ router.get("/search", async (req, res) => { // split ingredients by comma and trim whitespace, also filter out empty strings const ingredientList = ingredients.split(",") - .map(i => - i.trim()) + .map(ingredient => + ingredient.trim()) .filter(Boolean); + // Validate that at least 1 ingredient is provided after processing if (ingredientList.length < 1) { return res.status(400).json({ success: false, @@ -32,6 +32,7 @@ router.get("/search", async (req, res) => { response: null, }); } + // filter and diet parameters const diet = [ vegetarian === "true" ? "vegetarian" : null, @@ -47,13 +48,13 @@ router.get("/search", async (req, res) => { .filter(Boolean) .join(","); + try { const params = { includeIngredients: ingredientList.join(","), number: 15, diet, intolerances, - sort: mode === "exact" ? "min-missing-ingredients" : "max-used-ingredients", addRecipeInformation: true, addRecipeInstructions: true, instructionsRequired: true, @@ -61,6 +62,10 @@ router.get("/search", async (req, res) => { apiKey: process.env.SPOONACULAR_API_KEY, }; + if (mode === "allowExtra") { + params.sort = "max-used-ingredients"; + } + const response = await axios.get( "https://api.spoonacular.com/recipes/complexSearch", { params } @@ -70,10 +75,9 @@ router.get("/search", async (req, res) => { ...recipe, usedIngredients: recipe.usedIngredients || [], missedIngredients: recipe.missedIngredients || [], - unusedIngredients: recipe.unusedIngredients || [], extendedIngredients: recipe.extendedIngredients || [], })); - + // Filter out recipes with missing ingredients if mode is 'exact' let filteredRecipes = recipes; if (mode === "exact") { @@ -100,17 +104,24 @@ router.get("/details/:id", async (req, res) => { const { id } = req.params; try { + if (!id) { + return res.status(400).json({ + success: false, + message: "Invalid recipe ID format", + response: null, + }); + } const response = await axios.get( `https://api.spoonacular.com/recipes/${id}/information`, { params: { apiKey: process.env.SPOONACULAR_API_KEY, - + } } ); - const details = response.data; + const details = response.data; res.status(200).json({ success: true, From f2c5f0317ac417a339180ac819732e08bee58480 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Fri, 6 Mar 2026 07:41:42 +0100 Subject: [PATCH 117/127] refactor: improve spacing and media query handling in --- backend/model/Recipe.js | 2 ++ frontend/src/components/SearchRecipe.jsx | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/model/Recipe.js b/backend/model/Recipe.js index e4b2c53b9..6d3f96179 100644 --- a/backend/model/Recipe.js +++ b/backend/model/Recipe.js @@ -13,6 +13,7 @@ const recipeSchema = new mongoose.Schema({ ref: "User", required: true, }, + spoonacularId: { type: Number, required: true, @@ -27,6 +28,7 @@ const recipeSchema = new mongoose.Schema({ type: Date, default: Date.now, }, + analyzedInstructions: [ { name: String, diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index fe393af8c..f301b059a 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -24,10 +24,11 @@ const Form = styled.form` align-items: stretch; margin-bottom: 2rem; - @media (min-width: 768px) { + @media ${media.tablet}, ${media.desktop} { flex-direction: row; gap: 1rem; align-items: center; + justify-content: center; } `; From 35adf35350df24e60ceec856211a87761336db31 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 8 Mar 2026 12:14:57 +0100 Subject: [PATCH 118/127] refactor: move EmptyState, ShareButton and LoadingSpinner components to ui folder, update styles and media queries --- frontend/src/components/RecipeCard.jsx | 2 +- frontend/src/components/SearchRecipe.jsx | 11 +++---- frontend/src/pages/SavedRecipes.jsx | 6 ++-- frontend/src/styles/GlobalStyles.js | 29 +++++++++++++++++++ frontend/src/ui/EmptyState.jsx | 31 ++++++++++++++++++++ frontend/src/ui/LoadingSpinner.jsx | 31 ++++++++++++++++++++ frontend/src/ui/ShareButton.jsx | 37 ++++++++++++++++++++++++ 7 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 frontend/src/ui/EmptyState.jsx create mode 100644 frontend/src/ui/LoadingSpinner.jsx create mode 100644 frontend/src/ui/ShareButton.jsx diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 43cc43baa..5743dcd02 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -3,7 +3,7 @@ import styled from "styled-components"; import { useUserStore } from "../stores/userStore"; import { useNavigate } from "react-router-dom"; import { fetchRecipeDetails, saveRecipe } from "../api/api"; -import ShareButton from "./ShareButton"; +import ShareButton from "../ui/ShareButton"; import { media } from "../styles/media"; const Card = styled.div` diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index f301b059a..b5e41f2d7 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import styled from "styled-components"; import { useRecipeActions } from "../hooks/useRecipeActions"; import RecipeList from "./RecipeList"; -import LoadingSpinner from "./LoadingSpinner"; +import LoadingSpinner from "../ui/LoadingSpinner"; import { media } from "../styles/media"; const Section = styled.section` @@ -41,10 +41,11 @@ const Input = styled.input` width: 100%; min-width: 0; - @media ${media.tablet} { - min-width: 350px; - width: auto; - } +@media ${media.tablet}, ${media.desktop} { + min-width: 300px; + max-width: 400px; + width: 100%; +} `; const AddButton = styled.button` diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index acd49503a..97a61d982 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -3,9 +3,9 @@ import styled from "styled-components"; import { useUserStore } from "../stores/userStore"; import { getSavedRecipes, deleteRecipe } from "../api/api"; import { media } from "../styles/media"; -import ShareButton from "../components/ShareButton"; -import LoadingSpinner from "../components/LoadingSpinner"; -import EmptyState from "../components/EmptyState"; +import ShareButton from "../ui/ShareButton"; +import LoadingSpinner from "../ui/LoadingSpinner"; +import EmptyState from "../ui/EmptyState"; const Container = styled.div` margin-top: 2rem; diff --git a/frontend/src/styles/GlobalStyles.js b/frontend/src/styles/GlobalStyles.js index 736af7988..e3d49ae27 100644 --- a/frontend/src/styles/GlobalStyles.js +++ b/frontend/src/styles/GlobalStyles.js @@ -1,6 +1,17 @@ import { createGlobalStyle } from "styled-components"; const GlobalStyles = createGlobalStyle` + :root { + --color-error: #990606; + --color-title: #222; + --color-text: #353535; + --color-green: #1D5334; + } + + *, *::before, *::after { + box-sizing: border-box; + } + body { background: linear-gradient(135deg, #f8fdf9 0%, #e8f5e9 100%); min-height: 100vh; @@ -8,6 +19,24 @@ const GlobalStyles = createGlobalStyle` font-family: 'Inter', Arial, Helvetica, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + color: var(--color-text); + } + + h1, h2, h3, h4, h5, h6 { + color: var(--color-title); + margin: 0 0 0.5em 0; + } + + p { + color: var(--color-text); + } + + .error { + color: var(--color-error); + } + + .green { + color: var(--color-green); } `; diff --git a/frontend/src/ui/EmptyState.jsx b/frontend/src/ui/EmptyState.jsx new file mode 100644 index 000000000..522eda4c2 --- /dev/null +++ b/frontend/src/ui/EmptyState.jsx @@ -0,0 +1,31 @@ +import styled from "styled-components"; + +const EmptyContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +const EmptyTitle = styled.h2` + font-size: 24px; + font-weight: bold; + margin-bottom: 10px; + color: #353535; +`; + +const EmptyText = styled.p` + font-size: 16px; + color: #353535; +`; + +const EmptyState = () => { + return ( + + No saved recipes yet + Start searching for recipes and save your favorites! + + ); +}; + +export default EmptyState; diff --git a/frontend/src/ui/LoadingSpinner.jsx b/frontend/src/ui/LoadingSpinner.jsx new file mode 100644 index 000000000..04dfa913b --- /dev/null +++ b/frontend/src/ui/LoadingSpinner.jsx @@ -0,0 +1,31 @@ +import styled from "styled-components"; +import { media } from "../styles/media"; + +const Spinner = styled.div` + border: 4px solid #f3f3f3; + border-top: 4px solid #2e8b57; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 2rem auto; + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + @media ${media.tablet} { + width: 50px; + height: 50px; + } + + @media ${media.desktop} { + width: 60px; + height: 60px; + } +`; + +const LoadingSpinner = () => ; + +export default LoadingSpinner; diff --git a/frontend/src/ui/ShareButton.jsx b/frontend/src/ui/ShareButton.jsx new file mode 100644 index 000000000..a5b18b7af --- /dev/null +++ b/frontend/src/ui/ShareButton.jsx @@ -0,0 +1,37 @@ +import styled from "styled-components"; +import { Share2 } from "lucide-react"; + +const Button = styled.button` + background: none; + border: none; + cursor: pointer; + margin-left: 0.5rem; + vertical-align: middle; +`; + +const ShareButton = ({ url, title }) => { + const handleShare = async () => { + try { + await navigator.share({ + title: title || "Check out this recipe!", + url, + }); + } catch (err) { + if (err.name !== "AbortError") { + console.error("Share failed:", err); + } + } + }; + + return ( + + ); +}; + +export default ShareButton; From 1bd2e341c2a389be30e64fc7cc8184f207536d32 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 8 Mar 2026 13:26:43 +0100 Subject: [PATCH 119/127] Add readme --- README.md | 20 ++++++++++++++------ backend/README.md | 38 +++++++++++++++++++++++++++++++++----- frontend/README.md | 36 +++++++++++++++++++++++++++++++----- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 31466b54c..1038b05fc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,21 @@ -# Final Project +# PantryMatch – Final Project -Replace this readme with your own information about your project. - -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +PantryMatch is a web app that helps users find recipes based on the ingredients they already have at home. The assignment was to build a fullstack project with user authentication, recipe search, and a modern UI. ## 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? +The challenge was choosing endpoint from Spoonacular adn deciding what parameters I want to use. In the beginning I struggled with making missing and matched ingredients visible. I tried to manually calculate at first, but after experimenting in postman I found the fillIngredients=true parameters that add information about the ingredients and whether they are used or missing in relation to the query. By using postman I solved the struggle and no unnecessary extra code for calculation was needed and also made my code more clean. So in the end I landed on the complex search endpoint + +Technologies used: +- Backend: Node.js, Express, MongoDB, Mongoose, JWT +- Frontend: React, Vite, styled-components, react-icons + +If I had more time, I would add: +- Autocomplete a partial input to suggest possible ingredient names. +- User profiles and recipe ratings +- Create an add to shopping list for ingredients ## View it live -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 +The project is deployed here: +[https://pantrymatch.netlify.app/](https://pantrymatch.onrender.com/) \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index d1438c910..902e382b4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,36 @@ -# Backend part of Final Project +# PantryMatch Backend -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. +This is the backend part of the PantryMatch project. +The server is built with Express and handles users, recipes, and authentication. +External API - Spoonacular -## Getting Started +## Structure -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +- `server.js` – Main file for the Express server +- `routes/` – API routes for users and recipes +- `model/` – Mongoose models for User and Recipe +- `middleware/` – Middleware for authentication + +## API Endpoints + +### Users +- `POST /api/users/signup` – Create a new user +- `POST /api/users/login` – Log in a user + +### Recipes +- `GET /api/recipes` – Get all recipes +- `POST /api/recipes` – Create a new recipe +- `GET /api/recipes/:id` – Get recipe by id + +## Environment Variables + +Using a `.env` file with : +``` +MONGO_URL +Spoonacular API key +``` + +## Development + +- Nodemon is used for automatic restarts +- All code is in ES6 modules \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 5cdb1d9cf..ccb546715 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,34 @@ -# Frontend part of Final Project +# PantryMatch Frontend -This boilerplate is designed to give you a head start in your React projects, with a focus on understanding the structure and components. As a student of Technigo, you'll find this guide helpful in navigating and utilizing the repository. +This is the frontend part of the PantryMatch project. +The app is built with React and Vite, and provides a modern interface for searching, saving, and sharing recipes based on your pantry ingredients. -## Getting Started -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +## Structure + +- `src/` – Main source folder + - `components/` – UI components (recipe cards, search, forms, etc.) + - `ui/` – Shared UI elements (spinner, empty state, share button) + - `pages/` – Page views (home, about, member, saved recipes) + - `hooks/` – Custom React hooks + - `stores/` – State management (recipeStore, userStore) + - `styles/` – Global and media styles + - `api/` – API calls + +## Features + +- Search for recipes based on ingredients you have +- Save favorite recipes +- Share recipes +- Filter by diet and intolerances +- Responsive design for desktop and mobile + +## Environment Variables + +Using a `.env` file with my API key from spoonacular: + + +## Development + +- Built with React, Vite, and styled-components +- Modern, clean UI From 9e6a35e17c75b1cef59b7a376816c0a100881eba Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 8 Mar 2026 13:38:04 +0100 Subject: [PATCH 120/127] remove old files --- frontend/src/components/EmptyState.jsx | 32 ------------------- frontend/src/components/LoadingSpinner.jsx | 31 ------------------ frontend/src/components/ShareButton.jsx | 37 ---------------------- 3 files changed, 100 deletions(-) delete mode 100644 frontend/src/components/EmptyState.jsx delete mode 100644 frontend/src/components/LoadingSpinner.jsx delete mode 100644 frontend/src/components/ShareButton.jsx diff --git a/frontend/src/components/EmptyState.jsx b/frontend/src/components/EmptyState.jsx deleted file mode 100644 index 8e1ea3df6..000000000 --- a/frontend/src/components/EmptyState.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import styled from "styled-components"; - -const EmptyContainer = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -`; - -const EmptyTitle = styled.h2` - font-size: 24px; - font-weight: bold; - margin-bottom: 10px; - color: #353535; -`; - -const EmptyText = styled.p` - font-size: 16px; - color: #353535; -`; - - -const EmptyState = () => { - return ( - - No saved recipes yet - Start searching for recipes and save your favorites! - - ); -}; - -export default EmptyState; \ No newline at end of file diff --git a/frontend/src/components/LoadingSpinner.jsx b/frontend/src/components/LoadingSpinner.jsx deleted file mode 100644 index 04dfa913b..000000000 --- a/frontend/src/components/LoadingSpinner.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import styled from "styled-components"; -import { media } from "../styles/media"; - -const Spinner = styled.div` - border: 4px solid #f3f3f3; - border-top: 4px solid #2e8b57; - border-radius: 50%; - width: 40px; - height: 40px; - animation: spin 1s linear infinite; - margin: 2rem auto; - - @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - - @media ${media.tablet} { - width: 50px; - height: 50px; - } - - @media ${media.desktop} { - width: 60px; - height: 60px; - } -`; - -const LoadingSpinner = () => ; - -export default LoadingSpinner; diff --git a/frontend/src/components/ShareButton.jsx b/frontend/src/components/ShareButton.jsx deleted file mode 100644 index a5b18b7af..000000000 --- a/frontend/src/components/ShareButton.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import styled from "styled-components"; -import { Share2 } from "lucide-react"; - -const Button = styled.button` - background: none; - border: none; - cursor: pointer; - margin-left: 0.5rem; - vertical-align: middle; -`; - -const ShareButton = ({ url, title }) => { - const handleShare = async () => { - try { - await navigator.share({ - title: title || "Check out this recipe!", - url, - }); - } catch (err) { - if (err.name !== "AbortError") { - console.error("Share failed:", err); - } - } - }; - - return ( - - ); -}; - -export default ShareButton; From f9c4fa1aec8d6806a3793e10e875d7ca39ea7c93 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 8 Mar 2026 14:51:10 +0100 Subject: [PATCH 121/127] Add react icons and update readme --- frontend/README.md | 20 ++++++++++++++++++++ frontend/src/components/RecipeCard.jsx | 14 ++++++-------- frontend/src/pages/SavedRecipes.jsx | 6 ++++-- frontend/src/ui/ShareButton.jsx | 4 ++-- package.json | 3 ++- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index ccb546715..dcafca65e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -32,3 +32,23 @@ Using a `.env` file with my API key from spoonacular: - Built with React, Vite, and styled-components - Modern, clean UI + +## Inspiration + +### Functionality +https://www.supercook.com/#/desktop +https://www.myfridgefood.com/ +https://realfood.tesco.com/what-can-i-make-with.html + +### Icons +https://react-icons.github.io/react-icons/ + +### loading spinner: +https://www.blog.northernbadger.co.uk/articles/how-to-create-a-simple-css-loading-spinner-make-it-accessible/ + +### accessibility check: +https://accessibleweb.com/color-contrast-checker/ +Lighthouse extension +accessible web extension + +### Technigo learning material diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index 5743dcd02..c25239d68 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -5,6 +5,8 @@ import { useNavigate } from "react-router-dom"; import { fetchRecipeDetails, saveRecipe } from "../api/api"; import ShareButton from "../ui/ShareButton"; import { media } from "../styles/media"; +import { FcCancel, FcOk } from "react-icons/fc"; +import { IoHeartSharp, IoHeartOutline } from "react-icons/io5"; const Card = styled.div` background: #fff; @@ -93,8 +95,6 @@ const ToggleBtn = styled.button` `; const SaveBtn = styled.button` - background: #FFEACC; - color: #1D5334; padding: 0.5rem 1rem; border-radius: 4px; border: 1px solid #1D5334; @@ -106,8 +106,6 @@ const SaveBtn = styled.button` `; const SavedBtn = styled(SaveBtn)` - background: #e8f5e9; - color: #1D5334; border: 1px solid #1D5334; cursor: default; @@ -293,11 +291,11 @@ const RecipeCard = ({ recipe }) => { {recipe.title} - ✅ Matched: {matchedIngredients} + Matched: {matchedIngredients} - ❌ Missing: {missingIngredients} + Missing: {missingIngredients} @@ -310,14 +308,14 @@ const RecipeCard = ({ recipe }) => { disabled aria-label={`${recipe.title} is already saved in your collection`} > - Saved + ) : ( - Save Recipe + )} diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index 97a61d982..026d1e725 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -6,6 +6,7 @@ import { media } from "../styles/media"; import ShareButton from "../ui/ShareButton"; import LoadingSpinner from "../ui/LoadingSpinner"; import EmptyState from "../ui/EmptyState"; +import { MdDelete } from "react-icons/md"; const Container = styled.div` margin-top: 2rem; @@ -295,8 +296,9 @@ const SavedRecipes = () => { handleDelete(recipe._id)}> - Delete + onClick={() => handleDelete(recipe._id)} + aria-label={`Delete ${recipe.title} from your saved recipes`}> + diff --git a/frontend/src/ui/ShareButton.jsx b/frontend/src/ui/ShareButton.jsx index a5b18b7af..a8dcd5f7b 100644 --- a/frontend/src/ui/ShareButton.jsx +++ b/frontend/src/ui/ShareButton.jsx @@ -1,5 +1,5 @@ import styled from "styled-components"; -import { Share2 } from "lucide-react"; +import { IoShareOutline } from "react-icons/io5"; const Button = styled.button` background: none; @@ -29,7 +29,7 @@ const ShareButton = ({ url, title }) => { title="Share recipe" aria-label="Share recipe" > - + ); }; diff --git a/package.json b/package.json index a0f62cae1..8adb10216 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ }, "dependencies": { "axios": "^1.13.5", - "framer-motion": "^12.34.0" + "framer-motion": "^12.34.0", + "react-icons": "^5.6.0" } } From 78895304bdd18e0adcb7058d9f070f4bf17cdc96 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 8 Mar 2026 20:59:50 +0100 Subject: [PATCH 122/127] refactor: update styles to use CSS variables for consistency across components --- frontend/src/components/LoginForm.jsx | 19 ++++++-------- frontend/src/components/Navigation.jsx | 6 ++--- frontend/src/components/RecipeCard.jsx | 34 +++++++++++++------------- frontend/src/components/RecipeList.jsx | 2 +- frontend/src/components/SignupForm.jsx | 14 +++++------ frontend/src/pages/SavedRecipes.jsx | 32 ++++++++++++------------ frontend/src/pages/about.jsx | 18 +++++++------- frontend/src/pages/member.jsx | 2 +- frontend/src/styles/GlobalStyles.js | 8 +++++- frontend/src/ui/EmptyState.jsx | 4 +-- frontend/src/ui/LoadingSpinner.jsx | 4 +-- 11 files changed, 72 insertions(+), 71 deletions(-) diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx index b77569e77..78a28a740 100644 --- a/frontend/src/components/LoginForm.jsx +++ b/frontend/src/components/LoginForm.jsx @@ -4,9 +4,8 @@ import { API_URL } from "../api/api"; import styled from "styled-components"; import { media } from "../styles/media"; -// Styled Components const StyledForm = styled.form` - background: #fff; + background: var(--color-bg); padding: 1.2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); @@ -23,8 +22,6 @@ const StyledForm = styled.form` } `; - - const StyledHeading = styled.h2` text-align: center; margin-bottom: 1.5rem; @@ -46,7 +43,7 @@ const StyledInput = styled.input` padding: 0.5rem; margin-top: 0.3rem; border-radius: 4px; - border: 1px solid #ccc; + border: 1px solid var(--color-border); font-size: 1rem; width: 100%; @@ -58,11 +55,9 @@ const StyledInput = styled.input` } `; - - const StyledButton = styled.button` - background: #256b45; - color: #fff; + background: var(--color-button); + color: var(--color-bg); border: none; padding: 0.7rem 1.2rem; border-radius: 4px; @@ -71,7 +66,7 @@ const StyledButton = styled.button` font-size: 1rem; transition: background 0.2s; &:hover { - background: #1D5334; + background: var(--color-green); } `; @@ -83,14 +78,14 @@ const AuthActions = styled.div` `; const ToggleAuth = styled.span` - color: #1D5334; + color: var(--color-green); cursor: pointer; font-size: 0.95rem; text-decoration: underline; `; const ErrorMessage = styled.p` - color: #c0392b; + color: var(--color-error); text-align: center; margin-top: 1rem; `; diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index 87f2c43f7..c5ef482ac 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -7,7 +7,7 @@ const NavContainer = styled.nav` display: flex; justify-content: center; align-items: center; - background: #ffffff; + background: var(--color-bg); padding: 0.7rem 0; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); @@ -50,7 +50,7 @@ const NavItem = styled.li` const NavLink = styled(Link)` background: none; border: none; - color: #333; + color: var(--color-text); font-weight: 600; cursor: pointer; font-size: 1rem; @@ -73,7 +73,7 @@ const NavLink = styled(Link)` const LogoutBtn = styled.button` background: none; - color: #ab0303; + color: var(--color-error); border: none; cursor: pointer; font-size: 1rem; diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx index c25239d68..5af13fb5a 100644 --- a/frontend/src/components/RecipeCard.jsx +++ b/frontend/src/components/RecipeCard.jsx @@ -9,9 +9,9 @@ import { FcCancel, FcOk } from "react-icons/fc"; import { IoHeartSharp, IoHeartOutline } from "react-icons/io5"; const Card = styled.div` - background: #fff; + background: var(--color-bg); padding: 1.5rem 1rem; - border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--color-border); `; const CardTop = styled.div` @@ -44,12 +44,12 @@ const CardContent = styled.div` const Title = styled.h3` margin: 0.5rem 0; - color: #222; + color: var(--color-title); `; const Info = styled.p` font-size: 0.9rem; - color: #353535; + color: var(--color-text); margin: 0.3rem 0; `; @@ -63,11 +63,11 @@ const IngredientInfo = styled.p` `; const Matched = styled.span` - color: #1D5334; + color: var(--color-green); `; const Missing = styled.span` - color: #990606; + color: var(--color-error); `; const BtnRow = styled.div` @@ -79,8 +79,8 @@ const BtnRow = styled.div` const ToggleBtn = styled.button` background: none; - border: 1px solid #1D5334; - color: #1D5334; + border: 1px solid var(--color-green); + color: var(--color-green); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; @@ -89,7 +89,7 @@ const ToggleBtn = styled.button` transition: all 0.2s; &:hover { - background: #1D5334; + background: var(--color-green); color: white; } `; @@ -97,7 +97,7 @@ const ToggleBtn = styled.button` const SaveBtn = styled.button` padding: 0.5rem 1rem; border-radius: 4px; - border: 1px solid #1D5334; + border: 1px solid var(--color-green); cursor: pointer; margin-top: 0.5rem; font-size: 0.95rem; @@ -106,7 +106,7 @@ const SaveBtn = styled.button` `; const SavedBtn = styled(SaveBtn)` - border: 1px solid #1D5334; + border: 1px solid var(--color-green); cursor: default; &:disabled { @@ -117,7 +117,7 @@ const SavedBtn = styled(SaveBtn)` const Details = styled.div` margin-top: 1rem; padding-top: 1rem; - border-top: 1px solid #eee; + border-top: 1px solid var(--color-border); ul { padding-left: 1.5rem; @@ -129,20 +129,20 @@ const Details = styled.div` `; const ErrorMsg = styled.p` - color: #990606; + color: var(--color-error); font-weight: 500; margin: 1rem 0; `; const DetailTitle = styled.h4` margin: 0; - color: #222; + color: var(--color-title); font-weight: bold; font-size: 1.1rem; `; const IngredientContainer = styled.div` - background: #e8f5e9; + background: var(--color-tag); padding: 0.5rem 1rem; border-radius: 6px; margin-top: 1rem; @@ -171,8 +171,8 @@ const InstructionStep = styled.li` width: 1.7rem; height: 1.7rem; margin-right: 0.7rem; - background: #22633E; - color: #fff; + background: var(--color-button); + color: var(--color-bg); border-radius: 50%; font-weight: bold; font-size: 1rem; diff --git a/frontend/src/components/RecipeList.jsx b/frontend/src/components/RecipeList.jsx index d40fe1fb8..9de28ad10 100644 --- a/frontend/src/components/RecipeList.jsx +++ b/frontend/src/components/RecipeList.jsx @@ -7,7 +7,7 @@ const Container = styled.div` const Title = styled.h2` margin-bottom: 1rem; - color: #222; + color: var(--color-title); `; const RecipeGrid = styled.div` diff --git a/frontend/src/components/SignupForm.jsx b/frontend/src/components/SignupForm.jsx index 2dbd5097b..df7f58145 100644 --- a/frontend/src/components/SignupForm.jsx +++ b/frontend/src/components/SignupForm.jsx @@ -6,7 +6,7 @@ import { media } from "../styles/media"; const StyledForm = styled.form` - background: #fff; + background: var(--color-bg); padding: 1.2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); @@ -44,7 +44,7 @@ const StyledInput = styled.input` padding: 0.5rem; margin-top: 0.3rem; border-radius: 4px; - border: 1px solid #ccc; + border: 1px solid var(--color-border); font-size: 1rem; width: 100%; @@ -57,8 +57,8 @@ const StyledInput = styled.input` `; const StyledButton = styled.button` - background: #256b45; - color: #fff; + background: var(--color-button); + color: var(--color-bg); border: none; padding: 0.7rem 1.2rem; border-radius: 4px; @@ -67,7 +67,7 @@ const StyledButton = styled.button` font-size: 1rem; transition: background 0.2s; &:hover { - background: #1D5334; + background: var(--color-green); } `; @@ -79,14 +79,14 @@ const AuthActions = styled.div` `; const ToggleAuth = styled.span` - color: #1D5334; + color: var(--color-green); cursor: pointer; font-size: 0.95rem; text-decoration: underline; `; const ErrorMessage = styled.p` - color: #c0392b; + color: var(--color-error); text-align: center; margin-top: 1rem; `; diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx index 026d1e725..6fb3679ef 100644 --- a/frontend/src/pages/SavedRecipes.jsx +++ b/frontend/src/pages/SavedRecipes.jsx @@ -16,7 +16,7 @@ const Title = styled.h1` text-align: center; font-size: 2.5rem; margin-bottom: 1rem; - color: #2e8b57; + color: var(--color-green-light); `; const RecipeGrid = styled.div` @@ -28,7 +28,7 @@ const RecipeGrid = styled.div` `; const Card = styled.div` - background: #fff; + background: var(--color-bg); padding: 1.5rem 1rem; border-bottom: 1px solid #e0e0e0; `; @@ -63,12 +63,12 @@ const CardContent = styled.div` const RecipeTitle = styled.h2` margin: 0.5rem 0; - color: #222; + color: var(--color-title); `; const Info = styled.p` font-size: 0.9rem; - color: #353535; + color: var(--color-text); margin: 0.3rem 0; `; @@ -81,8 +81,8 @@ const ButtonRow = styled.div` const ToggleBtn = styled.button` background: none; - border: 1px solid #22633E; - color: #22633E; + border: 1px solid var(--color-button); + color: var(--color-button); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; @@ -91,15 +91,15 @@ const ToggleBtn = styled.button` transition: all 0.2s; &:hover { - background: #22633E; - color: white; + background: var(--color-button); + color: var(--color-bg); } `; const DeleteBtn = styled.button` - background: #ab0303; + background: var(--color-error); border: none; - color: white; + color: var(--color-bg); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; @@ -108,7 +108,7 @@ const DeleteBtn = styled.button` `; const ErrorMsg = styled.p` - color: #ab0303; + color: var(--color-error); font-weight: 500; margin: 1rem 0; text-align: center; @@ -117,7 +117,7 @@ const ErrorMsg = styled.p` const Details = styled.div` margin-top: 1rem; padding-top: 1rem; - border-top: 1px solid #eee; + border-top: 1px solid var(--color-border); ul { padding-left: 1.5rem; @@ -130,11 +130,11 @@ const Details = styled.div` const DetailTitle = styled.h4` margin-top: 1rem; - color: #222; + color: var(--color-title); `; const IngredientContainer = styled.div` - background: #e8f5e9; + background: var(--color-tag); padding: 0.5rem 1rem; border-radius: 6px; margin-top: 1rem; @@ -162,8 +162,8 @@ const InstructionStep = styled.li` width: 1.7rem; height: 1.7rem; margin-right: 0.7rem; - background: #22633E; - color: #fff; + background: var(--color-button); + color: var(--color-bg); border-radius: 50%; font-weight: bold; font-size: 1rem; diff --git a/frontend/src/pages/about.jsx b/frontend/src/pages/about.jsx index 5daf04764..3522fe492 100644 --- a/frontend/src/pages/about.jsx +++ b/frontend/src/pages/about.jsx @@ -4,7 +4,7 @@ const Container = styled.div` max-width: 900px; margin: 3rem auto; padding: 2rem; - background: #ffffff; + background: var(--color-bg); border-radius: 1.5rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); `; @@ -13,13 +13,13 @@ const Title = styled.h1` text-align: center; font-size: 2.5rem; margin-bottom: 1rem; - color: #2e8b57; + color: var(--color-green-light); `; const Subtitle = styled.p` text-align: center; font-size: 1.1rem; - color: #666; + color: var(--color-text); margin-bottom: 2.5rem; `; @@ -30,12 +30,12 @@ const Section = styled.section` const SectionTitle = styled.h2` font-size: 1.6rem; margin-bottom: 0.8rem; - color: #333; + color: var(--color-title); `; const Text = styled.p` line-height: 1.7; - color: #444; + color: var(--color-text); font-size: 1rem; `; @@ -48,11 +48,11 @@ const FeatureList = styled.ul` const FeatureItem = styled.li` margin-bottom: 0.6rem; font-size: 1rem; - color: #444; + color: var(--color-text); &::before { content: "✔ "; - color: #2e8b57; + color: var(--color-green-light); font-weight: bold; } `; @@ -61,8 +61,8 @@ const Footer = styled.div` text-align: center; margin-top: 3rem; padding-top: 1.5rem; - border-top: 1px solid #eee; - color: #232222; + border-top: 1px solid var(--color-border); + color: var(--color-text); font-size: 0.95rem; `; diff --git a/frontend/src/pages/member.jsx b/frontend/src/pages/member.jsx index 294e062b9..c4b88d638 100644 --- a/frontend/src/pages/member.jsx +++ b/frontend/src/pages/member.jsx @@ -11,7 +11,7 @@ const Intro = styled.div` margin-bottom: 0.5rem; } p { - color: #555; + color: var(--color-label); font-size: 1.1rem; } `; diff --git a/frontend/src/styles/GlobalStyles.js b/frontend/src/styles/GlobalStyles.js index e3d49ae27..315232b92 100644 --- a/frontend/src/styles/GlobalStyles.js +++ b/frontend/src/styles/GlobalStyles.js @@ -6,6 +6,12 @@ const GlobalStyles = createGlobalStyle` --color-title: #222; --color-text: #353535; --color-green: #1D5334; + --color-green-light: #2e8b57; + --color-border: #e6e6e6; + --color-tag: #e8f5e9; + --color-label: #555; + --color-bg: #ffffff; + --color-button: #22633E; } *, *::before, *::after { @@ -13,7 +19,7 @@ const GlobalStyles = createGlobalStyle` } body { - background: linear-gradient(135deg, #f8fdf9 0%, #e8f5e9 100%); + background: linear-gradient(135deg, #f8fdf9 0%, var(--color-tag) 100%); min-height: 100vh; margin: 0; font-family: 'Inter', Arial, Helvetica, sans-serif; diff --git a/frontend/src/ui/EmptyState.jsx b/frontend/src/ui/EmptyState.jsx index 522eda4c2..c3165cb4c 100644 --- a/frontend/src/ui/EmptyState.jsx +++ b/frontend/src/ui/EmptyState.jsx @@ -11,12 +11,12 @@ const EmptyTitle = styled.h2` font-size: 24px; font-weight: bold; margin-bottom: 10px; - color: #353535; + color: var(--color-title); `; const EmptyText = styled.p` font-size: 16px; - color: #353535; + color: var(--color-text); `; const EmptyState = () => { diff --git a/frontend/src/ui/LoadingSpinner.jsx b/frontend/src/ui/LoadingSpinner.jsx index 04dfa913b..270ea10e5 100644 --- a/frontend/src/ui/LoadingSpinner.jsx +++ b/frontend/src/ui/LoadingSpinner.jsx @@ -2,8 +2,8 @@ import styled from "styled-components"; import { media } from "../styles/media"; const Spinner = styled.div` - border: 4px solid #f3f3f3; - border-top: 4px solid #2e8b57; + border: 4px solid var(--color-border); + border-top: 4px solid var(--color-green-light); border-radius: 50%; width: 40px; height: 40px; From 4546e0d159f0129ec7b9cad5eaffe83b5c398888 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 8 Mar 2026 21:05:51 +0100 Subject: [PATCH 123/127] refactor: improved styling and structure for more UI friendly look. --- frontend/src/components/Hero.jsx | 220 +++++++------ frontend/src/components/SearchRecipe.jsx | 396 ++++++++++++++++------- frontend/src/pages/home.jsx | 82 +++-- 3 files changed, 448 insertions(+), 250 deletions(-) diff --git a/frontend/src/components/Hero.jsx b/frontend/src/components/Hero.jsx index 8c25747d7..dffd9d601 100644 --- a/frontend/src/components/Hero.jsx +++ b/frontend/src/components/Hero.jsx @@ -1,146 +1,174 @@ import styled, { keyframes } from "styled-components"; import { media } from "../styles/media"; +import { useState, useEffect } from "react"; -// Animations -const fadeInUp = keyframes` - from { - opacity: 0; - transform: translateY(30px); - } - to { - opacity: 1; - transform: translateY(0); - } +const fadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } `; -const float = keyframes` - 0%, 100% { - transform: translateY(0px); - } - 50% { - transform: translateY(-10px); - } +const cursorBlink = keyframes` + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } `; -// Styled Components const HeroSection = styled.section` - min-height: 50vh; + min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 2.5rem 0.7rem 2rem; + padding: 4rem 2rem; + position: relative; - overflow: hidden; - @media ${media.tablet} { - padding: 3.5rem 1.5rem 2.5rem; - } - @media ${media.desktop} { - padding: 5rem 2rem 4rem; + @media (max-width: 768px) { + padding: 3rem 1.5rem; + min-height: 50vh; } +`; - &::before { - content: ""; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: radial-gradient( - circle, - rgba(46, 139, 87, 0.03) 1px, - transparent 1px - ); - background-size: 50px 50px; - animation: ${float} 20s ease-in-out infinite; - } +const Container = styled.div` + text-align: center; + max-width: 900px; + animation: ${fadeIn} 0.8s ease-out; `; -const LogoContainer = styled.div` +const Logo = styled.div` width: 100px; height: 100px; - margin-bottom: 1.5rem; - animation: ${fadeInUp} 0.8s ease-out; - filter: drop-shadow(0 4px 12px rgba(46, 139, 87, 0.15)); + margin: 0 auto 2rem; + background: var(--color-bg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.3s ease; + + &:hover { + transform: scale(1.1); + } img { - width: 100%; - height: 100%; + width: 60%; + height: 60%; object-fit: contain; } `; -const TitleWrapper = styled.div` - text-align: center; - position: relative; - z-index: 1; +const MainTitle = styled.h1` + font-size: clamp(2.5rem, 6vw, 5rem); + font-weight: 300; + margin: 0 0 1rem 0; + color: var(--color-title); + line-height: 1.2; `; -const MainTitle = styled.h1` - font-size: clamp(3rem, 8vw, 5.5rem); - font-weight: 800; - margin: 0; - background: linear-gradient( - 135deg, - #2e8b57 0%, - #3ba569 25%, - #48c07a 50%, - #3ba569 75%, - #2e8b57 100% - ); - background-size: 300% 300%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - letter-spacing: -0.02em; - line-height: 1.1; +const BoldText = styled.span` + font-weight: 900; + color: var(--color-green-light); +`; - @media (max-width: 768px) { - letter-spacing: -0.01em; +const TypingText = styled.span` + position: relative; + color: var(--color-green-light); + font-weight: 900; + + &::after { + content: "|"; + position: absolute; + right: -0.5ch; + animation: ${cursorBlink} 1s step-end infinite; } `; const Subtitle = styled.p` - font-size: clamp(1rem, 2.5vw, 1.3rem); - color: #555; - margin: 1.5rem 0 0; - font-weight: 400; - max-width: 600px; + font-size: 1.25rem; + color: var(--color-text); + margin: 2rem 0; + font-weight: 300; line-height: 1.6; +`; + +const Divider = styled.div` + width: 80px; + height: 2px; + background: var(--color-green-light); + margin: 3rem auto; +`; - span { - color: #2e8b57; - font-weight: 600; +const QuickStats = styled.div` + display: flex; + justify-content: center; + gap: 4rem; + margin-top: 3rem; + + @media (max-width: 600px) { + gap: 2rem; } `; -const AccentLine = styled.div` - width: 100px; - height: 4px; - background: linear-gradient(90deg, transparent, #2e8b57, transparent); - margin: 2rem auto 0; - border-radius: 2px; - animation: ${fadeInUp} 0.8s ease-out 0.6s both; +const StatItem = styled.div` + text-align: center; `; +const StatNumber = styled.div` + font-size: 2rem; + font-weight: 900; + color: var(--color-green-light); + margin-bottom: 0.25rem; +`; +const StatLabel = styled.div` + font-size: 0.85rem; + color: var(--color-label); + text-transform: uppercase; + letter-spacing: 1px; +`; -// Component const Hero = () => { + const words = ["pasta", "salads", "desserts", "soups"]; + const [currentWord, setCurrentWord] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setCurrentWord((prev) => (prev + 1) % words.length); + }, 2000); + return () => clearInterval(interval); + }, []); + return ( - - PantryMatch Logo - - - - PantryMatch + + + PantryMatch Logo + + + Find perfect {words[currentWord]} +
    + with PantryMatch +
    - Turn your ingredients into delicious recipes + Your ingredients. Our recipes. Zero waste. - -
    + + + + 300K+ + Recipes + + + 15 + Cuisines + + + 100% + Free + + + + +
    ); }; diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx index b5e41f2d7..af90a0ac8 100644 --- a/frontend/src/components/SearchRecipe.jsx +++ b/frontend/src/components/SearchRecipe.jsx @@ -16,7 +16,6 @@ const Section = styled.section` } `; - const Form = styled.form` display: flex; flex-direction: column; @@ -32,25 +31,33 @@ const Form = styled.form` } `; +const InputWrapper = styled.div` + flex: 1; + position: relative; +`; + const Input = styled.input` - box-sizing: border-box; - padding: 0.5rem 1rem; - border-radius: 4px; - border: 1px solid #ccc; - font-size: 1rem; width: 100%; - min-width: 0; + padding: 1rem 1.25rem; + border-radius: 12px; + border: 2px solid var(--color-tag); + font-size: 1rem; + transition: all 0.3s ease; -@media ${media.tablet}, ${media.desktop} { - min-width: 300px; - max-width: 400px; - width: 100%; -} + &:focus { + outline: none; + border-color: var(--color-green-light); + box-shadow: 0 0 0 4px rgba(46, 139, 87, 0.1); + } + + &::placeholder { + color: var(--color-label); + } `; const AddButton = styled.button` - background: #22633E; - color: #fff; + background: var(--color-button); + color: var(--color-bg); border: none; padding: 0.5rem 1.2rem; border-radius: 4px; @@ -59,6 +66,14 @@ const AddButton = styled.button` width: 90px; transition: background 0.2s; + &:hover { + background: var(--color-green); + } + + &:focus-visible { + outline: 3px solid var(--color-green-light); + outline-offset: 2px; + } @media ${media.tablet}, ${media.desktop} { width: auto; @@ -71,51 +86,153 @@ const IngredientsInfo = styled.div` font-size: 0.95rem; `; -const IngredientTag = styled.span` +const IngredientsLabel = styled.div` + font-weight: 600; + color: var(--color-label); + margin-bottom: 0.75rem; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const IngredientTag = styled.button` display: inline-block; - background: #e8f5e9; - color: #1D5334; + background: var(--color-tag); + color: var(--color-green); padding: 0.3rem 0.6rem; border-radius: 12px; margin: 0.2rem; font-size: 0.9rem; cursor: pointer; + border: none; &:hover { background: #c8e6c9; } + + &:focus-visible { + outline: 3px solid var(--color-green-light); + outline-offset: 2px; + } `; const FilterSection = styled.div` margin-bottom: 2rem; - background: #fff; - border-radius: 8px; - padding: 1.2rem 1rem; `; -const FilterTitle = styled.div` - font-weight: 600; - font-size: 1.1rem; - color: #222; +const FilterCard = styled.fieldset` + background: white; + border-radius: 16px; + padding: 1.5rem; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); margin-bottom: 1rem; + border: none; `; -const FilterLabel = styled.label` +const FilterTitle = styled.legend` + font-weight: 700; + font-size: 1rem; + color: var(--color-title); + margin: 0 0 1.25rem 0; + padding: 0; +`; + +const ModeGrid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + + @media (max-width: 600px) { + grid-template-columns: 1fr; + } +`; + +const ModeOption = styled.label` + cursor: pointer; + padding: 1.25rem; + border: 2px solid ${(props) => (props.checked ? "var(--color-green)" : "var(--color-tag)")}; + border-radius: 12px; + background: ${(props) => (props.checked ? "var(--color-tag)" : "white")}; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + gap: 0.5rem; + + &:hover { + border-color: var(--color-green-light); + } + + &:focus-within { + outline: 3px solid var(--color-green-light); + outline-offset: 2px; + } +`; + +const ModeRow = styled.div` display: flex; align-items: center; + gap: 0.75rem; +`; + +const NativeRadio = styled.input` + width: 22px; + height: 22px; + margin: 0; + accent-color: var(--color-green); + cursor: pointer; +`; + +const ModeTitle = styled.div` + font-weight: 600; font-size: 1rem; - margin-bottom: 0.5rem; + color: ${(props) => (props.checked ? "var(--color-green)" : "var(--color-title)")}; +`; + +const ModeDescription = styled.div` + font-size: 0.85rem; + color: var(--color-title); + line-height: 1.4; + margin-left: 2rem; +`; + +const CheckboxGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; +`; + +const CheckboxLabel = styled.label` + display: flex; + align-items: center; + gap: 0.75rem; cursor: pointer; + padding: 0.75rem 1rem; + border-radius: 8px; + background: ${(props) => (props.checked ? "var(--color-tag)" : "transparent")} + transition: 0.2s ease; + + &:focus-within { + outline: 3px solid var(--color-green-light); + outline-offset: 2px; + } `; -const Radio = styled.input` - margin-right: 0.5rem; +const NativeCheckbox = styled.input` + width: 22px; + height: 22px; + margin: 0; + accent-color: var(--color-green); cursor: pointer; `; +const CheckboxText = styled.span` + font-size: 0.95rem; + color: ${(props) => (props.checked ? "var(--color-green)" : "var(--color-title)")}; +`; + const ShowButton = styled.button` - background: #22633E; - color: #fff; + background: var(--color-button); + color: var(--color-bg); border: none; padding: 0.7rem 2rem; border-radius: 4px; @@ -125,22 +242,30 @@ const ShowButton = styled.button` box-shadow: 0 4px 16px rgba(46, 139, 87, 0.15); transition: background 0.2s; + &:hover:not(:disabled) { + background: var(--color-green); + } + + &:focus-visible { + outline: 3px solid var(--color-green-light); + outline-offset: 2px; + } &:disabled { - background: #ccc; + background: var(--color-border); cursor: not-allowed; } `; const ErrorMsg = styled.p` - color: #d32f2f; + color: var(--color-error); font-weight: 500; margin: 1rem 0; `; -//states - const SearchRecipe = () => { +const SearchRecipe = () => { const [input, setInput] = useState(""); + const { ingredients, recipes, @@ -156,48 +281,55 @@ const ErrorMsg = styled.p` searchRecipes, } = useRecipeActions(); - //handlers const handleInputChange = (e) => { setInput(e.target.value); }; const handleSubmit = (e) => { - e.preventDefault(); + e.preventDefault(); + const trimmed = input.trim().toLowerCase(); - const trimmed = input.trim().toLowerCase(); - - if (trimmed && !ingredients.includes(trimmed)) { - addIngredient(trimmed); - setInput(""); - } -}; + if (trimmed && !ingredients.includes(trimmed)) { + addIngredient(trimmed); + setInput(""); + } + }; - // Toggle diet and intolerance filters const toggleFilter = (key) => { - setFilters({ ...filters, [key]: !filters[key] }); + setFilters((prev) => ({ + ...prev, + [key]: !prev[key], + })); }; return (
    - - - Add - + + + + + Add
    {ingredients.length > 0 && ( - Ingredients:{" "} + Ingredients ({ingredients.length}) + {ingredients.map((ing) => ( - removeIngredient(ing)}> + removeIngredient(ing)} + aria-label={`Remove ingredient ${ing}`} + > {ing} × ))} @@ -205,81 +337,95 @@ const ErrorMsg = styled.p` )} - Filter - - setMode("exact")} - /> - Use only these ingredients (no extras allowed) - - - setMode("allowExtra")} - /> - Allow extra ingredients (recipe may contain more) - - - {/* Diet filters */} - Diets - - toggleFilter("vegetarian")} - /> - Vegetarian - - - toggleFilter("vegan")} - /> - Vegan - - - {/* Intolerance filters */} - Intolerances - - toggleFilter("dairyFree")} - /> - Dairy Free - - - toggleFilter("glutenFree")} - /> - Gluten Free - + + Search Mode + + + + + setMode("exact")} + /> + Exact Match + + Use only these ingredients + + + + + setMode("allowExtra")} + /> + Allow Extras + + Recipe may contain more + + + + + + Diet Preferences + + + + toggleFilter("vegetarian")} + /> + Vegetarian + + + + toggleFilter("vegan")} + /> + Vegan + + + + toggleFilter("dairyFree")} + /> + Dairy Free + + + + toggleFilter("glutenFree")} + /> + Gluten Free + + + - + {loading ? "Searching..." : "Show recipes"} {loading && } {error && {error}} - - {/*if recipes are found, show recipe list*/} {recipes && recipes.length > 0 && } - - {/* if search has been performed, is not loading anymore, no error, and no recipes found, show message*/} + {hasSearched && !loading && !error && recipes.length === 0 && (

    No recipes found. Try different ingredients!

    )} diff --git a/frontend/src/pages/home.jsx b/frontend/src/pages/home.jsx index 368561d89..b9cdf22cf 100644 --- a/frontend/src/pages/home.jsx +++ b/frontend/src/pages/home.jsx @@ -1,54 +1,78 @@ import SearchRecipe from "../components/SearchRecipe"; import styled from "styled-components"; +import { media } from "../styles/media"; import Hero from "../components/Hero"; const Container = styled.div` - max-width: 900px; + max-width: 1200px; margin: 3rem auto; - padding: 2rem; - background: #ffffff; - border-radius: 1.5rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + padding: 0 2rem; + display: flex; + flex-direction: column; + gap: 2rem; - @media (max-width: 600px) { - padding: 1rem 0.5rem; - max-width: 98vw; + @media ${media.tablet} { + padding: 0 1rem; + gap: 2rem; } - @media (min-width: 601px) and (max-width: 1024px) { - padding: 1.5rem 1rem; - max-width: 98vw; + @media ${media.mobile} { + padding: 0 0.5rem; + gap: 1rem; + } +`; + +const MainContent = styled.div` + background: var(--color-bg); + padding: 3rem; + border-radius: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + + @media ${media.tablet} { + padding: 2rem 1.5rem; + border-radius: 1rem; } - @media (min-width: 1205px) { - max-width: 1100px; - padding: 2.5rem 3rem; + @media ${media.mobile} { + padding: 1rem 0.5rem; + border-radius: 0.5rem; } `; const Title = styled.h1` - text-align: center; - font-size: 2.5rem; - margin-bottom: 1rem; + font-size: 3.5rem; + margin-bottom: 0.5rem; color: #1A6A1A; + font-weight: 900; + line-height: 1.1; + text-align: center; + + @media ${media.tablet} { + font-size: 2.5rem; + } + @media ${media.mobile} { + font-size: 1.7rem; + } `; const Subtitle = styled.p` + font-size: 1.2rem; + color: var(--color-text); + margin-bottom: 3rem; text-align: center; - font-size: 1.1rem; - color: #353535; - margin-bottom: 2.5rem; - `; + const Home = () => { return ( <> - - - What to cook? - Discover recipes based on what you have at home - - - + + + + What to cook? + Find the perfect recipe using ingredients you already have. + + + + ); }; -export default Home; +export default Home; \ No newline at end of file From 72ee6b89307b432eeb421c68e4f72c907918803f Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 8 Mar 2026 21:06:21 +0100 Subject: [PATCH 124/127] update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1038b05fc..5d59f4dd3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ If I had more time, I would add: - Autocomplete a partial input to suggest possible ingredient names. - User profiles and recipe ratings - Create an add to shopping list for ingredients +- Pagination or 'more recipes' button ## View it live From a60c62451c98e32cb0f41fd2796080230d12c7c6 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Sun, 8 Mar 2026 21:12:52 +0100 Subject: [PATCH 125/127] update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5d59f4dd3..44afdf539 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ If I had more time, I would add: - User profiles and recipe ratings - Create an add to shopping list for ingredients - Pagination or 'more recipes' button +- Have the save button stay as saved when re-render so when user searcher for recipes again, they can see if they have already saved the recipe. At the moment there's only an error. ## View it live From ccdce08e81fe1475880fb2b6a1a4ebacdd216847 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Thu, 19 Mar 2026 17:10:53 +0100 Subject: [PATCH 126/127] refactor: adjust spacing and font sizes for improved responsiveness for small devices --- frontend/src/components/Hero.jsx | 13 ++++++++++--- frontend/src/styles/GlobalStyles.js | 5 +++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Hero.jsx b/frontend/src/components/Hero.jsx index dffd9d601..f62a22576 100644 --- a/frontend/src/components/Hero.jsx +++ b/frontend/src/components/Hero.jsx @@ -100,12 +100,15 @@ const Divider = styled.div` const QuickStats = styled.div` display: flex; justify-content: center; - gap: 4rem; + gap: 1rem; margin-top: 3rem; - @media (max-width: 600px) { + @media ${media.tablet} { gap: 2rem; } + @media ${media.desktop} { + gap: 4rem; + } `; const StatItem = styled.div` @@ -113,10 +116,14 @@ const StatItem = styled.div` `; const StatNumber = styled.div` - font-size: 2rem; + font-size: 1.5rem; font-weight: 900; color: var(--color-green-light); margin-bottom: 0.25rem; + + @media ${media.tablet} { + font-size: 2rem; + } `; const StatLabel = styled.div` diff --git a/frontend/src/styles/GlobalStyles.js b/frontend/src/styles/GlobalStyles.js index 315232b92..871ca4864 100644 --- a/frontend/src/styles/GlobalStyles.js +++ b/frontend/src/styles/GlobalStyles.js @@ -14,6 +14,11 @@ const GlobalStyles = createGlobalStyle` --color-button: #22633E; } + html, body { + max-width: 100vw; + overflow-x: hidden; + } + *, *::before, *::after { box-sizing: border-box; } From 1fe4e8edd5ed7a37dfa7cf1021721fc715beaef7 Mon Sep 17 00:00:00 2001 From: Jennifer Jansson Date: Thu, 19 Mar 2026 17:37:32 +0100 Subject: [PATCH 127/127] refactor: add focus to email input on load with useRef hook for Login and Signup forms --- frontend/src/components/LoginForm.jsx | 10 +++++++++- frontend/src/components/SignupForm.jsx | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx index 78a28a740..0e0efe5b8 100644 --- a/frontend/src/components/LoginForm.jsx +++ b/frontend/src/components/LoginForm.jsx @@ -1,5 +1,5 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { API_URL } from "../api/api"; import styled from "styled-components"; import { media } from "../styles/media"; @@ -99,6 +99,13 @@ export const LoginForm = ({ handleLogin, onToggleAuth }) => { const [error, setError] = useState(""); + const emailInputRef = useRef(null); + useEffect(() => { + if (emailInputRef.current) { + emailInputRef.current.focus(); + } + }, []); + const handleSubmit = async (e) => { e.preventDefault(); @@ -150,6 +157,7 @@ export const LoginForm = ({ handleLogin, onToggleAuth }) => { Email { password: "", }); + const emailInputRef = useRef(null); + useEffect(() => { + if (emailInputRef.current) { + emailInputRef.current.focus(); + } + }, []); + const handleSubmit = async (e) => { e.preventDefault(); @@ -152,6 +159,7 @@ export const SignupForm = ({ handleLogin, onToggleAuth }) => { Email