From 8751a814ea2c6602a7f91f7b1e57ae9b8cd6cc78 Mon Sep 17 00:00:00 2001 From: fridascript Date: Tue, 10 Feb 2026 14:10:03 +0100 Subject: [PATCH 01/19] backend set up, adds routes to register and login user and auth --- README.md | 4 +- backend/models/user.js | 35 ++++++++++++++++ backend/package.json | 18 ++++++--- backend/routes/auth.js | 91 ++++++++++++++++++++++++++++++++++++++++++ backend/server.js | 12 +++++- frontend/src/App.jsx | 2 +- 6 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 backend/models/user.js create mode 100644 backend/routes/auth.js diff --git a/README.md b/README.md index 31466b54c..494861db9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Final Project -Replace this readme with your own information about your project. +As my final project I decided to combine my two current hyper fixations: building websites and crafts (in my case ceramics) -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +This website helps hobby artists share their work and reach potential buyers by showcasing their hand crafted items in an online gallery with an interest-based system. Instead of public transactions, users can express interest and connect directly with the artist to complete purchases privately. The platform offers an alternative, more personal space for all hobby artists out their to show and sell their work. ## The problem diff --git a/backend/models/user.js b/backend/models/user.js new file mode 100644 index 000000000..b1c72d272 --- /dev/null +++ b/backend/models/user.js @@ -0,0 +1,35 @@ +import mongoose from "mongoose"; +import crypto from "crypto"; + + +const UserSchema = new mongoose.Schema ({ + name: { + type: String, + required: true, + }, + email: { + type: String, + required: true, + unique: true, + lowercase: true, + }, + password: { + type: String, + required: true, + minlength: 6 + }, + bio: { + type: String, + default: "" + }, + profileImage: { + type: String, + default: "" + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex") + } +}); + +export default mongoose.model("User", UserSchema); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f244..4585b5f2b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,17 @@ "@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", - "nodemon": "^3.0.1" + "bcrypt": "^6.0.0", + "cloudinary": "^1.41.3", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^17.2.4", + "express": "^4.22.1", + "jsonwebtoken": "^9.0.3", + "mongoose": "^8.22.1", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", + "nodemailer": "^8.0.1", + "nodemon": "^3.1.11" } -} \ No newline at end of file +} diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 000000000..8c691956e --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,91 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import User from "../models/user.js" + +// handles register user and log in user +const router = express.Router(); + +//*** routes *** + +// route: register new user +router.post('/register', async (req, res) =>{ + try { + const {name, email, password } = req.body; + +// check if user already exists +const existingUser = await User.findOne({ email: toLowerCase }); +if (existingUser) { + return res.status(400).json({ + success: false, + message: "This email address is already connected to an account" + }); +} + +// hash password +const salt = bcrypt.genSaltSync(); +const user = new User({ + name, + email: email.toLowerCase(), + password: bcrypt.hashSync(password, salt) +}); + +//save user +await user.save(); + +//response +res.status(201).json ({ + success: true, + message: "User created!", + response: { + userId: user._id, + name: user.name, + email: user.email, + accessToken: user.accessToken + } +}); + + } catch (error) { + res.status(500).json({ + success: false, + message: "Could not create user", + response: error + }); + } +}); + + +// route: log in user +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.status(200).json({ + success: true, + message: "Login successful", + response: { + userId: user._id, + name: user.name, + email: user.email, + accessToken: user.accessToken + } + }); + } else { + res.status(401).json({ + success: false, + message: 'Invalid email or password' + }); + } + + } catch (error) { + res.status(500).json({ + success: false, + message: 'Something went wrong', + response: error + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 070c87518..85a002763 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,8 +1,12 @@ 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"; + +// MongoDB connection not connected yet! +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/manomano"; mongoose.connect(mongoUrl); mongoose.Promise = Promise; @@ -12,6 +16,12 @@ const app = express(); app.use(cors()); app.use(express.json()); +//import routes +import authRoutes from "./routes/auth.js" + +//use routes +app.use("/api/auth", authRoutes); + app.get("/", (req, res) => { res.send("Hello Technigo!"); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..31a7a6673 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,7 +2,7 @@ export const App = () => { return ( <> -

Welcome to Final Project!

+

MANOMANO

); }; From 4353c65863687dcfb4e34a251bd3aba75e9a6998 Mon Sep 17 00:00:00 2001 From: fridascript Date: Tue, 10 Feb 2026 14:55:17 +0100 Subject: [PATCH 02/19] adds product model and routes, API documentation endpoints --- README.md | 4 ++- backend/models/product.js | 41 ++++++++++++++++++++++++ backend/routes/products.js | 65 ++++++++++++++++++++++++++++++++++++++ backend/server.js | 34 +++++++++++++++++++- 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 backend/models/product.js create mode 100644 backend/routes/products.js diff --git a/README.md b/README.md index 494861db9..903372c3b 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,6 @@ Describe how you approached to problem, and what tools and techniques you used t ## 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 +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. + + diff --git a/backend/models/product.js b/backend/models/product.js new file mode 100644 index 000000000..72b175691 --- /dev/null +++ b/backend/models/product.js @@ -0,0 +1,41 @@ +import mongoose from "mongoose"; + +const ProductSchema = new mongoose.Schema({ + title: { + type: String, + required: true, + trim: true + }, + image: { + type: String, + required: true + }, + category: { + type: String, + required: true, + enum: ['Ceramics', 'Textile', 'Wood', 'Jewelry', 'Art', 'Other'] + }, + forSale: { + type: Boolean, + default: false + }, + price: { + type: Number, + required: function() { + return this.forSale === true; + } + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + likes: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }] +}, { + timestamps: true +}); + +export default mongoose.model('Product', ProductSchema); \ No newline at end of file diff --git a/backend/routes/products.js b/backend/routes/products.js new file mode 100644 index 000000000..ef90857a2 --- /dev/null +++ b/backend/routes/products.js @@ -0,0 +1,65 @@ +import express from "express"; +import Product from "../models/product.js"; + +const router = express.Router(); + + +// *** routes *** + +// route: create new product post + +router.post("/", async (req, res) => { + try { + const { title, image, category, forSale, price, creator} = req.body; + + const product = new Product ({ + title, + image, + category, + forSale, + price, + creator, + }); + + await product.save(); + + res.status(201).json ({ + success: true, + message: "Product post created", + response: product + }); + + } catch (error) { + console.log('ERROR:', error); + res.status(400).json({ + success: false, + message: 'Could not create product', + response: error.message + }); + } +}); + + +// route: get all products + +router.get('/', async (req, res) => { + try { + const products = await Product.find() + .populate('creator', 'name email profileImage') + .sort({ createdAt: -1 }); + + res.status(200).json({ + success: true, + response: products + }); + + } catch (error) { + res.status(500).json({ + success: false, + message: 'Could not fetch products', + response: error + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 85a002763..d0fe221b7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,14 +18,46 @@ app.use(express.json()); //import routes import authRoutes from "./routes/auth.js" +import productRoutes from './routes/products.js'; + //use routes app.use("/api/auth", authRoutes); +app.use('/api/products', productRoutes); +//documentation for endpoints app.get("/", (req, res) => { - res.send("Hello Technigo!"); + res.json({ + message: "manomano API", + endpoints: [ + { + path: "/api/auth/register", + method: "POST", + description: "Register a new user", + body: { name: "string", email: "string", password: "string" } + }, + { + path: "/api/auth/login", + method: "POST", + description: "Log in (returns accessToken)", + body: { email: "string", password: "string" } + }, + { + path: "/api/products", + method: "GET", + description: "Get all products" + }, + { + path: "/api/products", + method: "POST", + description: "Create a new product", + body: { title: "string", image: "string", category: "string", forSale: "boolean", price: "number", creator: "userId" } + } + ] + }); }); + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); From 1c8f4e54cae6b5e04bd8c71ad5c152ca464784f3 Mon Sep 17 00:00:00 2001 From: fridascript Date: Fri, 13 Feb 2026 15:30:03 +0100 Subject: [PATCH 03/19] adds homepage set up --- frontend/index.html | 6 +- frontend/src/App.jsx | 14 ++++- frontend/src/assets/GlobalStyles.js | 16 +++++ frontend/src/assets/boiler-plate.svg | 18 ------ frontend/src/assets/react.svg | 1 - frontend/src/assets/technigo-logo.svg | 31 ---------- frontend/src/assets/theme.js | 21 +++++++ frontend/src/components/FilterBar.jsx | 63 +++++++++++++++++++ frontend/src/components/Navbar.jsx | 81 +++++++++++++++++++++++++ frontend/src/components/ProductCard.jsx | 60 ++++++++++++++++++ frontend/src/pages/home.jsx | 51 ++++++++++++++++ package.json | 5 +- 12 files changed, 313 insertions(+), 54 deletions(-) create mode 100644 frontend/src/assets/GlobalStyles.js delete mode 100644 frontend/src/assets/boiler-plate.svg delete mode 100644 frontend/src/assets/react.svg delete mode 100644 frontend/src/assets/technigo-logo.svg create mode 100644 frontend/src/assets/theme.js create mode 100644 frontend/src/components/FilterBar.jsx create mode 100644 frontend/src/components/Navbar.jsx create mode 100644 frontend/src/components/ProductCard.jsx create mode 100644 frontend/src/pages/home.jsx diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..89176b39f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,11 @@ - + + + - Technigo React Vite Boiler Plate + MANOMANO - personal and handcrafted items
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 31a7a6673..b3bf972c4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,20 @@ +import {ThemeProvider } from "styled-components"; +import { theme } from "./assets/theme"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import {Home} from "./pages/home.jsx"; +import { GlobalStyles } from "./assets/GlobalStyles.js"; export const App = () => { return ( <> -

MANOMANO

+ + + + + } /> + + + ); }; diff --git a/frontend/src/assets/GlobalStyles.js b/frontend/src/assets/GlobalStyles.js new file mode 100644 index 000000000..b670881d3 --- /dev/null +++ b/frontend/src/assets/GlobalStyles.js @@ -0,0 +1,16 @@ +import { createGlobalStyle } from 'styled-components'; + +export const GlobalStyles = createGlobalStyle` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + margin: 40px; + font-family: ${props => props.theme.fonts.body}; + background-color: ${props => props.theme.colors.background}; + color: ${props => props.theme.colors.text}; + } +`; \ No newline at end of file diff --git a/frontend/src/assets/boiler-plate.svg b/frontend/src/assets/boiler-plate.svg deleted file mode 100644 index c9252833b..000000000 --- a/frontend/src/assets/boiler-plate.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9bb..000000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/technigo-logo.svg b/frontend/src/assets/technigo-logo.svg deleted file mode 100644 index 3f0da3e57..000000000 --- a/frontend/src/assets/technigo-logo.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/theme.js b/frontend/src/assets/theme.js new file mode 100644 index 000000000..243bb2a1c --- /dev/null +++ b/frontend/src/assets/theme.js @@ -0,0 +1,21 @@ +export const theme = { + + colors: { + primary: '#000000', + accent: '#800020', + text: '#000000', + textLight: '#666666', + background: '#ffffff', + border: '#dddddd' + }, + fonts: { + heading: '"Rakkas", cursive', + body: '"Lexend", sans-serif' + }, + fontSizes: { + small: '12px', + medium: '14px', + large: '24px', + xlarge: '32px' + } +}; \ No newline at end of file diff --git a/frontend/src/components/FilterBar.jsx b/frontend/src/components/FilterBar.jsx new file mode 100644 index 000000000..69c6c37fe --- /dev/null +++ b/frontend/src/components/FilterBar.jsx @@ -0,0 +1,63 @@ +import styled from 'styled-components'; + +const FilterContainer = styled.div` + display: flex; + gap: 20px; + margin-top: 200px; + margin-left: 380px; + margin-bottom: 0px; + align-items: center; +`; + +const FilterLabel = styled.span` + font-family: ${props => props.theme.fonts.body}; + font-weight: 100; + margin-right: 8px; +`; + +const Select = styled.select` + padding: 8px 15px; + border: 1px solid ${props => props.theme.colors.border}; + border-radius: 4px; + font-family: ${props => props.theme.fonts.body}; + cursor: pointer; + + &:focus { + outline: none; + border-color: ${props => props.theme.colors.text}; + } +`; + +export const FilterBar = () => { + return ( + +
+ ARTIST + +
+ +
+ TYPE + +
+ +
+ COLOR + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx new file mode 100644 index 000000000..2406fb65a --- /dev/null +++ b/frontend/src/components/Navbar.jsx @@ -0,0 +1,81 @@ +import styled from 'styled-components'; + +const Nav = styled.nav` + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 40px; + background-color: ${props => props.theme.colors.background}; + border-bottom: 1px solid ${props => props.theme.colors.border}; + gap: 40px; +`; + +const LogoSection = styled.div` + display: flex; + flex-direction: column; +`; + +const Logo = styled.h2` + font-family: ${props => props.theme.fonts.heading}; + color: ${props => props.theme.colors.text}; + margin: 0; +`; + +const Subtitle = styled.p` + font-family: ${props => props.theme.fonts.body}; + color: ${props => props.theme.colors.textLight}; + font-size: ${props => props.theme.fontSizes.medium}; + margin: 0; +`; + +const SearchBar = styled.input` + padding: 8px 16px; + border: 1px solid ${props => props.theme.colors.border}; + border-radius: 4px; + font-family: ${props => props.theme.fonts.body}; + font-size: ${props => props.theme.fontSizes.medium}; + width: 300px; + margin-left: auto; + + &:focus { + outline: none; + border-color: ${props => props.theme.colors.text}; + } +`; + +const NavLinks = styled.div` + display: flex; + gap: 20px; + align-items: center; +`; + +const NavButton = styled.button` + background: none; + border: 1px solid ${props => props.theme.colors.text}; + padding: 8px 16px; + border-radius: 4px; + font-family: ${props => props.theme.fonts.body}; + cursor: pointer; + + &:hover { + background-color: ${props => props.theme.colors.accent}; + color: white; + border-color: ${props => props.theme.colors.accent}; + } +`; + +export const Navbar = () => { + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx new file mode 100644 index 000000000..a0e087efb --- /dev/null +++ b/frontend/src/components/ProductCard.jsx @@ -0,0 +1,60 @@ +import styled from "styled-components"; + +const Card = styled.div` +border: 1px solid black; +border-radius: 4px; +padding: 6px; +max-width: 250px; +display:flex; +flex-direction: column; +align-items: left; +cursor: pointer; + +&:hover { + transform: translateY(-5px); + border: 2px solid ${props => props.theme.colors.accent}; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } +`; + +const ImagePlaceholder = styled.div` + width: 100%; + height: 200px; + background-color: #c4c1c1; + border-radius: 4px; + margin-bottom: 12px; +`; + +const Title = styled.h3` + margin: 8px; + font-size: ${props => props.theme.fontSizes.medium}; + font-family: ${props => props.theme.fonts.body}; + color: ${props => props.theme.colors.text}; +`; + +const Artist = styled.p` + margin: 4px; + color: ${props => props.theme.colors.textLight}; + font-size: ${props => props.theme.fontSizes.small}; + font-family: ${props => props.theme.fonts.body}; +`; + +const Price = styled.p` + margin: 8px; + color: ${props => props.theme.colors.textLight}; + font-size: ${props => props.theme.fontSizes.small}; + font-family: ${props => props.theme.fonts.body}; +`; + + + +export const ProductCard = () => { + return ( + + + Product: + Artist: + xx kr + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/home.jsx b/frontend/src/pages/home.jsx new file mode 100644 index 000000000..863198246 --- /dev/null +++ b/frontend/src/pages/home.jsx @@ -0,0 +1,51 @@ +import styled from "styled-components"; +import { Navbar } from "../components/Navbar"; +import { FilterBar } from '../components/FilterBar'; +import { ProductCard } from '../components/ProductCard'; + +//styled components +const Container = styled.div` + padding: 20px; + background-color: ${props => props.theme.colors.background}; + max-width: 1400px; + margin: 0 auto; +`; +const GridContainer = styled.div` +max-width: 1100px; +margin: 0 auto; + +`; +const Grid = styled.div` + display: grid; + grid-template-columns: repeat(4, 1fr); + column-gap: 10px; + row-gap: 30px; + margin-top: 80px; + +`; + + +export const Home = () => { + return ( + + + + + + + + + + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/package.json b/package.json index 680d19077..85d027117 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,8 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "styled-components": "^6.3.9" } -} \ No newline at end of file +} From df612e563a0b2a10b5b2c33637f3a052e2616339 Mon Sep 17 00:00:00 2001 From: fridascript Date: Mon, 16 Feb 2026 13:17:26 +0100 Subject: [PATCH 04/19] Add cloudinary image upload and connect frontend to API --- backend/config/cloudinary.js | 25 ++++++++++++++++++++++ backend/package.json | 4 ++-- backend/routes/products.js | 11 +++++----- frontend/src/components/ProductCard.jsx | 18 +++++++++------- frontend/src/pages/home.jsx | 28 ++++++++++++++----------- frontend/src/tools/api.js | 7 +++++++ 6 files changed, 66 insertions(+), 27 deletions(-) create mode 100644 backend/config/cloudinary.js create mode 100644 frontend/src/tools/api.js diff --git a/backend/config/cloudinary.js b/backend/config/cloudinary.js new file mode 100644 index 000000000..6a729a4c2 --- /dev/null +++ b/backend/config/cloudinary.js @@ -0,0 +1,25 @@ +import cloudinaryFramework from "cloudinary"; +import multer from "multer"; +import { CloudinaryStorage } from "multer-storage-cloudinary"; +import dotenv from "dotenv"; +dotenv.config(); + +const cloudinary = cloudinaryFramework.v2; +cloudinary.config({ + cloud_name: process.env.cloud_name, + api_key: process.env.api_key, + api_secret: process.env.api_secret +}) + +const storage = new CloudinaryStorage({ + cloudinary, + params: { + folder: 'products', + allowedFormats: ['jpg', 'png'], + transformation: [{ width: 500, height: 500, crop: 'limit' }], + }, +}) +const parser = multer({ storage }) + + +export { parser }; diff --git a/backend/package.json b/backend/package.json index 4585b5f2b..6d6c2eacb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,10 +16,10 @@ "cloudinary": "^1.41.3", "cookie-parser": "^1.4.7", "cors": "^2.8.6", - "dotenv": "^17.2.4", + "dotenv": "^17.3.1", "express": "^4.22.1", "jsonwebtoken": "^9.0.3", - "mongoose": "^8.22.1", + "mongoose": "^8.23.0", "multer": "^2.0.2", "multer-storage-cloudinary": "^4.0.0", "nodemailer": "^8.0.1", diff --git a/backend/routes/products.js b/backend/routes/products.js index ef90857a2..4cc1e7192 100644 --- a/backend/routes/products.js +++ b/backend/routes/products.js @@ -1,20 +1,22 @@ import express from "express"; import Product from "../models/product.js"; +import { parser } from '../config/cloudinary.js'; const router = express.Router(); + // *** routes *** // route: create new product post - -router.post("/", async (req, res) => { +router.post("/",parser.single("image"), async (req, res) => { try { - const { title, image, category, forSale, price, creator} = req.body; + const { title, category, forSale, price, creator} = req.body; + const imageUrl = req.file.path; const product = new Product ({ title, - image, + image: imageUrl, category, forSale, price, @@ -41,7 +43,6 @@ router.post("/", async (req, res) => { // route: get all products - router.get('/', async (req, res) => { try { const products = await Product.find() diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx index a0e087efb..930980da5 100644 --- a/frontend/src/components/ProductCard.jsx +++ b/frontend/src/components/ProductCard.jsx @@ -19,8 +19,10 @@ cursor: pointer; const ImagePlaceholder = styled.div` width: 100%; - height: 200px; + height: 250px; background-color: #c4c1c1; + background-position: center; + background-repeat: no-repeat; border-radius: 4px; margin-bottom: 12px; `; @@ -48,13 +50,13 @@ const Price = styled.p` -export const ProductCard = () => { +export const ProductCard = ({ product }) => { return ( - - - Product: - Artist: - xx kr - + + + {product.title} + By {product.creator?.name || 'Artist'} + {product.price} kr + ); }; \ No newline at end of file diff --git a/frontend/src/pages/home.jsx b/frontend/src/pages/home.jsx index 863198246..2cba2d27d 100644 --- a/frontend/src/pages/home.jsx +++ b/frontend/src/pages/home.jsx @@ -1,7 +1,9 @@ +import { useState, useEffect } from "react"; import styled from "styled-components"; import { Navbar } from "../components/Navbar"; import { FilterBar } from '../components/FilterBar'; import { ProductCard } from '../components/ProductCard'; +import { fetchProducts } from "../tools/api"; //styled components const Container = styled.div` @@ -26,24 +28,26 @@ const Grid = styled.div` export const Home = () => { + const [products, setProducts] = useState([]); + + useEffect(() => { + const getProducts = async () => { + const data = await fetchProducts(); + setProducts(data); + }; + + getProducts(); + }, []); + return ( - - - - - - - - - - - - + {products.map((product) => ( + + ))} diff --git a/frontend/src/tools/api.js b/frontend/src/tools/api.js new file mode 100644 index 000000000..3c943b19b --- /dev/null +++ b/frontend/src/tools/api.js @@ -0,0 +1,7 @@ +const API_URL = 'http://localhost:5000/api'; + +export const fetchProducts = async () => { + const response = await fetch(`${API_URL}/products`); + const data = await response.json(); + return data.response; +}; \ No newline at end of file From 67fcb0ed31b1c71a0c45c3b0c027b6ff2275b47f Mon Sep 17 00:00:00 2001 From: fridascript Date: Mon, 23 Feb 2026 12:32:56 +0100 Subject: [PATCH 05/19] adds dashboard foundation for logged in user --- backend/models/interest.js | 12 ++ backend/routes/auth.js | 30 +++- backend/routes/interest.js | 38 +++++ backend/routes/products.js | 32 +++- backend/server.js | 2 + frontend/src/App.jsx | 15 +- frontend/src/assets/theme.js | 15 +- frontend/src/components/FilterBar.jsx | 28 ++- frontend/src/components/Navbar.jsx | 186 +++++++++++++++++++- frontend/src/components/ProductCard.jsx | 7 +- frontend/src/pages/ProductDetail.jsx | 215 ++++++++++++++++++++++++ frontend/src/pages/dashboard.jsx | 138 +++++++++++++++ frontend/src/pages/home.jsx | 31 +++- frontend/src/pages/login.jsx | 97 +++++++++++ frontend/src/pages/messages.jsx | 81 +++++++++ frontend/src/pages/register.jsx | 99 +++++++++++ frontend/src/tools/api.js | 6 + 17 files changed, 993 insertions(+), 39 deletions(-) create mode 100644 backend/models/interest.js create mode 100644 backend/routes/interest.js create mode 100644 frontend/src/pages/ProductDetail.jsx create mode 100644 frontend/src/pages/dashboard.jsx create mode 100644 frontend/src/pages/login.jsx create mode 100644 frontend/src/pages/messages.jsx create mode 100644 frontend/src/pages/register.jsx diff --git a/backend/models/interest.js b/backend/models/interest.js new file mode 100644 index 000000000..ecbe10833 --- /dev/null +++ b/backend/models/interest.js @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; + +const interestSchema = new mongoose.Schema({ + productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true }, + name: { type: String, required: true }, + email: { type: String, required: true }, + phone: { type: String }, + message: { type: String, required: true }, + createdAt: { type: Date, default: Date.now } +}); + +export const Interest = mongoose.model('Interest', interestSchema); \ No newline at end of file diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 8c691956e..e5f475eae 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -13,7 +13,7 @@ router.post('/register', async (req, res) =>{ const {name, email, password } = req.body; // check if user already exists -const existingUser = await User.findOne({ email: toLowerCase }); +const existingUser = await User.findOne({ email: email.toLowerCase() }); if (existingUser) { return res.status(400).json({ success: false, @@ -44,13 +44,14 @@ res.status(201).json ({ } }); - } catch (error) { - res.status(500).json({ - success: false, - message: "Could not create user", - response: error - }); - } + } catch (error) { + console.log('Register error:', error); + res.status(500).json({ + success: false, + message: "Could not create user", + response: error + }); +} }); @@ -88,4 +89,17 @@ router.post("/login", async (req, res) => { } }); +// identifies who is logged in "hi, username " +router.get('/me', async (req, res) => { + try { + const user = await User.findOne({ accessToken: req.headers.authorization }); + if (!user) return res.status(401).json({ success: false, message: 'Unauthorized' }); + res.status(200).json({ success: true, response: { name: user.name, email: user.email } }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}); + + + export default router; \ No newline at end of file diff --git a/backend/routes/interest.js b/backend/routes/interest.js new file mode 100644 index 000000000..350678743 --- /dev/null +++ b/backend/routes/interest.js @@ -0,0 +1,38 @@ +import express from 'express'; +import mongoose from 'mongoose'; +import { Interest } from '../models/Interest.js'; + +const router = express.Router(); + +router.post('/', async (req, res) => { + try { + const { productId, name, email, phone, message } = req.body; + + const interest = new Interest({ productId, name, email, phone, message }); + await interest.save(); + + res.status(201).json({ message: 'Interest saved successfully' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/my-interests/:userId', async (req, res) => { + try { + const { userId } = req.params; + + //find product by the artist + const products = await mongoose.model('Product').find({ creator: userId }); + const productIds = products.map(p => p._id); + + // find all interest of the product + const interests = await Interest.find({ productId: { $in: productIds } }) + .populate('productId', 'title image'); + + res.status(200).json({ success: true, response: interests }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/routes/products.js b/backend/routes/products.js index 4cc1e7192..7f21a814c 100644 --- a/backend/routes/products.js +++ b/backend/routes/products.js @@ -45,7 +45,9 @@ router.post("/",parser.single("image"), async (req, res) => { // route: get all products router.get('/', async (req, res) => { try { - const products = await Product.find() + const { userId } = req.query; + const filter = userId ? { creator: userId } : {}; + const products = await Product.find(filter) .populate('creator', 'name email profileImage') .sort({ createdAt: -1 }); @@ -63,4 +65,32 @@ router.get('/', async (req, res) => { } }); +// get single product by ID + +router.get("/:id", async (req,res) => { + try { + const { id } = req.params; + const product = await Product.findById(id) + .populate("creator", "name email profileImage"); +if (!product) { + return res.status(404).json({ + success: false, + message: 'Product not found' + }); + } + + res.status(200).json({ + success: true, + response: product + }); + + } catch (error) { + res.status(500).json({ + success: false, + message: 'Could not fetch product', + response: error + }); + } +}); + export default router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index d0fe221b7..860f72161 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,11 +19,13 @@ app.use(express.json()); //import routes import authRoutes from "./routes/auth.js" import productRoutes from './routes/products.js'; +import interestRoutes from "./routes/interest.js"; //use routes app.use("/api/auth", authRoutes); app.use('/api/products', productRoutes); +app.use('/api/interests', interestRoutes); //documentation for endpoints app.get("/", (req, res) => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b3bf972c4..1f4c5d424 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,16 @@ import {ThemeProvider } from "styled-components"; import { theme } from "./assets/theme"; +import { GlobalStyles } from "./assets/GlobalStyles.js"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import {Home} from "./pages/home.jsx"; -import { GlobalStyles } from "./assets/GlobalStyles.js"; +import { Login } from "./pages/login.jsx"; +import { Register } from './pages/register.jsx'; +import { ProductDetail } from "./pages/ProductDetail.jsx"; +//for logged in artist +import { Dashboard } from './pages/dashboard.jsx'; +import { Messages } from './pages/messages.jsx'; + + export const App = () => { return ( @@ -12,6 +20,11 @@ export const App = () => { } /> + } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/assets/theme.js b/frontend/src/assets/theme.js index 243bb2a1c..be40b3f64 100644 --- a/frontend/src/assets/theme.js +++ b/frontend/src/assets/theme.js @@ -13,9 +13,14 @@ export const theme = { body: '"Lexend", sans-serif' }, fontSizes: { - small: '12px', - medium: '14px', - large: '24px', - xlarge: '32px' - } + small: "12px", + medium: "14px", + large: "24px", + xlarge: "32px" + }, + breakpoints: { + mobile: "480px", + tablet: "768px", + desktop: "1024px" +} }; \ No newline at end of file diff --git a/frontend/src/components/FilterBar.jsx b/frontend/src/components/FilterBar.jsx index 69c6c37fe..e9ad65077 100644 --- a/frontend/src/components/FilterBar.jsx +++ b/frontend/src/components/FilterBar.jsx @@ -2,12 +2,21 @@ import styled from 'styled-components'; const FilterContainer = styled.div` display: flex; - gap: 20px; - margin-top: 200px; - margin-left: 380px; - margin-bottom: 0px; - align-items: center; -`; + flex-direction: column; + gap: 15px; + margin: 30px 0; + margin-top: 50px; + align-items: stretch; + border: 1px solid #800020; + border-radius: 8px; + padding: 20px; + + + @media (min-width: ${props => props.theme.breakpoints.tablet}) { + flex-direction: row; + align-items: center; + } + `; const FilterLabel = styled.span` font-family: ${props => props.theme.fonts.body}; @@ -16,11 +25,16 @@ const FilterLabel = styled.span` `; const Select = styled.select` - padding: 8px 15px; + padding: 10px 10px; border: 1px solid ${props => props.theme.colors.border}; border-radius: 4px; font-family: ${props => props.theme.fonts.body}; cursor: pointer; + width: 100%; + + @media (min-width: ${props => props.theme.breakpoints.tablet}) { + width: auto; + } &:focus { outline: none; diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 2406fb65a..951944f2f 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -1,4 +1,62 @@ import styled from 'styled-components'; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +// styling for component +const MobileMenu = styled.div.withConfig({ + shouldForwardProp: (prop) => prop !== 'isOpen' +})` + display: ${props => props.isOpen ? 'flex' : 'none'}; + flex-direction: column; + position: fixed; + top: 0; + right: 0; + width: 250px; + height: 100vh; + background-color: ${props => props.theme.colors.background}; + box-shadow: -2px 0 10px rgba(0,0,0,0.1); + padding: 60px 20px 20px; + gap: 20px; + z-index: 100; + transition: transform 0.3s ease; + transform: ${props => props.isOpen ? 'translateX(0)' : 'translateX(100%)'}; + + @media (min-width: ${props => props.theme.breakpoints.tablet}) { + display: none; + } +`; + +const HamburgerButton = styled.button` + display: flex; + background: none; + border: none; + cursor: pointer; + flex-direction: column; + gap: 4px; + z-index: 10; + + @media (min-width: ${props => props.theme.breakpoints.tablet}) { + display: none; + } + + span { + width: 25px; + height: 3px; + background-color: ${props => props.theme.colors.text}; + transition: all 0.3s ease; + } +`; + +const CloseButton = styled.button` + position:absolute; + top: 20px; + right: 20px; + background: none; + border:none; + font-size: 24px; + cursor: pointer; + color: ${props => props.theme.colors.text}; +`; const Nav = styled.nav` display: flex; @@ -41,12 +99,20 @@ const SearchBar = styled.input` outline: none; border-color: ${props => props.theme.colors.text}; } + + @media (max-width: ${props => props.theme.breakpoints.tablet}) { + display: none; + } `; const NavLinks = styled.div` - display: flex; + display: none; gap: 20px; align-items: center; + + @media (min-width: ${props => props.theme.breakpoints.tablet}) { + display: flex; + } `; const NavButton = styled.button` @@ -64,18 +130,124 @@ const NavButton = styled.button` } `; +const LogoutButton = styled(NavButton)` + background-color: ${props => props.theme.colors.accent}; + color: white; + border-color: ${props => props.theme.colors.accent}; + + &:hover { + opacity: 0.9; + transform: translateY(-2px); + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.3); + } +`; + +const MessagesLink = styled(NavButton)` + background: none; + border: none; + font-weight: bold; +`; + +const Dropdown = styled.div` + position: relative; + + &:hover > div { + display: flex; + } +`; + +const DropdownMenu = styled.div` + display: none; + flex-direction: column; + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid ${props => props.theme.colors.border}; + border-radius: 8px; + padding: 8px; + gap: 4px; + z-index: 100; + min-width: 150px; +`; + +const DropdownItem = styled(NavButton)` + border: none; + text-align: left; + width: 100%; +`; + +const Username = styled.span` + font-family: ${props => props.theme.fonts.body}; + font-weight: bold; + cursor: pointer; +`; + + export const Navbar = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const isLoggedIn = !!localStorage.getItem('accessToken'); + const navigate = useNavigate (); + return ( + <> + + setIsMenuOpen(!isMenuOpen)}> + + + + + + {!isLoggedIn && } + + + {isLoggedIn ? ( + <> + navigate('/messages')}>Messages + + menu ▾ + + navigate('/dashboard')}>My items + navigate('/post-item')}>Post new item + navigate('/account')}>My account + + + { + localStorage.removeItem('accessToken'); + localStorage.removeItem('userId'); + navigate('/'); + }}>Log out + + ) : ( + <> + navigate('/login')}>Log in + navigate('/register')}>Sign up + + )} + + + + + setIsMenuOpen(false)}>X + {isLoggedIn ? ( + { + localStorage.removeItem('accessToken'); + localStorage.removeItem('userId'); + navigate('/'); + setIsMenuOpen(false); + }}>Log out + ) : ( + <> + { navigate('/login'); setIsMenuOpen(false); }}>Log in + { navigate('/register'); setIsMenuOpen(false); }}>Sign up + + )} + + ); }; \ No newline at end of file diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx index 930980da5..c768b3c34 100644 --- a/frontend/src/components/ProductCard.jsx +++ b/frontend/src/components/ProductCard.jsx @@ -1,11 +1,14 @@ import styled from "styled-components"; +import { Link } from "react-router-dom"; const Card = styled.div` border: 1px solid black; border-radius: 4px; padding: 6px; +width: 100%; max-width: 250px; -display:flex; +margin: 0 auto; +display: flex; flex-direction: column; align-items: left; cursor: pointer; @@ -52,11 +55,13 @@ const Price = styled.p` export const ProductCard = ({ product }) => { return ( + {product.title} By {product.creator?.name || 'Artist'} {product.price} kr + ); }; \ No newline at end of file diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx new file mode 100644 index 000000000..c453ae829 --- /dev/null +++ b/frontend/src/pages/ProductDetail.jsx @@ -0,0 +1,215 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; +import { Navbar } from '../components/Navbar'; +import { fetchProductById } from '../tools/api'; + +const Container = styled.div` + padding: 20px; + margin: 0 auto; + max-width: 1200px; + + @media (min-width: ${props => props.theme.breakpoints.tablet}) { + padding: 40px 20px; + } +`; + +const DetailContainer = styled.div` + display: flex; + flex-direction: column; + gap: 30px; + margin-top: 50px; + + @media (min-width: ${props => props.theme.breakpoints.tablet}) { + flex-direction: row; + gap: 60px; + } +`; + +const ImageSection = styled.div` + flex: 1; + max-width: 400px; + padding: 10px; + border: 1px solid #800020; + border-radius: 8px; + align-self: flex-start; + + @media (min-width: ${props => props.theme.breakpoints.tablet}) { + max-width: 450px; + } +`; + +const ProductImage = styled.img` + width: 100%; + max-height: 400px; + border-radius: 8px; + object-fit: cover; + object-position: center; + background-color: #f5f5f5; +`; + +const InfoSection = styled.div` + flex: 1; +`; + +const Title = styled.h1` + font-family: ${props => props.theme.fonts.heading}; + font-size: ${props => props.theme.fontSizes.xlarge}; + margin-bottom: 10px; +`; + +const Price = styled.p` + font-size: ${props => props.theme.fontSizes.large}; + font-weight: bold; + margin: 20px 0; +`; + +const Category = styled.p` + color: ${props => props.theme.colors.textLight}; + margin: 10px 0; +`; + +const InterestButton = styled.button` + padding: 12px 24px; + background-color: ${props => props.theme.colors.accent}; + color: white; + border: none; + border-radius: 8px; + font-family: ${props => props.theme.fonts.body}; + font-size: ${props => props.theme.fontSizes.medium}; + cursor: pointer; + margin-top: 20px; + display: flex; + margin: 0 auto; + margin-top: 80px; + + &:hover { + opacity: 0.9; + transform: translateY(-2px); + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.3); + } +`; + +// styling for the interest form +const Form = styled.div` + margin-top: 30px; + display: flex; + flex-direction: column; + gap: 12px; +`; + +const Input = styled.input` + padding: 10px 14px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: ${props => props.theme.fontSizes.medium}; + font-family: ${props => props.theme.fonts.body}; + width: 100%; +`; + +const Textarea = styled.textarea` + padding: 10px 14px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: ${props => props.theme.fontSizes.medium}; + font-family: ${props => props.theme.fonts.body}; + width: 100%; + min-height: 120px; + resize: vertical; +`; + +const SubmitButton = styled.button` + padding: 12px 24px; + background-color: ${props => props.theme.colors.accent}; + color: white; + border: none; + border-radius: 8px; + font-size: ${props => props.theme.fontSizes.medium}; + font-family: ${props => props.theme.fonts.body}; + cursor: pointer; + + &:hover { opacity: 0.9; } +`; + + +export const ProductDetail = () => { + const { id } = useParams(); + const [product, setProduct] = useState(null); + const [showForm, setShowForm] = useState(false); + const [formData, setFormData] = useState({ + name: '', + email: '', + phone: '', + message: '' +}); + + useEffect(() => { + const getProduct = async () => { + const data = await fetchProductById(id); + setProduct(data); + }; + + getProduct(); + }, [id]); + + if (!product) { + return
Loading...
; + } + + const handleChange = (e) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); +}; + + const handleSubmit = async () => { + try { + await fetch('http://localhost:5000/api/interests', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId: id, ...formData }) + }); + setShowForm(false); + alert('Thanks! Your message has been sent to the artist.'); + } catch (error) { + alert('Oups something went wrong, try again!'); + } +}; + + + return ( + <> + + + + + + + + + {product.title} + {product.category} + {product.price} kr + + {product.forSale && ( + <> +

Contact the artist if interested in item

+ setShowForm(!showForm)}> + {showForm ? 'Hide form' : 'Connect with the artist'} + + + {showForm && ( +
+ + + +