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/README.md b/README.md index 31466b54c..44afdf539 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,23 @@ -# 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 +- 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 -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/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js new file mode 100644 index 000000000..9a918a614 --- /dev/null +++ b/backend/middleware/authMiddleware.js @@ -0,0 +1,23 @@ +import User from "../model/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 diff --git a/backend/model/Recipe.js b/backend/model/Recipe.js new file mode 100644 index 000000000..6d3f96179 --- /dev/null +++ b/backend/model/Recipe.js @@ -0,0 +1,47 @@ +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, + 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, + }, + + analyzedInstructions: [ + { + name: String, + steps: [ + { + number: Number, + step: String + } + ] + } + ], +}); + +const Recipe = mongoose.model("Recipe", recipeSchema); + +export default Recipe; \ No newline at end of file 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 diff --git a/backend/package.json b/backend/package.json index 08f29f244..0932fd6b3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,15 +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", + "axios": "^1.13.5", + "bcrypt": "^6.0.0", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "dotenv": "^17.2.3", + "express": "^4.22.1", + "express-list-endpoints": "^7.1.1", + "mongodb": "^7.0.0", + "mongoose": "^9.1.5", + "nodemon": "^3.1.11" } -} \ No newline at end of file +} diff --git a/backend/routes/recipeRoutes.js b/backend/routes/recipeRoutes.js new file mode 100644 index 000000000..ad9b25dd5 --- /dev/null +++ b/backend/routes/recipeRoutes.js @@ -0,0 +1,280 @@ +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(); + +// (GET) Search recipes +router.get("/search", async (req, res) => { + const { ingredients, mode, dairyFree, glutenFree, vegetarian, vegan } = req.query; + + if (!ingredients) { + return res.status(400).json({ + success: false, + message: "Ingredients are required", + response: null, + }); + } + // split ingredients by comma and trim whitespace, also filter out empty strings + const ingredientList = + ingredients.split(",") + .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, + message: "Please provide at least 1 ingredient", + response: null, + }); + } + + // filter and diet parameters + const diet = [ + vegetarian === "true" ? "vegetarian" : null, + vegan === "true" ? "vegan" : null + ] + .filter(Boolean) + .join(","); + + const intolerances = [ + dairyFree === "true" ? "dairy" : null, + glutenFree === "true" ? "gluten" : null + ] + .filter(Boolean) + .join(","); + + + try { + const params = { + includeIngredients: ingredientList.join(","), + number: 15, + diet, + intolerances, + addRecipeInformation: true, + addRecipeInstructions: true, + instructionsRequired: true, + fillIngredients: true, + 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 } + ); + + const recipes = response.data.results.map(recipe => ({ + ...recipe, + usedIngredients: recipe.usedIngredients || [], + missedIngredients: recipe.missedIngredients || [], + extendedIngredients: recipe.extendedIngredients || [], + })); + + // Filter out recipes with missing ingredients if mode is 'exact' + let filteredRecipes = recipes; + if (mode === "exact") { + filteredRecipes = recipes.filter(recipe => recipe.missedIngredients.length === 0); + } + + res.status(200).json({ + success: true, + message: "Recipes fetched from Spoonacular complexSearch", + response: filteredRecipes, + }); + + } catch (error) { + res.status(500).json({ + success: false, + message: "Failed to fetch recipes from Spoonacular", + response: error.response?.data || error.message, + }); + } +}); + +// (GET) Get recipe details +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; + + res.status(200).json({ + success: true, + response: { + id: details.id, + title: details.title, + instructions: details.instructions, + analyzedInstructions: details.analyzedInstructions, + extendedIngredients: details.extendedIngredients, + readyInMinutes: details.readyInMinutes, + servings: details.servings, + sourceUrl: details.sourceUrl, + } + }); + + } catch (error) { + 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 { + 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) Get single saved recipe 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, + }); + } + + 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", + response: recipe, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "Failed to fetch recipe", + response: error.message || error, + }); + } +}); + +// (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, + }); + + 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, + }); + } +}); + +// (DELETE) Delete a saved recipe +router.delete("/:id", authenticateUser, async (req, res) => { + try { + const deleted = await Recipe.findOneAndDelete({ + _id: req.params.id, + userId: req.user._id, // make sure user can only delete their own saved recipes + }); + + if (!deleted) { + return res.status(404).json({ + success: false, + message: "Recipe not found or you don't have permission to delete it", + response: null, + }); + } + + res.status(200).json({ + success: true, + message: "Recipe deleted successfully", + response: deleted, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "Failed to delete recipe", + response: error.message, + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 000000000..ccf59e086 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,90 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import User from "../model/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 diff --git a/backend/server.js b/backend/server.js index 070c87518..8c5451f79 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,8 +1,13 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import listEndpoints from "express-list-endpoints"; +import userRoutes from "./routes/userRoutes.js"; +import recipeRoutes from "./routes/recipeRoutes.js"; +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; @@ -12,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}`); diff --git a/frontend/README.md b/frontend/README.md index 5cdb1d9cf..dcafca65e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,54 @@ -# 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 + +## 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/index.html b/frontend/index.html index 664410b5b..e202ff3b4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,14 @@ - + - + - Technigo React Vite Boiler Plate + + PantryMatch
diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..5bbddae2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.13.5", + "lucide-react": "^0.575.0", "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", 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 diff --git a/frontend/public/recipe3.png b/frontend/public/recipe3.png new file mode 100644 index 000000000..4ee13d8f5 Binary files /dev/null and b/frontend/public/recipe3.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..066aea104 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,66 @@ +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"; +import GlobalStyles from "./styles/GlobalStyles"; +import SavedRecipes from "./pages/SavedRecipes"; + + export const App = () => { + const { user, setUser } = useUserStore(); + const [isSigningUp, setIsSigningUp] = useState(false); + + return ( - <> -

Welcome to Final Project!

- + + + + +
+ + + } + /> + + ) : ( + + ) + } + /> + + {/* Protected/Auth route */} + + : + } + /> + + + } + /> + + + +
+
); }; diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js new file mode 100644 index 000000000..f6cf2ff12 --- /dev/null +++ b/frontend/src/api/api.js @@ -0,0 +1,70 @@ + +export const API_URL = 'https://pantrymatch.onrender.com'; +//export const API_URL = 'http://localhost:8080'; + +// 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 with filters from backend API +export async function fetchRecipeByIngredients(ingredients, mode, filters = {}) { + const params = new URLSearchParams({ + ingredients: ingredients.join(","), + mode, + vegetarian: filters.vegetarian ? "true" : "false", + vegan: filters.vegan ? "true" : "false", + dairyFree: filters.dairyFree ? "true" : "false", + glutenFree: filters.glutenFree ? "true" : "false", + }); + const res = await fetch(`${API_URL}/recipes/search?${params.toString()}`); + const data = await handleResponse(res); + return data.response; +} + +// Fetch recipe details by ID +export async function fetchRecipeDetails(id) { + const res = await fetch(`${API_URL}/recipes/details/${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_URL}/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_URL}/recipes`, { + 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_URL}/recipes/${recipeId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + }, + }); + return handleResponse(res); +} \ No newline at end of file diff --git a/frontend/src/components/Hero.jsx b/frontend/src/components/Hero.jsx new file mode 100644 index 000000000..f62a22576 --- /dev/null +++ b/frontend/src/components/Hero.jsx @@ -0,0 +1,183 @@ +import styled, { keyframes } from "styled-components"; +import { media } from "../styles/media"; +import { useState, useEffect } from "react"; + +const fadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } +`; + +const cursorBlink = keyframes` + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +`; + +const HeroSection = styled.section` + min-height: 60vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + + position: relative; + + @media (max-width: 768px) { + padding: 3rem 1.5rem; + min-height: 50vh; + } +`; + +const Container = styled.div` + text-align: center; + max-width: 900px; + animation: ${fadeIn} 0.8s ease-out; +`; + +const Logo = styled.div` + width: 100px; + height: 100px; + 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: 60%; + height: 60%; + object-fit: contain; + } +`; + +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 BoldText = styled.span` + font-weight: 900; + color: var(--color-green-light); +`; + +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: 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; +`; + +const QuickStats = styled.div` + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 3rem; + + @media ${media.tablet} { + gap: 2rem; + } + @media ${media.desktop} { + gap: 4rem; + } +`; + +const StatItem = styled.div` + text-align: center; +`; + +const StatNumber = styled.div` + 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` + font-size: 0.85rem; + color: var(--color-label); + text-transform: uppercase; + letter-spacing: 1px; +`; + +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 + + + Find perfect {words[currentWord]} +
+ with PantryMatch +
+ + Your ingredients. Our recipes. Zero waste. + + + + + 300K+ + Recipes + + + 15 + Cuisines + + + 100% + Free + + +
+ + +
+ ); +}; + +export default Hero; \ No newline at end of file diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 000000000..0e0efe5b8 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,186 @@ + +import { useState, useRef, useEffect } from "react"; +import { API_URL } from "../api/api"; +import styled from "styled-components"; +import { media } from "../styles/media"; + +const StyledForm = styled.form` + background: var(--color-bg); + padding: 1.2rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + 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; +`; + +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 var(--color-border); + font-size: 1rem; + width: 100%; + + @media ${media.tablet} { + font-size: 1.05rem; + } + @media ${media.desktop} { + font-size: 1.1rem; + } +`; + +const StyledButton = styled.button` + background: var(--color-button); + color: var(--color-bg); + border: none; + padding: 0.7rem 1.2rem; + border-radius: 4px; + cursor: pointer; + margin-top: 1rem; + font-size: 1rem; + transition: background 0.2s; + &:hover { + background: var(--color-green); + } +`; + +const AuthActions = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 0.7rem; +`; + +const ToggleAuth = styled.span` + color: var(--color-green); + cursor: pointer; + font-size: 0.95rem; + text-decoration: underline; +`; + +const ErrorMessage = styled.p` + color: var(--color-error); + text-align: center; + margin-top: 1rem; +`; + + +export const LoginForm = ({ handleLogin, onToggleAuth }) => { + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + const [error, setError] = useState(""); + + const emailInputRef = useRef(null); + useEffect(() => { + if (emailInputRef.current) { + emailInputRef.current.focus(); + } + }, []); + + 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 diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx new file mode 100644 index 000000000..c5ef482ac --- /dev/null +++ b/frontend/src/components/Navigation.jsx @@ -0,0 +1,127 @@ +import styled from "styled-components"; +import { media } from "../styles/media"; +import { Link } from "react-router-dom"; +import { useUserStore } from "../stores/userStore"; + +const NavContainer = styled.nav` + display: flex; + justify-content: center; + align-items: center; + background: var(--color-bg); + 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: 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` + font-size: 1.1rem; + display: flex; + align-items: center; +`; + +const NavLink = styled(Link)` + background: none; + border: none; + color: var(--color-text); + font-weight: 600; + cursor: pointer; + font-size: 1rem; + text-decoration: none; + transition: color 0.2s; + padding: 0.2rem 0.3rem; + &:hover { + 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; + color: var(--color-error); + border: none; + 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} { + font-size: 1.08rem; + padding: 0.3rem 0.5rem; + } + @media ${media.desktop} { + font-size: 1.1rem; + padding: 0.4rem 0.7rem; + } +`; + +const Navigation = () => { + const { user, logout } = useUserStore(); + return ( + + + + Home + + + About + + {user && ( + + Saved Recipes + + )} + + {user ? ( + Logout + ) : ( + Login + )} + + + + ); +}; + +export default Navigation; diff --git a/frontend/src/components/RecipeCard.jsx b/frontend/src/components/RecipeCard.jsx new file mode 100644 index 000000000..5af13fb5a --- /dev/null +++ b/frontend/src/components/RecipeCard.jsx @@ -0,0 +1,366 @@ +import { useState } from "react"; +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 "../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: var(--color-bg); + padding: 1.5rem 1rem; + border-bottom: 1px solid var(--color-border); +`; + +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; + + @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` + margin: 0.5rem 0; + color: var(--color-title); +`; + +const Info = styled.p` + font-size: 0.9rem; + color: var(--color-text); + 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: var(--color-green); +`; + +const Missing = styled.span` + color: var(--color-error); +`; + +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 var(--color-green); + color: var(--color-green); + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; + font-size: 0.95rem; + transition: all 0.2s; + + &:hover { + background: var(--color-green); + color: white; + } +`; + +const SaveBtn = styled.button` + padding: 0.5rem 1rem; + border-radius: 4px; + border: 1px solid var(--color-green); + cursor: pointer; + margin-top: 0.5rem; + font-size: 0.95rem; + transition: all 0.2s; + +`; + +const SavedBtn = styled(SaveBtn)` + border: 1px solid var(--color-green); + cursor: default; + + &:disabled { + cursor: not-allowed; + } +`; + +const Details = styled.div` + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); + + ul { + padding-left: 1.5rem; + } + + li { + margin: 0.3rem 0; + } +`; + +const ErrorMsg = styled.p` + color: var(--color-error); + font-weight: 500; + margin: 1rem 0; +`; + +const DetailTitle = styled.h4` + margin: 0; + color: var(--color-title); + font-weight: bold; + font-size: 1.1rem; +`; + +const IngredientContainer = styled.div` + background: var(--color-tag); + 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: var(--color-button); + color: var(--color-bg); + 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 }) => { + 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); + setError(""); + + try { + const data = await fetchRecipeDetails(recipe.id); + setDetails(data); + } catch (err) { + setError("Failed to fetch recipe details"); + } finally { + setLoadingDetails(false); + } + } + setIsOpen(!isOpen); + }; + + const handleSave = async () => { + if (!user) { + navigate("/member"); + return; + } + + 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 + ); + + 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); + } + } + }; + + // 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.id}`; + + return ( + + + {recipe.title} + + + {recipe.title} + + + Matched: {matchedIngredients} + + + + Missing: {missingIngredients} + + + + + {loadingDetails ? "Loading..." : isOpen ? "Show less" : "Show more"} + + + {saved ? ( + + + + ) : ( + + + + )} + + + {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 ? ( + + ) : ( +

No instructions available

+ )} +
+
+ )} +
+ ); + }; +export default RecipeCard; \ No newline at end of file diff --git a/frontend/src/components/RecipeList.jsx b/frontend/src/components/RecipeList.jsx new file mode 100644 index 000000000..9de28ad10 --- /dev/null +++ b/frontend/src/components/RecipeList.jsx @@ -0,0 +1,37 @@ +import RecipeCard from "./RecipeCard"; +import styled from "styled-components"; + +const Container = styled.div` + margin-top: 2rem; +`; + +const Title = styled.h2` + margin-bottom: 1rem; + color: var(--color-title); +`; + +const RecipeGrid = styled.div` + display: flex; + flex-direction: column; + gap: 0; + margin: 2rem auto; + max-width: 900px; +`; + + +const RecipeList = ({ recipes }) => { + if (!recipes || recipes.length === 0) return null; + + return ( + + Found {recipes.length} recipe{recipes.length !== 1 ? "s" : ""} + + {recipes.map((recipe) => ( + + ))} + + + ); +}; + +export default RecipeList; \ No newline at end of file diff --git a/frontend/src/components/SearchRecipe.jsx b/frontend/src/components/SearchRecipe.jsx new file mode 100644 index 000000000..af90a0ac8 --- /dev/null +++ b/frontend/src/components/SearchRecipe.jsx @@ -0,0 +1,436 @@ +import { useState } from "react"; +import styled from "styled-components"; +import { useRecipeActions } from "../hooks/useRecipeActions"; +import RecipeList from "./RecipeList"; +import LoadingSpinner from "../ui/LoadingSpinner"; +import { media } from "../styles/media"; + +const Section = styled.section` + max-width: 98vw; + margin: 0 auto; + padding: 0 0.5rem; + + @media ${media.tablet} { + max-width: 700px; + padding: 0; + } +`; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: 0.7rem; + align-items: stretch; + margin-bottom: 2rem; + + @media ${media.tablet}, ${media.desktop} { + flex-direction: row; + gap: 1rem; + align-items: center; + justify-content: center; + } +`; + +const InputWrapper = styled.div` + flex: 1; + position: relative; +`; + +const Input = styled.input` + width: 100%; + padding: 1rem 1.25rem; + border-radius: 12px; + border: 2px solid var(--color-tag); + font-size: 1rem; + transition: all 0.3s ease; + + &: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: var(--color-button); + color: var(--color-bg); + border: none; + padding: 0.5rem 1.2rem; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + 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; + margin-left: 0.5rem; + } +`; + +const IngredientsInfo = styled.div` + margin-bottom: 1rem; + font-size: 0.95rem; +`; + +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: 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; +`; + +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 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; + 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 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: var(--color-button); + color: var(--color-bg); + 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:not(:disabled) { + background: var(--color-green); + } + + &:focus-visible { + outline: 3px solid var(--color-green-light); + outline-offset: 2px; + } + + &:disabled { + background: var(--color-border); + cursor: not-allowed; + } +`; + +const ErrorMsg = styled.p` + color: var(--color-error); + font-weight: 500; + margin: 1rem 0; +`; + +const SearchRecipe = () => { + const [input, setInput] = useState(""); + + const { + ingredients, + recipes, + mode, + loading, + error, + hasSearched, + filters, + setFilters, + setMode, + addIngredient, + removeIngredient, + searchRecipes, + } = useRecipeActions(); + + const handleInputChange = (e) => { + setInput(e.target.value); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const trimmed = input.trim().toLowerCase(); + + if (trimmed && !ingredients.includes(trimmed)) { + addIngredient(trimmed); + setInput(""); + } + }; + + const toggleFilter = (key) => { + setFilters((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + + return ( +
+
+ + + + + Add +
+ + {ingredients.length > 0 && ( + + Ingredients ({ingredients.length}) + + {ingredients.map((ing) => ( + removeIngredient(ing)} + aria-label={`Remove ingredient ${ing}`} + > + {ing} × + + ))} + + )} + + + + 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}} + {recipes && recipes.length > 0 && } + + {hasSearched && !loading && !error && recipes.length === 0 && ( +

No recipes found. Try different ingredients!

+ )} +
+ ); +}; + +export default SearchRecipe; \ No newline at end of file diff --git a/frontend/src/components/SignupForm.jsx b/frontend/src/components/SignupForm.jsx new file mode 100644 index 000000000..7b42f9251 --- /dev/null +++ b/frontend/src/components/SignupForm.jsx @@ -0,0 +1,188 @@ + +import { useState, useRef, useEffect } from "react"; +import { API_URL } from "../api/api"; +import styled from "styled-components"; +import { media } from "../styles/media"; + + +const StyledForm = styled.form` + background: var(--color-bg); + padding: 1.2rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + 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; +`; + +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 var(--color-border); + font-size: 1rem; + width: 100%; + + @media ${media.tablet} { + font-size: 1.05rem; + } + @media ${media.desktop} { + font-size: 1.1rem; + } +`; + +const StyledButton = styled.button` + background: var(--color-button); + color: var(--color-bg); + border: none; + padding: 0.7rem 1.2rem; + border-radius: 4px; + cursor: pointer; + margin-top: 1rem; + font-size: 1rem; + transition: background 0.2s; + &:hover { + background: var(--color-green); + } +`; + +const AuthActions = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 0.7rem; +`; + +const ToggleAuth = styled.span` + color: var(--color-green); + cursor: pointer; + font-size: 0.95rem; + text-decoration: underline; +`; + +const ErrorMessage = styled.p` + color: var(--color-error); + text-align: center; + margin-top: 1rem; +`; + +export const SignupForm = ({ handleLogin, onToggleAuth }) => { + const [error, setError] = useState(""); + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + const emailInputRef = useRef(null); + useEffect(() => { + if (emailInputRef.current) { + emailInputRef.current.focus(); + } + }, []); + + 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 diff --git a/frontend/src/hooks/useRecipeActions.js b/frontend/src/hooks/useRecipeActions.js new file mode 100644 index 000000000..4bd2ab4f2 --- /dev/null +++ b/frontend/src/hooks/useRecipeActions.js @@ -0,0 +1,53 @@ +import { useRecipeStore } from "../stores/recipeStore"; +import { fetchRecipeByIngredients } from "../api/api"; + +export const useRecipeActions = () => { + const { + ingredients, + recipes, + mode, + filters, + loading, + error, + setRecipes, + setLoading, + setError, + setMode, + setFilters, + hasSearched, + setHasSearched, + addIngredient, + removeIngredient, + } = useRecipeStore(); + + const searchRecipes = async () => { + setLoading(true); + setError(null); + setHasSearched(true); + try { + const data = await fetchRecipeByIngredients(ingredients, mode, filters); + setRecipes(data); + + } catch (err) { + setError(err.message); + setRecipes([]); + } finally { + setLoading(false); + } + }; + + return { + ingredients, + recipes, + mode, + filters, + loading, + error, + hasSearched, + setFilters, + setMode, + addIngredient, + removeIngredient, + searchRecipes, + }; +}; \ No newline at end of file diff --git a/frontend/src/pages/SavedRecipes.jsx b/frontend/src/pages/SavedRecipes.jsx new file mode 100644 index 000000000..6fb3679ef --- /dev/null +++ b/frontend/src/pages/SavedRecipes.jsx @@ -0,0 +1,346 @@ +import { useEffect, useState } from "react"; +import styled from "styled-components"; +import { useUserStore } from "../stores/userStore"; +import { getSavedRecipes, deleteRecipe } from "../api/api"; +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; +`; + +const Title = styled.h1` + text-align: center; + font-size: 2.5rem; + margin-bottom: 1rem; + color: var(--color-green-light); +`; + +const RecipeGrid = styled.div` + display: flex; + flex-direction: column; + gap: 0; + margin: 2rem auto; + max-width: 900px; +`; + +const Card = styled.div` + background: var(--color-bg); + 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; + + @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.h2` + margin: 0.5rem 0; + color: var(--color-title); +`; + +const Info = styled.p` + font-size: 0.9rem; + color: var(--color-text); + margin: 0.3rem 0; +`; + +const ButtonRow = styled.div` + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin-top: 1rem; +`; + +const ToggleBtn = styled.button` + background: none; + border: 1px solid var(--color-button); + color: var(--color-button); + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; + font-size: 0.95rem; + transition: all 0.2s; + + &:hover { + background: var(--color-button); + color: var(--color-bg); + } +`; + +const DeleteBtn = styled.button` + background: var(--color-error); + border: none; + color: var(--color-bg); + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; + +`; + +const ErrorMsg = styled.p` + color: var(--color-error); + font-weight: 500; + margin: 1rem 0; + text-align: center; +`; + +const Details = styled.div` + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); + + ul { + padding-left: 1.5rem; + } + + li { + margin: 0.3rem 0; + } +`; + +const DetailTitle = styled.h4` + margin-top: 1rem; + color: var(--color-title); +`; + +const IngredientContainer = styled.div` + background: var(--color-tag); + 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: var(--color-button); + color: var(--color-bg); + 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(() => { + if (user) { + fetchSavedRecipes(); + } + }, [user]); + + //handlers + const fetchSavedRecipes = async () => { + try { + setLoading(true); + setError(null); + + const data = await getSavedRecipes(user.accessToken); + 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)); + setDeleteError(null); + } catch (err) { + setDeleteError(err.message); + } + }; + + const toggleDetails = (id) => { + setExpandedId(expandedId === id ? null : id); + }; + + //loading state + if (loading) { + return ( + + Saved Recipes + + + ); + } + //error state + if (error) { + return ( + + Saved Recipes + Error: {error} + + ); + } + + // Main render + return ( + + Saved Recipes + {deleteError && {deleteError}} + + {/* 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)} + aria-label={`Delete ${recipe.title} from your saved recipes`}> + + + + + + + + {isExpanded && ( +
+ + + + Ingredients: +
    + {recipe.extendedIngredients?.map((ing, index) => ( +
  • {ing.original || ing.name}
  • + ))} +
+
+ + + 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 diff --git a/frontend/src/pages/about.jsx b/frontend/src/pages/about.jsx new file mode 100644 index 000000000..3522fe492 --- /dev/null +++ b/frontend/src/pages/about.jsx @@ -0,0 +1,121 @@ +import styled from "styled-components"; + +const Container = styled.div` + max-width: 900px; + margin: 3rem auto; + padding: 2rem; + background: var(--color-bg); + 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: var(--color-green-light); +`; + +const Subtitle = styled.p` + text-align: center; + font-size: 1.1rem; + color: var(--color-text); + 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: var(--color-title); +`; + +const Text = styled.p` + line-height: 1.7; + color: var(--color-text); + 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: var(--color-text); + + &::before { + content: "✔ "; + color: var(--color-green-light); + font-weight: bold; + } +`; + +const Footer = styled.div` + text-align: center; + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); + color: var(--color-text); + 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 filters, 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, diets and intolerances + 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. + +
+ + +
+ ); +}; + +export default About; diff --git a/frontend/src/pages/home.jsx b/frontend/src/pages/home.jsx new file mode 100644 index 000000000..b9cdf22cf --- /dev/null +++ b/frontend/src/pages/home.jsx @@ -0,0 +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: 1200px; + margin: 3rem auto; + padding: 0 2rem; + display: flex; + flex-direction: column; + gap: 2rem; + + @media ${media.tablet} { + padding: 0 1rem; + gap: 2rem; + } + @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 ${media.mobile} { + padding: 1rem 0.5rem; + border-radius: 0.5rem; + } +`; + +const Title = styled.h1` + 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; +`; + +const Home = () => { + return ( + <> + + + + What to cook? + Find the perfect recipe using ingredients you already have. + + + + + ); +}; + +export default Home; \ No newline at end of file diff --git a/frontend/src/pages/member.jsx b/frontend/src/pages/member.jsx new file mode 100644 index 000000000..c4b88d638 --- /dev/null +++ b/frontend/src/pages/member.jsx @@ -0,0 +1,42 @@ +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: var(--color-label); + font-size: 1.1rem; + } +`; + +const AuthContainer = styled.div` + margin-top: 2rem; +`; + +const Member = ({ isSigningUp, setIsSigningUp, handleLogin }) => { + return ( + <> + +

What to cook?

+

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

+

And save your favorite recipes

+
+ + {isSigningUp ? ( + setIsSigningUp(false)} /> + ) : ( + setIsSigningUp(true)} /> + )} + + + ); +}; + +export default Member; diff --git a/frontend/src/stores/recipeStore.js b/frontend/src/stores/recipeStore.js new file mode 100644 index 000000000..211239ca2 --- /dev/null +++ b/frontend/src/stores/recipeStore.js @@ -0,0 +1,34 @@ +import { create } from "zustand"; + +export const useRecipeStore = create((set) => ({ + ingredients: [], + recipes: [], + mode: "allowExtra", + loading: false, + hasSearched: false, + error: null, + filters: { + vegetarian: false, + vegan: false, + dairyFree: false, + glutenFree: false, + }, + + setMode: (mode) => set({ mode }), + setFilters: (filters) => set({ filters }), + + 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 }), + setHasSearched: (hasSearched) => set({ hasSearched }), + setError: (error) => set({ error }), +})); \ No newline at end of file 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 }); + }, + +})); diff --git a/frontend/src/styles/GlobalStyles.js b/frontend/src/styles/GlobalStyles.js new file mode 100644 index 000000000..871ca4864 --- /dev/null +++ b/frontend/src/styles/GlobalStyles.js @@ -0,0 +1,54 @@ +import { createGlobalStyle } from "styled-components"; + +const GlobalStyles = createGlobalStyle` + :root { + --color-error: #990606; + --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; + } + + html, body { + max-width: 100vw; + overflow-x: hidden; + } + + *, *::before, *::after { + box-sizing: border-box; + } + + body { + background: linear-gradient(135deg, #f8fdf9 0%, var(--color-tag) 100%); + min-height: 100vh; + margin: 0; + 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); + } +`; + +export default GlobalStyles; \ No newline at end of file diff --git a/frontend/src/styles/media.js b/frontend/src/styles/media.js new file mode 100644 index 000000000..654cf15ba --- /dev/null +++ b/frontend/src/styles/media.js @@ -0,0 +1,6 @@ +// src/styles/media.js +export const media = { + mobile: "(max-width: 767px)", + tablet: "(min-width: 768px) and (max-width: 1024px)", + desktop: "(min-width: 1025px)", +}; diff --git a/frontend/src/ui/EmptyState.jsx b/frontend/src/ui/EmptyState.jsx new file mode 100644 index 000000000..c3165cb4c --- /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: var(--color-title); +`; + +const EmptyText = styled.p` + font-size: 16px; + color: var(--color-text); +`; + +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..270ea10e5 --- /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 var(--color-border); + border-top: 4px solid var(--color-green-light); + 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..a8dcd5f7b --- /dev/null +++ b/frontend/src/ui/ShareButton.jsx @@ -0,0 +1,37 @@ +import styled from "styled-components"; +import { IoShareOutline } from "react-icons/io5"; + +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; diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 000000000..bd7752652 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "cd frontend && npm install && npm run build" + publish = "frontend/dist" \ No newline at end of file diff --git a/package.json b/package.json index 680d19077..8adb10216 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,10 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "axios": "^1.13.5", + "framer-motion": "^12.34.0", + "react-icons": "^5.6.0" } -} \ No newline at end of file +}