Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
# Final Project
# MANOMANO 🏺

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.
MANOMANO is a marketplace for hobby artists to showcase and sell their handmade items. Instead of public transactions, visitors can express interest and connect directly with the artist to complete purchases privately. The platform offers a more personal alternative space for hobby artists to show and sell their work. To post your items you register and log in, for visitors only there's no need to sign up.

## The problem
## 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?
I wanted to build something that felt personal and different from big e-commerce platforms like Etsy. The solution was an interest-based system where buyers reach out directly to artists, keeping the transaction human and private.

**Planning & approach:**
- Designed the UI in Figma before building
- Built a REST API with Node/Express and MongoDB
- Used Cloudinary for image uploads
- Implemented authentication with bcrypt and access tokens
- Managed global auth state with Zustand

**Future adds**
- Email notifications when a new interest is received
- Ability to reply to messages directly in the app
- To be able to save an object as a favorite

## Tech Stack

- **Frontend:** React, Styled Components, Zustand, React Router
- **Backend:** Node.js, Express, MongoDB, Mongoose
- **Other:** Cloudinary (image uploads), bcrypt (auth), Render (backend hosting), Netlify (frontend hosting)

## 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.
- 🌐 Frontend: https://manoamano.netlify.app
- 🔧 Backend: https://manomano-backend.onrender.com

25 changes: 25 additions & 0 deletions backend/config/cloudinary.js
Original file line number Diff line number Diff line change
@@ -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 };
13 changes: 13 additions & 0 deletions backend/models/Interest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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 },
read: { type: Boolean, default: false }
});

export const Interest = mongoose.model('Interest', interestSchema);
48 changes: 48 additions & 0 deletions backend/models/Product.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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', 'Jewelry', 'Art', 'Other']
},

color: {
type: String,
enum: ['','White', 'Black', 'Brown', 'Red', 'Blue', 'Green', 'Yellow', 'Pink', 'Purple', 'Orange', 'Grey', 'Multicolor'],
default: ''
},

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);
41 changes: 41 additions & 0 deletions backend/models/User.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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: ""
},

headerImage: {
type: String,
default: ""
},

accessToken: {
type: String,
default: () => crypto.randomBytes(128).toString("hex")
}
});

export default mongoose.model("User", UserSchema);
18 changes: 13 additions & 5 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.3.1",
"express": "^4.22.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^8.23.0",
"multer": "^2.0.2",
"multer-storage-cloudinary": "^4.0.0",
"nodemailer": "^8.0.1",
"nodemon": "^3.1.11"
}
}
}
136 changes: 136 additions & 0 deletions backend/routes/Auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import express from "express";
import bcrypt from "bcrypt";
import User from "../models/User.js";
import { parser } from '../config/cloudinary.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: 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) {
console.log('Register error:', 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
});
}
});

// 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, bio: user.bio, profileImage: user.profileImage, headerImage: user.headerImage } });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});

router.put('/me', parser.fields([{ name: 'profileImage', maxCount: 1 }, { name: 'headerImage', maxCount: 1 }]), async (req, res) => {
try {
const user = await User.findOne({ accessToken: req.headers.authorization });
if (!user) return res.status(401).json({ success: false, message: 'Unauthorized' });

const { name, bio } = req.body;
if (name) user.name = name;
if (bio) user.bio = bio;
if (req.file) user.profileImage = req.file.path;
if (req.files && req.files.headerImage) user.headerImage = req.files.headerImage[0].path;


await user.save();
res.status(200).json({ success: true, response: { name: user.name, bio: user.bio, profileImage: user.profileImage } });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});

router.get('/user/:userId', async (req, res) => {
try {
const user = await User.findById(req.params.userId).select('name bio profileImage headerImage');
if (!user) return res.status(404).json({ success: false, message: 'User not found' });
res.status(200).json({ success: true, response: user });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});




export default router;
Loading