From 7c6c1209699ab61aac3bdd9fc07d1f30c7c12dd9 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:12:56 -0400 Subject: [PATCH] phone auth, image uploading, address broken up --- backend/.gitignore | 6 + backend/middleware/auth.js | 29 ++-- backend/middleware/upload.js | 40 ++++++ backend/models/Item.js | 18 +++ backend/package-lock.json | 189 +++++++++++++++++++++++++- backend/package.json | 4 +- backend/routes/auth.js | 38 +++--- backend/routes/phone-auth.js | 24 ++-- backend/routes/users.js | 83 ++++++++++- backend/server.js | 8 +- frontend/src/components/AuthModal.tsx | 16 +-- frontend/src/contexts/AuthContext.tsx | 39 +++--- frontend/src/pages/CreateItem.tsx | 177 ++++++++++++++++++++---- frontend/src/pages/Profile.tsx | 177 +++++++++++++++++++----- frontend/src/services/api.ts | 57 ++++---- frontend/src/types/index.ts | 23 +++- frontend/src/utils/imageUrl.ts | 13 ++ 17 files changed, 759 insertions(+), 182 deletions(-) create mode 100644 backend/.gitignore create mode 100644 backend/middleware/upload.js create mode 100644 frontend/src/utils/imageUrl.ts diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..e449bfe --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.env.* +uploads/ +*.log +.DS_Store \ No newline at end of file diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 2e012ff..74184fd 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,35 +1,34 @@ -const jwt = require('jsonwebtoken'); -const { User } = require('../models'); // Import from models/index.js to get models with associations +const jwt = require("jsonwebtoken"); +const { User } = require("../models"); // Import from models/index.js to get models with associations const authenticateToken = async (req, res, next) => { - const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; + const authHeader = req.headers["authorization"]; + const token = authHeader && authHeader.split(" ")[1]; if (!token) { - return res.status(401).json({ error: 'Access token required' }); + return res.status(401).json({ error: "Access token required" }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET); - // Handle both 'userId' and 'id' for backward compatibility - const userId = decoded.userId || decoded.id; - + const userId = decoded.id; + if (!userId) { - return res.status(401).json({ error: 'Invalid token format' }); + return res.status(401).json({ error: "Invalid token format" }); } - + const user = await User.findByPk(userId); - + if (!user) { - return res.status(401).json({ error: 'User not found' }); + return res.status(401).json({ error: "User not found" }); } req.user = user; next(); } catch (error) { - console.error('Auth middleware error:', error); - return res.status(403).json({ error: 'Invalid or expired token' }); + console.error("Auth middleware error:", error); + return res.status(403).json({ error: "Invalid or expired token" }); } }; -module.exports = { authenticateToken }; \ No newline at end of file +module.exports = { authenticateToken }; diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js new file mode 100644 index 0000000..08deea7 --- /dev/null +++ b/backend/middleware/upload.js @@ -0,0 +1,40 @@ +const multer = require('multer'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +// Configure storage for profile images +const profileImageStorage = multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, path.join(__dirname, '../uploads/profiles')); + }, + filename: function (req, file, cb) { + // Generate unique filename: uuid + original extension + const uniqueId = uuidv4(); + const ext = path.extname(file.originalname); + cb(null, `${uniqueId}${ext}`); + } +}); + +// File filter to accept only images +const imageFileFilter = (req, file, cb) => { + // Accept images only + const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (allowedMimes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only JPEG, PNG, GIF and WebP images are allowed.'), false); + } +}; + +// Create multer upload middleware for profile images +const uploadProfileImage = multer({ + storage: profileImageStorage, + fileFilter: imageFileFilter, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB limit + } +}).single('profileImage'); + +module.exports = { + uploadProfileImage +}; \ No newline at end of file diff --git a/backend/models/Item.js b/backend/models/Item.js index cc11b3f..5203194 100644 --- a/backend/models/Item.js +++ b/backend/models/Item.js @@ -60,6 +60,24 @@ const Item = sequelize.define('Item', { type: DataTypes.STRING, allowNull: false }, + address1: { + type: DataTypes.STRING + }, + address2: { + type: DataTypes.STRING + }, + city: { + type: DataTypes.STRING + }, + state: { + type: DataTypes.STRING + }, + zipCode: { + type: DataTypes.STRING + }, + country: { + type: DataTypes.STRING + }, latitude: { type: DataTypes.DECIMAL(10, 8) }, diff --git a/backend/package-lock.json b/backend/package-lock.json index 82f9f6f..5bf340d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,9 +15,11 @@ "dotenv": "^17.2.0", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", "pg": "^8.16.3", "sequelize": "^6.37.7", - "sequelize-cli": "^6.6.3" + "sequelize-cli": "^6.6.3", + "uuid": "^11.1.0" }, "devDependencies": { "nodemon": "^3.1.10" @@ -134,6 +136,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -216,6 +224,23 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -382,6 +407,21 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -1209,6 +1249,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -1217,6 +1266,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -1241,6 +1302,67 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1601,6 +1723,20 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1806,6 +1942,15 @@ "node": ">= 10.0.0" } }, + "node_modules/sequelize/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", @@ -1951,6 +2096,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2109,6 +2271,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/umzug": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", @@ -2147,12 +2315,23 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/validator": { diff --git a/backend/package.json b/backend/package.json index b15277e..592960a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,9 +22,11 @@ "dotenv": "^17.2.0", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", "pg": "^8.16.3", "sequelize": "^6.37.7", - "sequelize-cli": "^6.6.3" + "sequelize-cli": "^6.6.3", + "uuid": "^11.1.0" }, "devDependencies": { "nodemon": "^3.1.10" diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 4738edb..6338827 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,20 +1,20 @@ -const express = require('express'); -const jwt = require('jsonwebtoken'); -const { User } = require('../models'); // Import from models/index.js to get models with associations +const express = require("express"); +const jwt = require("jsonwebtoken"); +const { User } = require("../models"); // Import from models/index.js to get models with associations const router = express.Router(); -router.post('/register', async (req, res) => { +router.post("/register", async (req, res) => { try { const { username, email, password, firstName, lastName, phone } = req.body; const existingUser = await User.findOne({ where: { - [require('sequelize').Op.or]: [{ email }, { username }] - } + [require("sequelize").Op.or]: [{ email }, { username }], + }, }); if (existingUser) { - return res.status(400).json({ error: 'User already exists' }); + return res.status(400).json({ error: "User already exists" }); } const user = await User.create({ @@ -23,11 +23,11 @@ router.post('/register', async (req, res) => { password, firstName, lastName, - phone + phone, }); - const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { - expiresIn: '7d' + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { + expiresIn: "7d", }); res.status(201).json({ @@ -36,27 +36,27 @@ router.post('/register', async (req, res) => { username: user.username, email: user.email, firstName: user.firstName, - lastName: user.lastName + lastName: user.lastName, }, - token + token, }); } catch (error) { res.status(500).json({ error: error.message }); } }); -router.post('/login', async (req, res) => { +router.post("/login", async (req, res) => { try { const { email, password } = req.body; const user = await User.findOne({ where: { email } }); if (!user || !(await user.comparePassword(password))) { - return res.status(401).json({ error: 'Invalid credentials' }); + return res.status(401).json({ error: "Invalid credentials" }); } - const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { - expiresIn: '7d' + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { + expiresIn: "7d", }); res.json({ @@ -65,13 +65,13 @@ router.post('/login', async (req, res) => { username: user.username, email: user.email, firstName: user.firstName, - lastName: user.lastName + lastName: user.lastName, }, - token + token, }); } catch (error) { res.status(500).json({ error: error.message }); } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/routes/phone-auth.js b/backend/routes/phone-auth.js index 7411526..8f7a6b3 100644 --- a/backend/routes/phone-auth.js +++ b/backend/routes/phone-auth.js @@ -59,31 +59,25 @@ router.post("/verify-code", async (req, res) => { const storedData = verificationCodes.get(phoneNumber); if (!storedData) { - return res - .status(400) - .json({ - message: "No verification code found. Please request a new one.", - }); + return res.status(400).json({ + message: "No verification code found. Please request a new one.", + }); } // Check if code expired (10 minutes) if (Date.now() - storedData.createdAt > 10 * 60 * 1000) { verificationCodes.delete(phoneNumber); - return res - .status(400) - .json({ - message: "Verification code expired. Please request a new one.", - }); + return res.status(400).json({ + message: "Verification code expired. Please request a new one.", + }); } // Check attempts if (storedData.attempts >= 3) { verificationCodes.delete(phoneNumber); - return res - .status(400) - .json({ - message: "Too many failed attempts. Please request a new code.", - }); + return res.status(400).json({ + message: "Too many failed attempts. Please request a new code.", + }); } if (storedData.code !== code) { diff --git a/backend/routes/users.js b/backend/routes/users.js index 9e2ebf8..c2295ab 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,6 +1,9 @@ const express = require('express'); const { User } = require('../models'); // Import from models/index.js to get models with associations const { authenticateToken } = require('../middleware/auth'); +const { uploadProfileImage } = require('../middleware/upload'); +const fs = require('fs').promises; +const path = require('path'); const router = express.Router(); router.get('/profile', authenticateToken, async (req, res) => { @@ -32,15 +35,38 @@ router.get('/:id', async (req, res) => { router.put('/profile', authenticateToken, async (req, res) => { try { - const { firstName, lastName, phone, address, profileImage } = req.body; + const { + firstName, + lastName, + email, + phone, + address1, + address2, + city, + state, + zipCode, + country + } = req.body; - await req.user.update({ + // Build update object, excluding empty email + const updateData = { firstName, lastName, phone, - address, - profileImage - }); + address1, + address2, + city, + state, + zipCode, + country + }; + + // Only include email if it's not empty + if (email && email.trim() !== '') { + updateData.email = email; + } + + await req.user.update(updateData); const updatedUser = await User.findByPk(req.user.id, { attributes: { exclude: ['password'] } @@ -48,8 +74,53 @@ router.put('/profile', authenticateToken, async (req, res) => { res.json(updatedUser); } catch (error) { - res.status(500).json({ error: error.message }); + console.error('Profile update error:', error); + res.status(500).json({ + error: error.message, + details: error.errors ? error.errors.map(e => ({ field: e.path, message: e.message })) : undefined + }); } }); +// Upload profile image endpoint +router.post('/profile/image', authenticateToken, (req, res) => { + uploadProfileImage(req, res, async (err) => { + if (err) { + console.error('Upload error:', err); + return res.status(400).json({ error: err.message }); + } + + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + try { + // Delete old profile image if exists + const user = await User.findByPk(req.user.id); + if (user.profileImage) { + const oldImagePath = path.join(__dirname, '../uploads/profiles', user.profileImage); + try { + await fs.unlink(oldImagePath); + } catch (unlinkErr) { + console.error('Error deleting old image:', unlinkErr); + } + } + + // Update user with new image filename + await user.update({ + profileImage: req.file.filename + }); + + res.json({ + message: 'Profile image uploaded successfully', + filename: req.file.filename, + imageUrl: `/uploads/profiles/${req.file.filename}` + }); + } catch (error) { + console.error('Database update error:', error); + res.status(500).json({ error: 'Failed to update profile image' }); + } + }); +}); + module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 100ead0..67c792d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,7 @@ require('dotenv').config({ const express = require('express'); const cors = require('cors'); const bodyParser = require('body-parser'); +const path = require('path'); const { sequelize } = require('./models'); // Import from models/index.js to ensure associations are loaded const authRoutes = require('./routes/auth'); @@ -20,8 +21,11 @@ const messageRoutes = require('./routes/messages'); const app = express(); app.use(cors()); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json({ limit: '5mb' })); +app.use(bodyParser.urlencoded({ extended: true, limit: '5mb' })); + +// Serve static files from uploads directory +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/api/auth', authRoutes); app.use('/api/auth/phone', phoneAuthRoutes); diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx index b28c163..4624a65 100644 --- a/frontend/src/components/AuthModal.tsx +++ b/frontend/src/components/AuthModal.tsx @@ -134,16 +134,16 @@ const AuthModal: React.FC = ({ console.log("Current mode:", mode); console.log("First Name:", firstName); console.log("Last Name:", lastName); - + const requestBody = { phoneNumber: cleanPhone, code: verificationCode, firstName: mode === "signup" ? firstName : undefined, lastName: mode === "signup" ? lastName : undefined, }; - + console.log("Request body:", requestBody); - + const response = await fetch( "http://localhost:5001/api/auth/phone/verify-code", { @@ -171,23 +171,23 @@ const AuthModal: React.FC = ({ // Store token and user data console.log("Storing token:", data.token); localStorage.setItem("token", data.token); - + // Verify token was stored const storedToken = localStorage.getItem("token"); console.log("Token stored successfully:", !!storedToken); console.log("User data:", data.user); - + // Update auth context with the user data updateUser(data.user); - + // Close modal and reset state onHide(); resetModal(); - + // Force a page reload to ensure auth state is properly initialized // This is needed because AuthContext's useEffect only runs once on mount setTimeout(() => { - window.location.href = '/'; + window.location.href = "/"; }, 100); } catch (err: any) { console.error("Verification error:", err); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 81eb31c..16b368a 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,6 +1,12 @@ -import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react'; -import { User } from '../types'; -import { authAPI, userAPI } from '../services/api'; +import React, { + createContext, + useState, + useContext, + useEffect, + ReactNode, +} from "react"; +import { User } from "../types"; +import { authAPI, userAPI } from "../services/api"; interface AuthContextType { user: User | null; @@ -16,7 +22,7 @@ const AuthContext = createContext(undefined); export const useAuth = () => { const context = useContext(AuthContext); if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); + throw new Error("useAuth must be used within an AuthProvider"); } return context; }; @@ -30,41 +36,38 @@ export const AuthProvider: React.FC = ({ children }) => { const [loading, setLoading] = useState(true); useEffect(() => { - const token = localStorage.getItem('token'); + const token = localStorage.getItem("token"); if (token) { - console.log('AuthContext: Found token, fetching profile...'); - userAPI.getProfile() - .then(response => { - console.log('AuthContext: Profile loaded', response.data); + userAPI + .getProfile() + .then((response) => { setUser(response.data); }) .catch((error) => { - console.error('AuthContext: Failed to load profile', error); - localStorage.removeItem('token'); + localStorage.removeItem("token"); }) .finally(() => { setLoading(false); }); } else { - console.log('AuthContext: No token found'); setLoading(false); } }, []); const login = async (email: string, password: string) => { const response = await authAPI.login({ email, password }); - localStorage.setItem('token', response.data.token); + localStorage.setItem("token", response.data.token); setUser(response.data.user); }; const register = async (data: any) => { const response = await authAPI.register(data); - localStorage.setItem('token', response.data.token); + localStorage.setItem("token", response.data.token); setUser(response.data.user); }; const logout = () => { - localStorage.removeItem('token'); + localStorage.removeItem("token"); setUser(null); }; @@ -73,8 +76,10 @@ export const AuthProvider: React.FC = ({ children }) => { }; return ( - + {children} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/CreateItem.tsx b/frontend/src/pages/CreateItem.tsx index 94f1c6b..9835d6c 100644 --- a/frontend/src/pages/CreateItem.tsx +++ b/frontend/src/pages/CreateItem.tsx @@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import api from "../services/api"; import AvailabilityCalendar from "../components/AvailabilityCalendar"; -import AddressAutocomplete from "../components/AddressAutocomplete"; interface ItemFormData { name: string; @@ -18,6 +17,12 @@ interface ItemFormData { pricePerDay?: number; replacementCost: number; location: string; + address1: string; + address2: string; + city: string; + state: string; + zipCode: string; + country: string; latitude?: number; longitude?: number; rules?: string; @@ -49,6 +54,12 @@ const CreateItem: React.FC = () => { pricePerDay: undefined, replacementCost: 0, location: "", + address1: "", + address2: "", + city: "", + state: "", + zipCode: "", + country: "", minimumRentalDays: 1, needsTraining: false, unavailablePeriods: [], @@ -73,8 +84,21 @@ const CreateItem: React.FC = () => { // In production, you'd upload to a service like S3 const imageUrls = imagePreviews; + // Construct location from address components + const locationParts = [ + formData.address1, + formData.address2, + formData.city, + formData.state, + formData.zipCode, + formData.country + ].filter(part => part && part.trim()); + + const location = locationParts.join(', '); + const response = await api.post("/items", { ...formData, + location, images: imageUrls, }); navigate(`/items/${response.data.id}`); @@ -271,23 +295,99 @@ const CreateItem: React.FC = () => { +
Location *
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
-
@@ -305,7 +405,10 @@ const CreateItem: React.FC = () => { />
@@ -325,24 +428,27 @@ const CreateItem: React.FC = () => { Local Delivery {formData.localDeliveryAvailable && ( - (Delivery Radius: + (Delivery Radius: e.stopPropagation()} placeholder="25" min="1" max="100" - style={{ width: '60px' }} + style={{ width: "60px" }} /> miles) )} -
You deliver and then pick-up the item when the rental period ends
+
+ You deliver and then pick-up the item when the rental + period ends +
@@ -373,7 +479,9 @@ const CreateItem: React.FC = () => { htmlFor="inPlaceUseAvailable" > In-Place Use -
They use at your location
+
+ They use at your location +
@@ -387,7 +495,9 @@ const CreateItem: React.FC = () => { - )} + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
{editing ? ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 592668f..2effec3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,16 +1,16 @@ -import axios from 'axios'; +import axios from "axios"; const API_BASE_URL = process.env.REACT_APP_API_URL; const api = axios.create({ baseURL: API_BASE_URL, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, }); api.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); + const token = localStorage.getItem("token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -22,12 +22,12 @@ api.interceptors.response.use( (error) => { if (error.response?.status === 401) { // Only redirect to login if we have a token (user was logged in) - const token = localStorage.getItem('token'); - + const token = localStorage.getItem("token"); + if (token) { // User was logged in but token expired/invalid - localStorage.removeItem('token'); - window.location.href = '/login'; + localStorage.removeItem("token"); + window.location.href = "/login"; } // For non-authenticated users, just reject the error without redirecting // Let individual components handle 401 errors as needed @@ -37,42 +37,47 @@ api.interceptors.response.use( ); export const authAPI = { - register: (data: any) => api.post('/auth/register', data), - login: (data: any) => api.post('/auth/login', data), + register: (data: any) => api.post("/auth/register", data), + login: (data: any) => api.post("/auth/login", data), }; export const userAPI = { - getProfile: () => api.get('/users/profile'), - updateProfile: (data: any) => api.put('/users/profile', data), - getPublicProfile: (userId: string) => api.get(`/users/${userId}`), + getProfile: () => api.get("/users/profile"), + updateProfile: (data: any) => api.put("/users/profile", data), + uploadProfileImage: (formData: FormData) => + api.post("/users/profile/image", formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }), + getPublicProfile: (id: string) => api.get(`/users/${id}`), }; export const itemAPI = { - getItems: (params?: any) => api.get('/items', { params }), + getItems: (params?: any) => api.get("/items", { params }), getItem: (id: string) => api.get(`/items/${id}`), - createItem: (data: any) => api.post('/items', data), + createItem: (data: any) => api.post("/items", data), updateItem: (id: string, data: any) => api.put(`/items/${id}`, data), deleteItem: (id: string) => api.delete(`/items/${id}`), - getRecommendations: () => api.get('/items/recommendations'), + getRecommendations: () => api.get("/items/recommendations"), }; export const rentalAPI = { - createRental: (data: any) => api.post('/rentals', data), - getMyRentals: () => api.get('/rentals/my-rentals'), - getMyListings: () => api.get('/rentals/my-listings'), - updateRentalStatus: (id: string, status: string) => + createRental: (data: any) => api.post("/rentals", data), + getMyRentals: () => api.get("/rentals/my-rentals"), + getMyListings: () => api.get("/rentals/my-listings"), + updateRentalStatus: (id: string, status: string) => api.put(`/rentals/${id}/status`, { status }), - addReview: (id: string, data: any) => - api.post(`/rentals/${id}/review`, data), + addReview: (id: string, data: any) => api.post(`/rentals/${id}/review`, data), }; export const messageAPI = { - getMessages: () => api.get('/messages'), - getSentMessages: () => api.get('/messages/sent'), + getMessages: () => api.get("/messages"), + getSentMessages: () => api.get("/messages/sent"), getMessage: (id: string) => api.get(`/messages/${id}`), - sendMessage: (data: any) => api.post('/messages', data), + sendMessage: (data: any) => api.post("/messages", data), markAsRead: (id: string) => api.put(`/messages/${id}/read`), - getUnreadCount: () => api.get('/messages/unread/count'), + getUnreadCount: () => api.get("/messages/unread/count"), }; -export default api; \ No newline at end of file +export default api; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a8a0d3f..6f149b9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,7 +5,12 @@ export interface User { firstName: string; lastName: string; phone?: string; - address?: string; + address1?: string; + address2?: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; profileImage?: string; isVerified: boolean; } @@ -42,10 +47,16 @@ export interface Item { pricePerMonth?: number; replacementCost: number; location: string; + address1?: string; + address2?: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; latitude?: number; longitude?: number; images: string[]; - condition: 'excellent' | 'good' | 'fair' | 'poor'; + condition: "excellent" | "good" | "fair" | "poor"; availability: boolean; specifications: Record; rules?: string; @@ -73,9 +84,9 @@ export interface Rental { startDate: string; endDate: string; totalAmount: number; - status: 'pending' | 'confirmed' | 'active' | 'completed' | 'cancelled'; - paymentStatus: 'pending' | 'paid' | 'refunded'; - deliveryMethod: 'pickup' | 'delivery'; + status: "pending" | "confirmed" | "active" | "completed" | "cancelled"; + paymentStatus: "pending" | "paid" | "refunded"; + deliveryMethod: "pickup" | "delivery"; deliveryAddress?: string; notes?: string; rating?: number; @@ -86,4 +97,4 @@ export interface Rental { owner?: User; createdAt: string; updatedAt: string; -} \ No newline at end of file +} diff --git a/frontend/src/utils/imageUrl.ts b/frontend/src/utils/imageUrl.ts new file mode 100644 index 0000000..366e974 --- /dev/null +++ b/frontend/src/utils/imageUrl.ts @@ -0,0 +1,13 @@ +export const getImageUrl = (imagePath: string): string => { + // Get the base URL without /api + const apiUrl = process.env.REACT_APP_API_URL || ''; + const baseUrl = apiUrl.replace('/api', ''); + + // If imagePath already includes the full path, use it + if (imagePath.startsWith('/uploads/')) { + return `${baseUrl}${imagePath}`; + } + + // Otherwise, construct the full path + return `${baseUrl}/uploads/profiles/${imagePath}`; +}; \ No newline at end of file