Initial commit - Rentall App
- Full-stack rental marketplace application - React frontend with TypeScript - Node.js/Express backend with JWT authentication - Features: item listings, rental requests, calendar availability, user profiles
This commit is contained in:
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# Production
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Backend specific
|
||||||
|
backend/node_modules
|
||||||
|
backend/.env
|
||||||
|
backend/dist
|
||||||
|
backend/logs
|
||||||
|
|
||||||
|
# Frontend specific
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/.env
|
||||||
|
frontend/build
|
||||||
|
frontend/.env.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
temp/
|
||||||
21
backend/config/database.js
Normal file
21
backend/config/database.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
|
||||||
|
const sequelize = new Sequelize(
|
||||||
|
process.env.DB_NAME,
|
||||||
|
process.env.DB_USER,
|
||||||
|
process.env.DB_PASSWORD,
|
||||||
|
{
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
dialect: 'postgres',
|
||||||
|
logging: false,
|
||||||
|
pool: {
|
||||||
|
max: 5,
|
||||||
|
min: 0,
|
||||||
|
acquire: 30000,
|
||||||
|
idle: 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = sequelize;
|
||||||
27
backend/middleware/auth.js
Normal file
27
backend/middleware/auth.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Access token required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
const user = await User.findByPk(decoded.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { authenticateToken };
|
||||||
110
backend/models/Item.js
Normal file
110
backend/models/Item.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const Item = sequelize.define('Item', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.ARRAY(DataTypes.STRING),
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
pickUpAvailable: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
localDeliveryAvailable: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
localDeliveryRadius: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
validate: {
|
||||||
|
min: 1,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shippingAvailable: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
inPlaceUseAvailable: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
pricePerHour: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2)
|
||||||
|
},
|
||||||
|
pricePerDay: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2)
|
||||||
|
},
|
||||||
|
replacementCost: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
latitude: {
|
||||||
|
type: DataTypes.DECIMAL(10, 8)
|
||||||
|
},
|
||||||
|
longitude: {
|
||||||
|
type: DataTypes.DECIMAL(11, 8)
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
type: DataTypes.ARRAY(DataTypes.STRING),
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
specifications: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
type: DataTypes.TEXT
|
||||||
|
},
|
||||||
|
minimumRentalDays: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 1
|
||||||
|
},
|
||||||
|
maximumRentalDays: {
|
||||||
|
type: DataTypes.INTEGER
|
||||||
|
},
|
||||||
|
needsTraining: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
unavailablePeriods: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
ownerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Item;
|
||||||
76
backend/models/Rental.js
Normal file
76
backend/models/Rental.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const Rental = sequelize.define('Rental', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
itemId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Items',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renterId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ownerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
totalAmount: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('pending', 'confirmed', 'active', 'completed', 'cancelled'),
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
paymentStatus: {
|
||||||
|
type: DataTypes.ENUM('pending', 'paid', 'refunded'),
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
deliveryMethod: {
|
||||||
|
type: DataTypes.ENUM('pickup', 'delivery'),
|
||||||
|
defaultValue: 'pickup'
|
||||||
|
},
|
||||||
|
deliveryAddress: {
|
||||||
|
type: DataTypes.TEXT
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
validate: {
|
||||||
|
min: 1,
|
||||||
|
max: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
type: DataTypes.TEXT
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Rental;
|
||||||
66
backend/models/User.js
Normal file
66
backend/models/User.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const User = sequelize.define('User', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
unique: true,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
unique: true,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
isEmail: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
firstName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: DataTypes.TEXT
|
||||||
|
},
|
||||||
|
profileImage: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
isVerified: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
hooks: {
|
||||||
|
beforeCreate: async (user) => {
|
||||||
|
user.password = await bcrypt.hash(user.password, 10);
|
||||||
|
},
|
||||||
|
beforeUpdate: async (user) => {
|
||||||
|
if (user.changed('password')) {
|
||||||
|
user.password = await bcrypt.hash(user.password, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
User.prototype.comparePassword = async function(password) {
|
||||||
|
return bcrypt.compare(password, this.password);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = User;
|
||||||
22
backend/models/index.js
Normal file
22
backend/models/index.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const sequelize = require('../config/database');
|
||||||
|
const User = require('./User');
|
||||||
|
const Item = require('./Item');
|
||||||
|
const Rental = require('./Rental');
|
||||||
|
|
||||||
|
User.hasMany(Item, { as: 'ownedItems', foreignKey: 'ownerId' });
|
||||||
|
Item.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
||||||
|
|
||||||
|
User.hasMany(Rental, { as: 'rentalsAsRenter', foreignKey: 'renterId' });
|
||||||
|
User.hasMany(Rental, { as: 'rentalsAsOwner', foreignKey: 'ownerId' });
|
||||||
|
|
||||||
|
Item.hasMany(Rental, { as: 'rentals', foreignKey: 'itemId' });
|
||||||
|
Rental.belongsTo(Item, { as: 'item', foreignKey: 'itemId' });
|
||||||
|
Rental.belongsTo(User, { as: 'renter', foreignKey: 'renterId' });
|
||||||
|
Rental.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sequelize,
|
||||||
|
User,
|
||||||
|
Item,
|
||||||
|
Rental
|
||||||
|
};
|
||||||
2364
backend/package-lock.json
generated
Normal file
2364
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
backend/package.json
Normal file
28
backend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"sequelize": "^6.37.7",
|
||||||
|
"sequelize-cli": "^6.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
backend/routes/auth.js
Normal file
77
backend/routes/auth.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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) => {
|
||||||
|
try {
|
||||||
|
const { username, email, password, firstName, lastName, phone } = req.body;
|
||||||
|
|
||||||
|
const existingUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
[require('sequelize').Op.or]: [{ email }, { username }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({ error: 'User already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.create({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: '7d'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName
|
||||||
|
},
|
||||||
|
token
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: '7d'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName
|
||||||
|
},
|
||||||
|
token
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
164
backend/routes/items.js
Normal file
164
backend/routes/items.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const { Item, User, Rental } = require('../models'); // Import from models/index.js to get models with associations
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
tags,
|
||||||
|
isPortable,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
location,
|
||||||
|
search,
|
||||||
|
page = 1,
|
||||||
|
limit = 20
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (tags) {
|
||||||
|
const tagsArray = Array.isArray(tags) ? tags : [tags];
|
||||||
|
where.tags = { [Op.overlap]: tagsArray };
|
||||||
|
}
|
||||||
|
if (isPortable !== undefined) where.isPortable = isPortable === 'true';
|
||||||
|
if (minPrice || maxPrice) {
|
||||||
|
where.pricePerDay = {};
|
||||||
|
if (minPrice) where.pricePerDay[Op.gte] = minPrice;
|
||||||
|
if (maxPrice) where.pricePerDay[Op.lte] = maxPrice;
|
||||||
|
}
|
||||||
|
if (location) where.location = { [Op.iLike]: `%${location}%` };
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ name: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows } = await Item.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
items: rows,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: parseInt(page),
|
||||||
|
totalItems: count
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/recommendations', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userRentals = await Rental.findAll({
|
||||||
|
where: { renterId: req.user.id },
|
||||||
|
include: [{ model: Item, as: 'item' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const rentedTags = userRentals.reduce((tags, rental) => {
|
||||||
|
return [...tags, ...(rental.item.tags || [])];
|
||||||
|
}, []);
|
||||||
|
const uniqueTags = [...new Set(rentedTags)];
|
||||||
|
|
||||||
|
const recommendations = await Item.findAll({
|
||||||
|
where: {
|
||||||
|
tags: { [Op.overlap]: uniqueTags },
|
||||||
|
availability: true
|
||||||
|
},
|
||||||
|
limit: 10,
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(recommendations);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const item = await Item.findByPk(req.params.id, {
|
||||||
|
include: [{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return res.status(404).json({ error: 'Item not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(item);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const item = await Item.create({
|
||||||
|
...req.body,
|
||||||
|
ownerId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemWithOwner = await Item.findByPk(item.id, {
|
||||||
|
include: [{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(itemWithOwner);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const item = await Item.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return res.status(404).json({ error: 'Item not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ownerId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await item.update(req.body);
|
||||||
|
|
||||||
|
const updatedItem = await Item.findByPk(item.id, {
|
||||||
|
include: [{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedItem);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const item = await Item.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return res.status(404).json({ error: 'Item not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ownerId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await item.destroy();
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
156
backend/routes/rentals.js
Normal file
156
backend/routes/rentals.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const { Rental, Item, User } = require('../models'); // Import from models/index.js to get models with associations
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/my-rentals', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rentals = await Rental.findAll({
|
||||||
|
where: { renterId: req.user.id },
|
||||||
|
include: [
|
||||||
|
{ model: Item, as: 'item' },
|
||||||
|
{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(rentals);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/my-listings', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rentals = await Rental.findAll({
|
||||||
|
where: { ownerId: req.user.id },
|
||||||
|
include: [
|
||||||
|
{ model: Item, as: 'item' },
|
||||||
|
{ model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] }
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(rentals);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { itemId, startDate, endDate, deliveryMethod, deliveryAddress, notes } = req.body;
|
||||||
|
|
||||||
|
const item = await Item.findByPk(itemId);
|
||||||
|
if (!item) {
|
||||||
|
return res.status(404).json({ error: 'Item not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.availability) {
|
||||||
|
return res.status(400).json({ error: 'Item is not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlappingRental = await Rental.findOne({
|
||||||
|
where: {
|
||||||
|
itemId,
|
||||||
|
status: { [Op.in]: ['confirmed', 'active'] },
|
||||||
|
[Op.or]: [
|
||||||
|
{
|
||||||
|
startDate: { [Op.between]: [startDate, endDate] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
endDate: { [Op.between]: [startDate, endDate] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (overlappingRental) {
|
||||||
|
return res.status(400).json({ error: 'Item is already booked for these dates' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rentalDays = Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24));
|
||||||
|
const totalAmount = rentalDays * (item.pricePerDay || 0);
|
||||||
|
|
||||||
|
const rental = await Rental.create({
|
||||||
|
itemId,
|
||||||
|
renterId: req.user.id,
|
||||||
|
ownerId: item.ownerId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
totalAmount,
|
||||||
|
deliveryMethod,
|
||||||
|
deliveryAddress,
|
||||||
|
notes
|
||||||
|
});
|
||||||
|
|
||||||
|
const rentalWithDetails = await Rental.findByPk(rental.id, {
|
||||||
|
include: [
|
||||||
|
{ model: Item, as: 'item' },
|
||||||
|
{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] },
|
||||||
|
{ model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(rentalWithDetails);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.body;
|
||||||
|
const rental = await Rental.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!rental) {
|
||||||
|
return res.status(404).json({ error: 'Rental not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await rental.update({ status });
|
||||||
|
|
||||||
|
const updatedRental = await Rental.findByPk(rental.id, {
|
||||||
|
include: [
|
||||||
|
{ model: Item, as: 'item' },
|
||||||
|
{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] },
|
||||||
|
{ model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedRental);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/review', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rating, review } = req.body;
|
||||||
|
const rental = await Rental.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!rental) {
|
||||||
|
return res.status(404).json({ error: 'Rental not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rental.renterId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Only renters can leave reviews' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rental.status !== 'completed') {
|
||||||
|
return res.status(400).json({ error: 'Can only review completed rentals' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await rental.update({ rating, review });
|
||||||
|
|
||||||
|
res.json(rental);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
38
backend/routes/users.js
Normal file
38
backend/routes/users.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { User } = require('../models'); // Import from models/index.js to get models with associations
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/profile', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findByPk(req.user.id, {
|
||||||
|
attributes: { exclude: ['password'] }
|
||||||
|
});
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/profile', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { firstName, lastName, phone, address } = req.body;
|
||||||
|
|
||||||
|
await req.user.update({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
address
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedUser = await User.findByPk(req.user.id, {
|
||||||
|
attributes: { exclude: ['password'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedUser);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
36
backend/server.js
Normal file
36
backend/server.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const bodyParser = require('body-parser');
|
||||||
|
const { sequelize } = require('./models'); // Import from models/index.js to ensure associations are loaded
|
||||||
|
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const userRoutes = require('./routes/users');
|
||||||
|
const itemRoutes = require('./routes/items');
|
||||||
|
const rentalRoutes = require('./routes/rentals');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/items', itemRoutes);
|
||||||
|
app.use('/api/rentals', rentalRoutes);
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({ message: 'Rentall API is running!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 5000;
|
||||||
|
|
||||||
|
sequelize.sync({ alter: true }).then(() => {
|
||||||
|
console.log('Database synced');
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Unable to sync database:', err);
|
||||||
|
});
|
||||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Getting Started with Create React App
|
||||||
|
|
||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.\
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `npm test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `npm run eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
16359
frontend/package-lock.json
generated
Normal file
16359
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
frontend/package.json
Normal file
48
frontend/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/node": "^16.18.126",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"bootstrap": "^5.3.7",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^6.30.1",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
32
frontend/public/index.html
Normal file
32
frontend/public/index.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Rentall - Rent gym equipment, tools, and musical instruments from your neighbors"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<title>Rentall - Equipment & Tool Rental Marketplace</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
31
frontend/src/App.css
Normal file
31
frontend/src/App.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.App {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand i {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-toggle::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
9
frontend/src/App.test.tsx
Normal file
9
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
84
frontend/src/App.tsx
Normal file
84
frontend/src/App.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import Home from './pages/Home';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Register from './pages/Register';
|
||||||
|
import ItemList from './pages/ItemList';
|
||||||
|
import ItemDetail from './pages/ItemDetail';
|
||||||
|
import EditItem from './pages/EditItem';
|
||||||
|
import RentItem from './pages/RentItem';
|
||||||
|
import CreateItem from './pages/CreateItem';
|
||||||
|
import MyRentals from './pages/MyRentals';
|
||||||
|
import MyListings from './pages/MyListings';
|
||||||
|
import Profile from './pages/Profile';
|
||||||
|
import PrivateRoute from './components/PrivateRoute';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<Navbar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/items" element={<ItemList />} />
|
||||||
|
<Route path="/items/:id" element={<ItemDetail />} />
|
||||||
|
<Route
|
||||||
|
path="/items/:id/edit"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<EditItem />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/items/:id/rent"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<RentItem />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/create-item"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<CreateItem />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my-rentals"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<MyRentals />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my-listings"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<MyListings />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Profile />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
159
frontend/src/components/AddressAutocomplete.tsx
Normal file
159
frontend/src/components/AddressAutocomplete.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface AddressSuggestion {
|
||||||
|
place_id: string;
|
||||||
|
display_name: string;
|
||||||
|
lat: string;
|
||||||
|
lon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddressAutocompleteProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string, lat?: number, lon?: number) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Address",
|
||||||
|
required = false,
|
||||||
|
className = "form-control",
|
||||||
|
id,
|
||||||
|
name
|
||||||
|
}) => {
|
||||||
|
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceTimer = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// Handle clicking outside to close suggestions
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAddressSuggestions = async (query: string) => {
|
||||||
|
if (query.length < 3) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Using Nominatim API (OpenStreetMap) for free geocoding
|
||||||
|
// In production, you might want to use Google Places API or another service
|
||||||
|
const response = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/search?` +
|
||||||
|
`q=${encodeURIComponent(query)}&` +
|
||||||
|
`format=json&` +
|
||||||
|
`limit=5&` +
|
||||||
|
`countrycodes=us`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSuggestions(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching address suggestions:', error);
|
||||||
|
setSuggestions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
onChange(newValue);
|
||||||
|
setShowSuggestions(true);
|
||||||
|
|
||||||
|
// Debounce the API call
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer.current = window.setTimeout(() => {
|
||||||
|
fetchAddressSuggestions(newValue);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestionClick = (suggestion: AddressSuggestion) => {
|
||||||
|
onChange(
|
||||||
|
suggestion.display_name,
|
||||||
|
parseFloat(suggestion.lat),
|
||||||
|
parseFloat(suggestion.lon)
|
||||||
|
);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="position-relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={className}
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setShowSuggestions(true)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showSuggestions && (suggestions.length > 0 || loading) && (
|
||||||
|
<div
|
||||||
|
className="position-absolute w-100 bg-white border rounded-bottom shadow-sm"
|
||||||
|
style={{ top: '100%', zIndex: 1000, maxHeight: '300px', overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-2 text-center text-muted">
|
||||||
|
<small>Searching addresses...</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
suggestions.map((suggestion) => (
|
||||||
|
<div
|
||||||
|
key={suggestion.place_id}
|
||||||
|
className="p-2 border-bottom cursor-pointer"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.classList.add('bg-light')}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.classList.remove('bg-light')}
|
||||||
|
>
|
||||||
|
<small className="d-block text-truncate">
|
||||||
|
{suggestion.display_name}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddressAutocomplete;
|
||||||
596
frontend/src/components/AvailabilityCalendar.tsx
Normal file
596
frontend/src/components/AvailabilityCalendar.tsx
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface UnavailablePeriod {
|
||||||
|
id: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
isRentalSelection?: boolean;
|
||||||
|
isAcceptedRental?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvailabilityCalendarProps {
|
||||||
|
unavailablePeriods: UnavailablePeriod[];
|
||||||
|
onPeriodsChange: (periods: UnavailablePeriod[]) => void;
|
||||||
|
priceType?: 'hour' | 'day';
|
||||||
|
isRentalMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewType = 'month' | 'week' | 'day';
|
||||||
|
|
||||||
|
const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
|
||||||
|
unavailablePeriods,
|
||||||
|
onPeriodsChange,
|
||||||
|
priceType = 'hour',
|
||||||
|
isRentalMode = false
|
||||||
|
}) => {
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [viewType, setViewType] = useState<ViewType>('month');
|
||||||
|
const [selectionStart, setSelectionStart] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// Reset to month view if priceType is day and current view is week/day
|
||||||
|
useEffect(() => {
|
||||||
|
if (priceType === 'day' && (viewType === 'week' || viewType === 'day')) {
|
||||||
|
setViewType('month');
|
||||||
|
}
|
||||||
|
}, [priceType]);
|
||||||
|
|
||||||
|
const getDaysInMonth = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const daysInMonth = lastDay.getDate();
|
||||||
|
const startingDayOfWeek = firstDay.getDay();
|
||||||
|
|
||||||
|
const days: (Date | null)[] = [];
|
||||||
|
|
||||||
|
// Add empty cells for days before month starts
|
||||||
|
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||||
|
days.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all days in month
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push(new Date(year, month, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWeekDays = (date: Date) => {
|
||||||
|
const startOfWeek = new Date(date);
|
||||||
|
const day = startOfWeek.getDay();
|
||||||
|
const diff = startOfWeek.getDate() - day;
|
||||||
|
startOfWeek.setDate(diff);
|
||||||
|
|
||||||
|
const days: Date[] = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const day = new Date(startOfWeek);
|
||||||
|
day.setDate(startOfWeek.getDate() + i);
|
||||||
|
days.push(day);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateInPeriod = (date: Date, period: UnavailablePeriod) => {
|
||||||
|
const start = new Date(period.startDate);
|
||||||
|
const end = new Date(period.endDate);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
const checkDate = new Date(date);
|
||||||
|
checkDate.setHours(0, 0, 0, 0);
|
||||||
|
return checkDate >= start && checkDate <= end;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateUnavailable = (date: Date) => {
|
||||||
|
return unavailablePeriods.some(period => {
|
||||||
|
const start = new Date(period.startDate);
|
||||||
|
const end = new Date(period.endDate);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
const checkDate = new Date(date);
|
||||||
|
checkDate.setHours(0, 0, 0, 0);
|
||||||
|
return checkDate >= start && checkDate <= end;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateFullyUnavailable = (date: Date) => {
|
||||||
|
// Check if there's a period that covers the entire day without specific times
|
||||||
|
const hasFullDayPeriod = unavailablePeriods.some(period => {
|
||||||
|
const start = new Date(period.startDate);
|
||||||
|
const end = new Date(period.endDate);
|
||||||
|
const checkDate = new Date(date);
|
||||||
|
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
end.setHours(0, 0, 0, 0);
|
||||||
|
checkDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return checkDate >= start && checkDate <= end && !period.startTime && !period.endTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasFullDayPeriod) return true;
|
||||||
|
|
||||||
|
// Check if all 24 hours are covered by hour-specific periods
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
const hoursWithPeriods = new Set<number>();
|
||||||
|
|
||||||
|
unavailablePeriods.forEach(period => {
|
||||||
|
const periodDateStr = new Date(period.startDate).toISOString().split('T')[0];
|
||||||
|
if (periodDateStr === dateStr && period.startTime && period.endTime) {
|
||||||
|
const startHour = parseInt(period.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(period.endTime.split(':')[0]);
|
||||||
|
for (let h = startHour; h <= endHour; h++) {
|
||||||
|
hoursWithPeriods.add(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return hoursWithPeriods.size === 24;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDatePartiallyUnavailable = (date: Date) => {
|
||||||
|
return isDateUnavailable(date) && !isDateFullyUnavailable(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHourUnavailable = (date: Date, hour: number) => {
|
||||||
|
return unavailablePeriods.some(period => {
|
||||||
|
const start = new Date(period.startDate);
|
||||||
|
const end = new Date(period.endDate);
|
||||||
|
|
||||||
|
// Check if date is within period
|
||||||
|
const dateOnly = new Date(date);
|
||||||
|
dateOnly.setHours(0, 0, 0, 0);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
end.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (dateOnly < start || dateOnly > end) return false;
|
||||||
|
|
||||||
|
// If no specific times, entire day is unavailable
|
||||||
|
if (!period.startTime || !period.endTime) return true;
|
||||||
|
|
||||||
|
// Check specific hour
|
||||||
|
const startHour = parseInt(period.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(period.endTime.split(':')[0]);
|
||||||
|
|
||||||
|
return hour >= startHour && hour <= endHour;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateClick = (date: Date) => {
|
||||||
|
// Check if this date has an accepted rental
|
||||||
|
const hasAcceptedRental = unavailablePeriods.some(p =>
|
||||||
|
p.isAcceptedRental && isDateInPeriod(date, p)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAcceptedRental) {
|
||||||
|
// Don't allow clicking on accepted rental dates
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRentalMode) {
|
||||||
|
toggleDateAvailability(date);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if clicking on an existing rental selection to clear it
|
||||||
|
const existingRental = unavailablePeriods.find(p =>
|
||||||
|
p.isRentalSelection && isDateInPeriod(date, p)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRental) {
|
||||||
|
// Clear the rental selection
|
||||||
|
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingRental.id));
|
||||||
|
setSelectionStart(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-click selection in rental mode
|
||||||
|
if (!selectionStart) {
|
||||||
|
// First click - set start date
|
||||||
|
setSelectionStart(date);
|
||||||
|
} else {
|
||||||
|
// Second click - create rental period
|
||||||
|
const start = new Date(Math.min(selectionStart.getTime(), date.getTime()));
|
||||||
|
const end = new Date(Math.max(selectionStart.getTime(), date.getTime()));
|
||||||
|
|
||||||
|
// Check if any date in range is unavailable
|
||||||
|
let currentDate = new Date(start);
|
||||||
|
let hasUnavailable = false;
|
||||||
|
while (currentDate <= end) {
|
||||||
|
if (unavailablePeriods.some(p => !p.isRentalSelection && isDateInPeriod(currentDate, p))) {
|
||||||
|
hasUnavailable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnavailable) {
|
||||||
|
// Range contains unavailable dates, reset selection
|
||||||
|
setSelectionStart(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing rental selections and add new one
|
||||||
|
const nonRentalPeriods = unavailablePeriods.filter(p => !p.isRentalSelection);
|
||||||
|
const newPeriod: UnavailablePeriod = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
isRentalSelection: true
|
||||||
|
};
|
||||||
|
onPeriodsChange([...nonRentalPeriods, newPeriod]);
|
||||||
|
|
||||||
|
// Reset selection
|
||||||
|
setSelectionStart(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDateAvailability = (date: Date) => {
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (isRentalMode) {
|
||||||
|
// In rental mode, only handle rental selections (green), not unavailable periods (red)
|
||||||
|
const existingRentalPeriod = unavailablePeriods.find(period => {
|
||||||
|
const periodStart = new Date(period.startDate).toISOString().split('T')[0];
|
||||||
|
const periodEnd = new Date(period.endDate).toISOString().split('T')[0];
|
||||||
|
return period.isRentalSelection && periodStart === dateStr && periodEnd === dateStr && !period.startTime && !period.endTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if date is already unavailable (not a rental selection)
|
||||||
|
const isUnavailable = unavailablePeriods.some(p =>
|
||||||
|
!p.isRentalSelection && isDateInPeriod(date, p)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUnavailable) {
|
||||||
|
// Can't select unavailable dates
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRentalPeriod) {
|
||||||
|
// Remove the rental selection
|
||||||
|
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingRentalPeriod.id));
|
||||||
|
} else {
|
||||||
|
// Add new rental selection
|
||||||
|
const newPeriod: UnavailablePeriod = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
startDate: date,
|
||||||
|
endDate: date,
|
||||||
|
isRentalSelection: true
|
||||||
|
};
|
||||||
|
onPeriodsChange([...unavailablePeriods, newPeriod]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Original behavior for marking unavailable
|
||||||
|
const existingPeriod = unavailablePeriods.find(period => {
|
||||||
|
const periodStart = new Date(period.startDate).toISOString().split('T')[0];
|
||||||
|
const periodEnd = new Date(period.endDate).toISOString().split('T')[0];
|
||||||
|
return periodStart === dateStr && periodEnd === dateStr && !period.startTime && !period.endTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingPeriod) {
|
||||||
|
// Remove the period to make it available
|
||||||
|
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingPeriod.id));
|
||||||
|
} else {
|
||||||
|
// Add new unavailable period for this date
|
||||||
|
const newPeriod: UnavailablePeriod = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
startDate: date,
|
||||||
|
endDate: date
|
||||||
|
};
|
||||||
|
onPeriodsChange([...unavailablePeriods, newPeriod]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHourAvailability = (date: Date, hour: number) => {
|
||||||
|
const startTime = `${hour.toString().padStart(2, '0')}:00`;
|
||||||
|
const endTime = `${hour.toString().padStart(2, '0')}:59`;
|
||||||
|
|
||||||
|
const existingPeriod = unavailablePeriods.find(period => {
|
||||||
|
const periodDate = new Date(period.startDate).toISOString().split('T')[0];
|
||||||
|
const checkDate = date.toISOString().split('T')[0];
|
||||||
|
return periodDate === checkDate &&
|
||||||
|
period.startTime === startTime &&
|
||||||
|
period.endTime === endTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingPeriod) {
|
||||||
|
// Remove the period to make it available
|
||||||
|
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingPeriod.id));
|
||||||
|
} else {
|
||||||
|
// Add new unavailable period for this hour
|
||||||
|
const newPeriod: UnavailablePeriod = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
startDate: date,
|
||||||
|
endDate: date,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime
|
||||||
|
};
|
||||||
|
onPeriodsChange([...unavailablePeriods, newPeriod]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMonthView = () => {
|
||||||
|
const days = getDaysInMonth(currentDate);
|
||||||
|
const monthName = currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h6 className="text-center mb-3">{monthName}</h6>
|
||||||
|
<div className="d-grid" style={{ gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||||||
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||||
|
<div key={day} className="text-center fw-bold p-2">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{days.map((date, index) => {
|
||||||
|
let className = 'p-2 text-center';
|
||||||
|
let title = '';
|
||||||
|
let backgroundColor = undefined;
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
className += ' border cursor-pointer';
|
||||||
|
|
||||||
|
const rentalPeriod = unavailablePeriods.find(p =>
|
||||||
|
p.isRentalSelection && isDateInPeriod(date, p)
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptedRental = unavailablePeriods.find(p =>
|
||||||
|
p.isAcceptedRental && isDateInPeriod(date, p)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if date is the selection start
|
||||||
|
const isSelectionStart = selectionStart && date.getTime() === selectionStart.getTime();
|
||||||
|
|
||||||
|
// Check if date would be in the range if this was the end date
|
||||||
|
const wouldBeInRange = selectionStart && date >=
|
||||||
|
new Date(Math.min(selectionStart.getTime(), date.getTime())) &&
|
||||||
|
date <= new Date(Math.max(selectionStart.getTime(), date.getTime()));
|
||||||
|
|
||||||
|
if (acceptedRental) {
|
||||||
|
className += ' text-white';
|
||||||
|
title = 'Booked - This date has an accepted rental';
|
||||||
|
backgroundColor = '#6f42c1';
|
||||||
|
} else if (rentalPeriod) {
|
||||||
|
className += ' bg-success text-white';
|
||||||
|
title = isRentalMode ? 'Selected for rental - Click to clear' : 'Selected';
|
||||||
|
} else if (isSelectionStart) {
|
||||||
|
className += ' bg-primary text-white';
|
||||||
|
title = 'Start date selected - Click another date to complete selection';
|
||||||
|
} else if (wouldBeInRange && !isDateFullyUnavailable(date) && !isDatePartiallyUnavailable(date)) {
|
||||||
|
className += ' bg-info bg-opacity-25';
|
||||||
|
title = 'Click to set as end date';
|
||||||
|
} else if (isDateFullyUnavailable(date)) {
|
||||||
|
className += ' bg-danger text-white';
|
||||||
|
title = isRentalMode ? 'Unavailable' : 'Fully unavailable - Click to make available';
|
||||||
|
} else if (isDatePartiallyUnavailable(date)) {
|
||||||
|
className += ' text-dark';
|
||||||
|
title = isRentalMode ? 'Partially unavailable' : 'Partially unavailable - Click to view details';
|
||||||
|
backgroundColor = '#ffeb3b';
|
||||||
|
} else {
|
||||||
|
className += ' bg-light';
|
||||||
|
title = isRentalMode ? 'Available - Click to select' : 'Available - Click to make unavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={className}
|
||||||
|
onClick={() => date && handleDateClick(date)}
|
||||||
|
style={{
|
||||||
|
minHeight: '40px',
|
||||||
|
cursor: date ? 'pointer' : 'default',
|
||||||
|
backgroundColor: backgroundColor
|
||||||
|
}}
|
||||||
|
title={date ? title : ''}
|
||||||
|
>
|
||||||
|
{date?.getDate()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWeekView = () => {
|
||||||
|
const days = getWeekDays(currentDate);
|
||||||
|
const weekRange = `${formatDate(days[0])} - ${formatDate(days[6])}`;
|
||||||
|
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h6 className="text-center mb-3">{weekRange}</h6>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="table table-bordered table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '60px' }}>Time</th>
|
||||||
|
{days.map((date, index) => (
|
||||||
|
<th key={index} className="text-center" style={{ minWidth: '100px' }}>
|
||||||
|
<div>{date.toLocaleDateString('en-US', { weekday: 'short' })}</div>
|
||||||
|
<div>{date.getDate()}</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{hours.map(hour => (
|
||||||
|
<tr key={hour}>
|
||||||
|
<td className="text-center small">
|
||||||
|
{hour.toString().padStart(2, '0')}:00
|
||||||
|
</td>
|
||||||
|
{days.map((date, dayIndex) => {
|
||||||
|
const isUnavailable = isHourUnavailable(date, hour);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={dayIndex}
|
||||||
|
className={`text-center cursor-pointer p-1
|
||||||
|
${isUnavailable ? 'bg-danger text-white' : 'bg-light'}`}
|
||||||
|
onClick={() => toggleHourAvailability(date, hour)}
|
||||||
|
style={{ cursor: 'pointer', height: '30px' }}
|
||||||
|
title={isUnavailable ? 'Click to make available' : 'Click to make unavailable'}
|
||||||
|
>
|
||||||
|
{isUnavailable && '×'}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDayView = () => {
|
||||||
|
const dayName = currentDate.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h6 className="text-center mb-3">{dayName}</h6>
|
||||||
|
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
|
||||||
|
<table className="table table-bordered">
|
||||||
|
<tbody>
|
||||||
|
{hours.map(hour => {
|
||||||
|
const isUnavailable = isHourUnavailable(currentDate, hour);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={hour}>
|
||||||
|
<td className="text-center" style={{ width: '100px' }}>
|
||||||
|
{hour.toString().padStart(2, '0')}:00
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`text-center cursor-pointer p-3
|
||||||
|
${isUnavailable ? 'bg-danger text-white' : 'bg-light'}`}
|
||||||
|
onClick={() => toggleHourAvailability(currentDate, hour)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title={isUnavailable ? 'Click to make available' : 'Click to make unavailable'}
|
||||||
|
>
|
||||||
|
{isUnavailable ? 'Unavailable' : 'Available'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateDate = (direction: 'prev' | 'next') => {
|
||||||
|
const newDate = new Date(currentDate);
|
||||||
|
|
||||||
|
switch (viewType) {
|
||||||
|
case 'month':
|
||||||
|
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
|
||||||
|
break;
|
||||||
|
case 'day':
|
||||||
|
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="availability-calendar">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div className="btn-group" role="group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${viewType === 'month' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setViewType('month')}
|
||||||
|
>
|
||||||
|
Month
|
||||||
|
</button>
|
||||||
|
{priceType === 'hour' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${viewType === 'week' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setViewType('week')}
|
||||||
|
>
|
||||||
|
Week
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${viewType === 'day' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setViewType('day')}
|
||||||
|
>
|
||||||
|
Day
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-secondary me-2"
|
||||||
|
onClick={() => navigateDate('prev')}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => navigateDate('next')}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-view mb-3">
|
||||||
|
{viewType === 'month' && renderMonthView()}
|
||||||
|
{viewType === 'week' && renderWeekView()}
|
||||||
|
{viewType === 'day' && renderDayView()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted small">
|
||||||
|
<div className="mb-2">
|
||||||
|
<i className="bi bi-info-circle"></i> {isRentalMode ? 'Click start date, then click end date to select rental period' : 'Click on any date or time slot to toggle availability'}
|
||||||
|
</div>
|
||||||
|
{viewType === 'month' && (
|
||||||
|
<div className="d-flex gap-3 justify-content-center flex-wrap">
|
||||||
|
<span><span className="badge bg-light text-dark">□</span> Available</span>
|
||||||
|
{isRentalMode && (
|
||||||
|
<span><span className="badge bg-success">□</span> Selected</span>
|
||||||
|
)}
|
||||||
|
{!isRentalMode && (
|
||||||
|
<span><span className="badge text-white" style={{ backgroundColor: '#6f42c1' }}>□</span> Booked</span>
|
||||||
|
)}
|
||||||
|
<span><span className="badge text-dark" style={{ backgroundColor: '#ffeb3b' }}>□</span> Partially Unavailable</span>
|
||||||
|
<span><span className="badge bg-danger">□</span> Fully Unavailable</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AvailabilityCalendar;
|
||||||
81
frontend/src/components/InfoTooltip.tsx
Normal file
81
frontend/src/components/InfoTooltip.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface InfoTooltipProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoTooltip: React.FC<InfoTooltipProps> = ({ text }) => {
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
tooltipRef.current &&
|
||||||
|
!tooltipRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowTooltip(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showTooltip) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showTooltip]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="position-relative">
|
||||||
|
<span
|
||||||
|
ref={buttonRef}
|
||||||
|
className="text-muted ms-1"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowTooltip(!showTooltip);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-info-circle"></i>
|
||||||
|
</span>
|
||||||
|
{showTooltip && (
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
className="position-absolute bg-dark text-white p-2 rounded"
|
||||||
|
style={{
|
||||||
|
bottom: '100%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
marginBottom: '5px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
<div
|
||||||
|
className="position-absolute"
|
||||||
|
style={{
|
||||||
|
top: '100%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: '5px solid transparent',
|
||||||
|
borderRight: '5px solid transparent',
|
||||||
|
borderTop: '5px solid var(--bs-dark)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfoTooltip;
|
||||||
110
frontend/src/components/Navbar.tsx
Normal file
110
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const Navbar: React.FC = () => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link className="navbar-brand fw-bold" to="/">
|
||||||
|
<i className="bi bi-box-seam me-2"></i>
|
||||||
|
Rentall
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbarNav"
|
||||||
|
aria-controls="navbarNav"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<span className="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div className="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul className="navbar-nav me-auto">
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="nav-link" to="/items">
|
||||||
|
Browse Items
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
{user && (
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="nav-link" to="/create-item">
|
||||||
|
List an Item
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<ul className="navbar-nav">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<li className="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
className="nav-link dropdown-toggle"
|
||||||
|
href="#"
|
||||||
|
id="navbarDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-circle me-1"></i>
|
||||||
|
{user.firstName}
|
||||||
|
</a>
|
||||||
|
<ul className="dropdown-menu" aria-labelledby="navbarDropdown">
|
||||||
|
<li>
|
||||||
|
<Link className="dropdown-item" to="/profile">
|
||||||
|
<i className="bi bi-person me-2"></i>Profile
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link className="dropdown-item" to="/my-rentals">
|
||||||
|
<i className="bi bi-calendar-check me-2"></i>My Rentals
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link className="dropdown-item" to="/my-listings">
|
||||||
|
<i className="bi bi-list-ul me-2"></i>My Listings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr className="dropdown-divider" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button className="dropdown-item" onClick={handleLogout}>
|
||||||
|
<i className="bi bi-box-arrow-right me-2"></i>Logout
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="nav-link" to="/login">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="btn btn-primary btn-sm ms-2" to="/register">
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
25
frontend/src/components/PrivateRoute.tsx
Normal file
25
frontend/src/components/PrivateRoute.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
interface PrivateRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '80vh' }}>
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user ? <>{children}</> : <Navigate to="/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivateRoute;
|
||||||
76
frontend/src/contexts/AuthContext.tsx
Normal file
76
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
|
||||||
|
import { User } from '../types';
|
||||||
|
import { authAPI, userAPI } from '../services/api';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (data: any) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
userAPI.getProfile()
|
||||||
|
.then(response => {
|
||||||
|
setUser(response.data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
const response = await authAPI.login({ email, password });
|
||||||
|
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);
|
||||||
|
setUser(response.data.user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = (user: User) => {
|
||||||
|
setUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
20
frontend/src/index.tsx
Normal file
20
frontend/src/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
||||||
1
frontend/src/logo.svg
Normal file
1
frontend/src/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
516
frontend/src/pages/CreateItem.tsx
Normal file
516
frontend/src/pages/CreateItem.tsx
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
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;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
pickUpAvailable: boolean;
|
||||||
|
localDeliveryAvailable: boolean;
|
||||||
|
localDeliveryRadius?: number;
|
||||||
|
shippingAvailable: boolean;
|
||||||
|
inPlaceUseAvailable: boolean;
|
||||||
|
pricePerHour?: number;
|
||||||
|
pricePerDay?: number;
|
||||||
|
replacementCost: number;
|
||||||
|
location: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
rules?: string;
|
||||||
|
minimumRentalDays: number;
|
||||||
|
needsTraining: boolean;
|
||||||
|
unavailablePeriods?: Array<{
|
||||||
|
id: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateItem: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [formData, setFormData] = useState<ItemFormData>({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
pickUpAvailable: false,
|
||||||
|
localDeliveryAvailable: false,
|
||||||
|
localDeliveryRadius: 25,
|
||||||
|
shippingAvailable: false,
|
||||||
|
inPlaceUseAvailable: false,
|
||||||
|
pricePerDay: undefined,
|
||||||
|
replacementCost: 0,
|
||||||
|
location: "",
|
||||||
|
minimumRentalDays: 1,
|
||||||
|
needsTraining: false,
|
||||||
|
unavailablePeriods: [],
|
||||||
|
});
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
|
const [priceType, setPriceType] = useState<"hour" | "day">("day");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!user) {
|
||||||
|
setError("You must be logged in to create a listing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For now, we'll store image URLs as base64 strings
|
||||||
|
// In production, you'd upload to a service like S3
|
||||||
|
const imageUrls = imagePreviews;
|
||||||
|
|
||||||
|
const response = await api.post("/items", {
|
||||||
|
...formData,
|
||||||
|
images: imageUrls,
|
||||||
|
});
|
||||||
|
navigate(`/items/${response.data.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || "Failed to create listing");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<
|
||||||
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
|
||||||
|
if (type === "checkbox") {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: checked }));
|
||||||
|
} else if (type === "number") {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value ? parseFloat(value) : undefined,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: [...prev.tags, tagInput.trim()],
|
||||||
|
}));
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tag: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: prev.tags.filter((t) => t !== tag),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
|
||||||
|
// Limit to 5 images
|
||||||
|
if (imageFiles.length + files.length > 5) {
|
||||||
|
setError("You can upload a maximum of 5 images");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newImageFiles = [...imageFiles, ...files];
|
||||||
|
setImageFiles(newImageFiles);
|
||||||
|
|
||||||
|
// Create previews
|
||||||
|
files.forEach((file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreviews((prev) => [...prev, reader.result as string]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = (index: number) => {
|
||||||
|
setImageFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<h1>List an Item for Rent</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Images (Max 5)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="form-control"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
disabled={imageFiles.length >= 5}
|
||||||
|
/>
|
||||||
|
<div className="form-text">
|
||||||
|
Upload up to 5 images of your item
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imagePreviews.length > 0 && (
|
||||||
|
<div className="row mt-3">
|
||||||
|
{imagePreviews.map((preview, index) => (
|
||||||
|
<div key={index} className="col-6 col-md-4 col-lg-3 mb-3">
|
||||||
|
<div className="position-relative">
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt={`Preview ${index + 1}`}
|
||||||
|
className="img-fluid rounded"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "150px",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
|
||||||
|
onClick={() => removeImage(index)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="name" className="form-label">
|
||||||
|
Item Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="description" className="form-label">
|
||||||
|
Description *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={4}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Tags</label>
|
||||||
|
<div className="input-group mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyPress={(e) =>
|
||||||
|
e.key === "Enter" && (e.preventDefault(), addTag())
|
||||||
|
}
|
||||||
|
placeholder="Add a tag"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={addTag}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{formData.tags.map((tag, index) => (
|
||||||
|
<span key={index} className="badge bg-primary me-2 mb-2">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close btn-close-white ms-2"
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
style={{ fontSize: "0.7rem" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="location" className="form-label">
|
||||||
|
Location *
|
||||||
|
</label>
|
||||||
|
<AddressAutocomplete
|
||||||
|
id="location"
|
||||||
|
name="location"
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(value, lat, lon) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
location: value,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="Address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Availability Type</label>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="pickUpAvailable"
|
||||||
|
name="pickUpAvailable"
|
||||||
|
checked={formData.pickUpAvailable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="pickUpAvailable">
|
||||||
|
Pick-Up
|
||||||
|
<div className="small text-muted">They pick-up the item from your location and they return the item to your location</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="localDeliveryAvailable"
|
||||||
|
name="localDeliveryAvailable"
|
||||||
|
checked={formData.localDeliveryAvailable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label d-flex align-items-center"
|
||||||
|
htmlFor="localDeliveryAvailable"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Local Delivery
|
||||||
|
{formData.localDeliveryAvailable && (
|
||||||
|
<span className="ms-2">
|
||||||
|
(Delivery Radius:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control form-control-sm d-inline-block mx-1"
|
||||||
|
id="localDeliveryRadius"
|
||||||
|
name="localDeliveryRadius"
|
||||||
|
value={formData.localDeliveryRadius || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
placeholder="25"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
style={{ width: '60px' }}
|
||||||
|
/>
|
||||||
|
miles)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="small text-muted">You deliver and then pick-up the item when the rental period ends</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="shippingAvailable"
|
||||||
|
name="shippingAvailable"
|
||||||
|
checked={formData.shippingAvailable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="shippingAvailable">
|
||||||
|
Shipping
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="inPlaceUseAvailable"
|
||||||
|
name="inPlaceUseAvailable"
|
||||||
|
checked={formData.inPlaceUseAvailable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label"
|
||||||
|
htmlFor="inPlaceUseAvailable"
|
||||||
|
>
|
||||||
|
In-Place Use
|
||||||
|
<div className="small text-muted">They use at your location</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="col-form-label">Price per</label>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={priceType}
|
||||||
|
onChange={(e) => setPriceType(e.target.value as "hour" | "day")}
|
||||||
|
>
|
||||||
|
<option value="hour">Hour</option>
|
||||||
|
<option value="day">Day</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||||
|
name={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||||
|
value={priceType === "hour" ? (formData.pricePerHour || "") : (formData.pricePerDay || "")}
|
||||||
|
onChange={handleChange}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="minimumRentalDays" className="form-label">
|
||||||
|
Minimum Rental {priceType === "hour" ? "Hours" : "Days"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id="minimumRentalDays"
|
||||||
|
name="minimumRentalDays"
|
||||||
|
value={formData.minimumRentalDays}
|
||||||
|
onChange={handleChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Availability Schedule</h5>
|
||||||
|
<p className="text-muted">Select dates when the item is NOT available for rent</p>
|
||||||
|
<AvailabilityCalendar
|
||||||
|
unavailablePeriods={formData.unavailablePeriods || []}
|
||||||
|
onPeriodsChange={(periods) =>
|
||||||
|
setFormData(prev => ({ ...prev, unavailablePeriods: periods }))
|
||||||
|
}
|
||||||
|
priceType={priceType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="rules" className="form-label">
|
||||||
|
Rental Rules & Guidelines
|
||||||
|
</label>
|
||||||
|
<div className="form-check mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="needsTraining"
|
||||||
|
name="needsTraining"
|
||||||
|
checked={formData.needsTraining}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="needsTraining">
|
||||||
|
Requires in-person training before rental
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="rules"
|
||||||
|
name="rules"
|
||||||
|
rows={3}
|
||||||
|
value={formData.rules || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Any specific rules or guidelines for renting this item"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="replacementCost" className="form-label">
|
||||||
|
Replacement Cost *
|
||||||
|
</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id="replacementCost"
|
||||||
|
name="replacementCost"
|
||||||
|
value={formData.replacementCost}
|
||||||
|
onChange={handleChange}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-text">
|
||||||
|
The cost to replace the item if damaged or lost
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Creating..." : "Create Listing"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateItem;
|
||||||
641
frontend/src/pages/EditItem.tsx
Normal file
641
frontend/src/pages/EditItem.tsx
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Item, Rental } from '../types';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { itemAPI, rentalAPI } from '../services/api';
|
||||||
|
import AvailabilityCalendar from '../components/AvailabilityCalendar';
|
||||||
|
import AddressAutocomplete from '../components/AddressAutocomplete';
|
||||||
|
|
||||||
|
interface ItemFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
pickUpAvailable: boolean;
|
||||||
|
localDeliveryAvailable: boolean;
|
||||||
|
localDeliveryRadius?: number;
|
||||||
|
shippingAvailable: boolean;
|
||||||
|
inPlaceUseAvailable: boolean;
|
||||||
|
pricePerHour?: number;
|
||||||
|
pricePerDay?: number;
|
||||||
|
replacementCost: number;
|
||||||
|
location: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
rules?: string;
|
||||||
|
minimumRentalDays: number;
|
||||||
|
needsTraining: boolean;
|
||||||
|
availability: boolean;
|
||||||
|
unavailablePeriods?: Array<{
|
||||||
|
id: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditItem: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
|
const [priceType, setPriceType] = useState<"hour" | "day">("day");
|
||||||
|
const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]);
|
||||||
|
const [formData, setFormData] = useState<ItemFormData>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
tags: [],
|
||||||
|
pickUpAvailable: false,
|
||||||
|
localDeliveryAvailable: false,
|
||||||
|
shippingAvailable: false,
|
||||||
|
inPlaceUseAvailable: false,
|
||||||
|
pricePerHour: undefined,
|
||||||
|
pricePerDay: undefined,
|
||||||
|
replacementCost: 0,
|
||||||
|
location: '',
|
||||||
|
rules: '',
|
||||||
|
minimumRentalDays: 1,
|
||||||
|
needsTraining: false,
|
||||||
|
availability: true,
|
||||||
|
unavailablePeriods: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItem();
|
||||||
|
fetchAcceptedRentals();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchItem = async () => {
|
||||||
|
try {
|
||||||
|
const response = await itemAPI.getItem(id!);
|
||||||
|
const item: Item = response.data;
|
||||||
|
|
||||||
|
if (item.ownerId !== user?.id) {
|
||||||
|
setError('You are not authorized to edit this item');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the price type based on available pricing
|
||||||
|
if (item.pricePerHour) {
|
||||||
|
setPriceType('hour');
|
||||||
|
} else if (item.pricePerDay) {
|
||||||
|
setPriceType('day');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert item data to form data format
|
||||||
|
setFormData({
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
tags: item.tags || [],
|
||||||
|
pickUpAvailable: item.pickUpAvailable || false,
|
||||||
|
localDeliveryAvailable: item.localDeliveryAvailable || false,
|
||||||
|
localDeliveryRadius: item.localDeliveryRadius || 25,
|
||||||
|
shippingAvailable: item.shippingAvailable || false,
|
||||||
|
inPlaceUseAvailable: item.inPlaceUseAvailable || false,
|
||||||
|
pricePerHour: item.pricePerHour,
|
||||||
|
pricePerDay: item.pricePerDay,
|
||||||
|
replacementCost: item.replacementCost,
|
||||||
|
location: item.location,
|
||||||
|
latitude: item.latitude,
|
||||||
|
longitude: item.longitude,
|
||||||
|
rules: item.rules || '',
|
||||||
|
minimumRentalDays: item.minimumRentalDays,
|
||||||
|
needsTraining: item.needsTraining || false,
|
||||||
|
availability: item.availability,
|
||||||
|
unavailablePeriods: item.unavailablePeriods || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set existing images as previews
|
||||||
|
if (item.images && item.images.length > 0) {
|
||||||
|
setImagePreviews(item.images);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAcceptedRentals = async () => {
|
||||||
|
try {
|
||||||
|
const response = await rentalAPI.getMyListings();
|
||||||
|
const rentals: Rental[] = response.data;
|
||||||
|
// Filter for accepted rentals for this specific item
|
||||||
|
const itemRentals = rentals.filter(rental =>
|
||||||
|
rental.itemId === id &&
|
||||||
|
['confirmed', 'active'].includes(rental.status)
|
||||||
|
);
|
||||||
|
setAcceptedRentals(itemRentals);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching rentals:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<
|
||||||
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
|
||||||
|
if (type === "checkbox") {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: checked }));
|
||||||
|
} else if (type === "number") {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value ? parseFloat(value) : undefined,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use existing image previews (which includes both old and new images)
|
||||||
|
const imageUrls = imagePreviews;
|
||||||
|
|
||||||
|
await itemAPI.updateItem(id!, {
|
||||||
|
...formData,
|
||||||
|
images: imageUrls,
|
||||||
|
isPortable: formData.pickUpAvailable || formData.shippingAvailable,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(`/items/${id}`);
|
||||||
|
}, 1500);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to update item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: [...prev.tags, tagInput.trim()],
|
||||||
|
}));
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tag: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: prev.tags.filter((t) => t !== tag),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
|
||||||
|
// Limit to 5 images total
|
||||||
|
if (imagePreviews.length + files.length > 5) {
|
||||||
|
setError("You can upload a maximum of 5 images");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newImageFiles = [...imageFiles, ...files];
|
||||||
|
setImageFiles(newImageFiles);
|
||||||
|
|
||||||
|
// Create previews
|
||||||
|
files.forEach((file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreviews((prev) => [...prev, reader.result as string]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = (index: number) => {
|
||||||
|
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && error.includes('authorized')) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<h1>Edit Listing</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="alert alert-success" role="alert">
|
||||||
|
Item updated successfully! Redirecting...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Images (Max 5)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="form-control"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
disabled={imagePreviews.length >= 5}
|
||||||
|
/>
|
||||||
|
<div className="form-text">
|
||||||
|
Upload up to 5 images of your item
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imagePreviews.length > 0 && (
|
||||||
|
<div className="row mt-3">
|
||||||
|
{imagePreviews.map((preview, index) => (
|
||||||
|
<div key={index} className="col-6 col-md-4 col-lg-3 mb-3">
|
||||||
|
<div className="position-relative">
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt={`Preview ${index + 1}`}
|
||||||
|
className="img-fluid rounded"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "150px",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
|
||||||
|
onClick={() => removeImage(index)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="name" className="form-label">
|
||||||
|
Item Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="description" className="form-label">
|
||||||
|
Description *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={4}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Tags</label>
|
||||||
|
<div className="input-group mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyPress={(e) =>
|
||||||
|
e.key === "Enter" && (e.preventDefault(), addTag())
|
||||||
|
}
|
||||||
|
placeholder="Add a tag"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={addTag}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{formData.tags.map((tag, index) => (
|
||||||
|
<span key={index} className="badge bg-primary me-2 mb-2">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close btn-close-white ms-2"
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
style={{ fontSize: "0.7rem" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="location" className="form-label">
|
||||||
|
Location *
|
||||||
|
</label>
|
||||||
|
<AddressAutocomplete
|
||||||
|
id="location"
|
||||||
|
name="location"
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(value, lat, lon) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
location: value,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="Address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Availability Type</label>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="pickUpAvailable"
|
||||||
|
name="pickUpAvailable"
|
||||||
|
checked={formData.pickUpAvailable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="pickUpAvailable">
|
||||||
|
Pick-Up
|
||||||
|
<div className="small text-muted">They pick-up the item from your location and they return the item to your location</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="localDeliveryAvailable"
|
||||||
|
name="localDeliveryAvailable"
|
||||||
|
checked={formData.localDeliveryAvailable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label d-flex align-items-center"
|
||||||
|
htmlFor="localDeliveryAvailable"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Local Delivery
|
||||||
|
{formData.localDeliveryAvailable && (
|
||||||
|
<span className="ms-2">
|
||||||
|
(Delivery Radius:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control form-control-sm d-inline-block mx-1"
|
||||||
|
id="localDeliveryRadius"
|
||||||
|
name="localDeliveryRadius"
|
||||||
|
value={formData.localDeliveryRadius || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
placeholder="25"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
style={{ width: '60px' }}
|
||||||
|
/>
|
||||||
|
miles)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="small text-muted">You deliver and then pick-up the item when the rental period ends</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="shippingAvailable"
|
||||||
|
name="shippingAvailable"
|
||||||
|
checked={formData.shippingAvailable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="shippingAvailable">
|
||||||
|
Shipping
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="inPlaceUseAvailable"
|
||||||
|
name="inPlaceUseAvailable"
|
||||||
|
checked={formData.inPlaceUseAvailable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label"
|
||||||
|
htmlFor="inPlaceUseAvailable"
|
||||||
|
>
|
||||||
|
In-Place Use
|
||||||
|
<div className="small text-muted">They use at your location</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="col-form-label">Price per</label>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={priceType}
|
||||||
|
onChange={(e) => setPriceType(e.target.value as "hour" | "day")}
|
||||||
|
>
|
||||||
|
<option value="hour">Hour</option>
|
||||||
|
<option value="day">Day</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||||
|
name={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||||
|
value={priceType === "hour" ? (formData.pricePerHour || "") : (formData.pricePerDay || "")}
|
||||||
|
onChange={handleChange}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="minimumRentalDays" className="form-label">
|
||||||
|
Minimum Rental {priceType === "hour" ? "Hours" : "Days"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id="minimumRentalDays"
|
||||||
|
name="minimumRentalDays"
|
||||||
|
value={formData.minimumRentalDays}
|
||||||
|
onChange={handleChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Availability Schedule</h5>
|
||||||
|
<p className="text-muted">Select dates when the item is NOT available for rent. Dates with accepted rentals are shown in purple.</p>
|
||||||
|
<AvailabilityCalendar
|
||||||
|
unavailablePeriods={[
|
||||||
|
...(formData.unavailablePeriods || []),
|
||||||
|
...acceptedRentals.map(rental => ({
|
||||||
|
id: `rental-${rental.id}`,
|
||||||
|
startDate: new Date(rental.startDate),
|
||||||
|
endDate: new Date(rental.endDate),
|
||||||
|
isAcceptedRental: true
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
onPeriodsChange={(periods) => {
|
||||||
|
// Filter out accepted rental periods when saving
|
||||||
|
const userPeriods = periods.filter(p => !p.isAcceptedRental);
|
||||||
|
setFormData(prev => ({ ...prev, unavailablePeriods: userPeriods }));
|
||||||
|
}}
|
||||||
|
priceType={priceType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="rules" className="form-label">
|
||||||
|
Rental Rules & Guidelines
|
||||||
|
</label>
|
||||||
|
<div className="form-check mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="needsTraining"
|
||||||
|
name="needsTraining"
|
||||||
|
checked={formData.needsTraining}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="needsTraining">
|
||||||
|
Requires in-person training before rental
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="rules"
|
||||||
|
name="rules"
|
||||||
|
rows={3}
|
||||||
|
value={formData.rules || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Any specific rules or guidelines for renting this item"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="replacementCost" className="form-label">
|
||||||
|
Replacement Cost *
|
||||||
|
</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id="replacementCost"
|
||||||
|
name="replacementCost"
|
||||||
|
value={formData.replacementCost}
|
||||||
|
onChange={handleChange}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-text">
|
||||||
|
The cost to replace the item if damaged or lost
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="availability"
|
||||||
|
name="availability"
|
||||||
|
checked={formData.availability}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="availability">
|
||||||
|
Available for rent
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Updating..." : "Update Listing"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditItem;
|
||||||
137
frontend/src/pages/Home.tsx
Normal file
137
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const Home: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<section className="py-5 bg-light">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<h1 className="display-4 fw-bold mb-4">
|
||||||
|
Rent Equipment from Your Neighbors
|
||||||
|
</h1>
|
||||||
|
<p className="lead mb-4">
|
||||||
|
Why buy when you can rent? Find gym equipment, tools, and musical instruments
|
||||||
|
available for rent in your area. Save money and space while getting access to
|
||||||
|
everything you need.
|
||||||
|
</p>
|
||||||
|
<div className="d-flex gap-3">
|
||||||
|
<Link to="/items" className="btn btn-primary btn-lg">
|
||||||
|
Browse Items
|
||||||
|
</Link>
|
||||||
|
{user ? (
|
||||||
|
<Link to="/create-item" className="btn btn-outline-primary btn-lg">
|
||||||
|
List Your Item
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link to="/register" className="btn btn-outline-primary btn-lg">
|
||||||
|
Start Renting
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600"
|
||||||
|
alt="Equipment rental"
|
||||||
|
className="img-fluid rounded shadow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-5">
|
||||||
|
<div className="container">
|
||||||
|
<h2 className="text-center mb-5">Popular Categories</h2>
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<i className="bi bi-tools display-3 text-primary mb-3"></i>
|
||||||
|
<h4>Tools</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Power tools, hand tools, and equipment for your DIY projects
|
||||||
|
</p>
|
||||||
|
<Link to="/items?tags=tools" className="btn btn-sm btn-outline-primary">
|
||||||
|
Browse Tools
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<i className="bi bi-heart-pulse display-3 text-primary mb-3"></i>
|
||||||
|
<h4>Gym Equipment</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Weights, machines, and fitness gear for your workout needs
|
||||||
|
</p>
|
||||||
|
<Link to="/items?tags=gym" className="btn btn-sm btn-outline-primary">
|
||||||
|
Browse Gym Equipment
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<i className="bi bi-music-note-beamed display-3 text-primary mb-3"></i>
|
||||||
|
<h4>Musical Instruments</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Guitars, keyboards, drums, and more for musicians
|
||||||
|
</p>
|
||||||
|
<Link to="/items?tags=music" className="btn btn-sm btn-outline-primary">
|
||||||
|
Browse Instruments
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-5 bg-light">
|
||||||
|
<div className="container">
|
||||||
|
<h2 className="text-center mb-5">How It Works</h2>
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-md-3 text-center">
|
||||||
|
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||||
|
<span className="fs-3 fw-bold">1</span>
|
||||||
|
</div>
|
||||||
|
<h5 className="mt-3">Search</h5>
|
||||||
|
<p className="text-muted">Find the equipment you need in your area</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3 text-center">
|
||||||
|
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||||
|
<span className="fs-3 fw-bold">2</span>
|
||||||
|
</div>
|
||||||
|
<h5 className="mt-3">Book</h5>
|
||||||
|
<p className="text-muted">Reserve items for the dates you need</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3 text-center">
|
||||||
|
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||||
|
<span className="fs-3 fw-bold">3</span>
|
||||||
|
</div>
|
||||||
|
<h5 className="mt-3">Pick Up</h5>
|
||||||
|
<p className="text-muted">Collect items or have them delivered</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3 text-center">
|
||||||
|
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||||
|
<span className="fs-3 fw-bold">4</span>
|
||||||
|
</div>
|
||||||
|
<h5 className="mt-3">Return</h5>
|
||||||
|
<p className="text-muted">Return items when you're done</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
204
frontend/src/pages/ItemDetail.tsx
Normal file
204
frontend/src/pages/ItemDetail.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Item, Rental } from '../types';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { itemAPI, rentalAPI } from '../services/api';
|
||||||
|
|
||||||
|
const ItemDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [item, setItem] = useState<Item | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedImage, setSelectedImage] = useState(0);
|
||||||
|
const [isAlreadyRenting, setIsAlreadyRenting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItem();
|
||||||
|
if (user) {
|
||||||
|
checkIfAlreadyRenting();
|
||||||
|
}
|
||||||
|
}, [id, user]);
|
||||||
|
|
||||||
|
const fetchItem = async () => {
|
||||||
|
try {
|
||||||
|
const response = await itemAPI.getItem(id!);
|
||||||
|
setItem(response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkIfAlreadyRenting = async () => {
|
||||||
|
try {
|
||||||
|
const response = await rentalAPI.getMyRentals();
|
||||||
|
const rentals: Rental[] = response.data;
|
||||||
|
// Check if user has an active rental for this item
|
||||||
|
const hasActiveRental = rentals.some(rental =>
|
||||||
|
rental.item?.id === id &&
|
||||||
|
['pending', 'confirmed', 'active'].includes(rental.status)
|
||||||
|
);
|
||||||
|
setIsAlreadyRenting(hasActiveRental);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check rental status:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
navigate(`/items/${id}/edit`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRent = () => {
|
||||||
|
navigate(`/items/${id}/rent`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !item) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error || 'Item not found'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = user?.id === item.ownerId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-10">
|
||||||
|
{item.images.length > 0 ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<img
|
||||||
|
src={item.images[selectedImage]}
|
||||||
|
alt={item.name}
|
||||||
|
className="img-fluid rounded mb-3"
|
||||||
|
style={{ width: '100%', maxHeight: '500px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
{item.images.length > 1 && (
|
||||||
|
<div className="d-flex gap-2 overflow-auto justify-content-center">
|
||||||
|
{item.images.map((image, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={image}
|
||||||
|
alt={`${item.name} ${index + 1}`}
|
||||||
|
className={`rounded cursor-pointer ${selectedImage === index ? 'border border-primary' : ''}`}
|
||||||
|
style={{ width: '80px', height: '80px', objectFit: 'cover', cursor: 'pointer' }}
|
||||||
|
onClick={() => setSelectedImage(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-light rounded d-flex align-items-center justify-content-center mb-4" style={{ height: '400px' }}>
|
||||||
|
<span className="text-muted">No image available</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<h1>{item.name}</h1>
|
||||||
|
<p className="text-muted">{item.location}</p>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
{item.tags.map((tag, index) => (
|
||||||
|
<span key={index} className="badge bg-secondary me-2">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Description</h5>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Pricing</h5>
|
||||||
|
<div className="row">
|
||||||
|
{item.pricePerHour && (
|
||||||
|
<div className="col-6">
|
||||||
|
<strong>Per Hour:</strong> ${item.pricePerHour}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.pricePerDay && (
|
||||||
|
<div className="col-6">
|
||||||
|
<strong>Per Day:</strong> ${item.pricePerDay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.pricePerWeek && (
|
||||||
|
<div className="col-6">
|
||||||
|
<strong>Per Week:</strong> ${item.pricePerWeek}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.pricePerMonth && (
|
||||||
|
<div className="col-6">
|
||||||
|
<strong>Per Month:</strong> ${item.pricePerMonth}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Details</h5>
|
||||||
|
<p><strong>Replacement Cost:</strong> ${item.replacementCost}</p>
|
||||||
|
{item.minimumRentalDays && (
|
||||||
|
<p><strong>Minimum Rental:</strong> {item.minimumRentalDays} days</p>
|
||||||
|
)}
|
||||||
|
{item.maximumRentalDays && (
|
||||||
|
<p><strong>Maximum Rental:</strong> {item.maximumRentalDays} days</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.rules && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Rules</h5>
|
||||||
|
<p>{item.rules}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
{isOwner ? (
|
||||||
|
<button className="btn btn-primary" onClick={handleEdit}>
|
||||||
|
Edit Listing
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
item.availability && !isAlreadyRenting && (
|
||||||
|
<button className="btn btn-primary" onClick={handleRent}>
|
||||||
|
Rent This Item
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{!isOwner && isAlreadyRenting && (
|
||||||
|
<button className="btn btn-success" disabled style={{ opacity: 0.8 }}>
|
||||||
|
✓ Renting
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemDetail;
|
||||||
164
frontend/src/pages/ItemList.tsx
Normal file
164
frontend/src/pages/ItemList.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Item } from '../types';
|
||||||
|
import { itemAPI } from '../services/api';
|
||||||
|
|
||||||
|
const ItemList: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filterTag, setFilterTag] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
try {
|
||||||
|
const response = await itemAPI.getItems();
|
||||||
|
console.log('API Response:', response);
|
||||||
|
// Access the items array from response.data.items
|
||||||
|
const allItems = response.data.items || response.data || [];
|
||||||
|
// Filter only available items
|
||||||
|
const availableItems = allItems.filter((item: Item) => item.availability);
|
||||||
|
setItems(availableItems);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching items:', err);
|
||||||
|
console.error('Error response:', err.response);
|
||||||
|
setError(err.response?.data?.message || err.message || 'Failed to fetch items');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unique tags from all items
|
||||||
|
const allTags = Array.from(new Set(items.flatMap(item => item.tags || [])));
|
||||||
|
|
||||||
|
// Filter items based on search term and selected tag
|
||||||
|
const filteredItems = items.filter(item => {
|
||||||
|
const matchesSearch = searchTerm === '' ||
|
||||||
|
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
item.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
const matchesTag = filterTag === '' || (item.tags && item.tags.includes(filterTag));
|
||||||
|
|
||||||
|
return matchesSearch && matchesTag;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<h1>Browse Items</h1>
|
||||||
|
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Search items..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={filterTag}
|
||||||
|
onChange={(e) => setFilterTag(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{allTags.map(tag => (
|
||||||
|
<option key={tag} value={tag}>{tag}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-2">
|
||||||
|
<span className="text-muted">{filteredItems.length} items found</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<p className="text-center text-muted">No items available for rent.</p>
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<div key={item.id} className="col-md-6 col-lg-4 mb-4">
|
||||||
|
<Link
|
||||||
|
to={`/items/${item.id}`}
|
||||||
|
className="text-decoration-none"
|
||||||
|
>
|
||||||
|
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||||
|
{item.images && item.images[0] && (
|
||||||
|
<img
|
||||||
|
src={item.images[0]}
|
||||||
|
className="card-img-top"
|
||||||
|
alt={item.name}
|
||||||
|
style={{ height: '200px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title text-dark">
|
||||||
|
{item.name}
|
||||||
|
</h5>
|
||||||
|
<p className="card-text text-truncate text-dark">{item.description}</p>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
{item.tags && item.tags.map((tag, index) => (
|
||||||
|
<span key={index} className="badge bg-secondary me-1">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
{item.pricePerDay && (
|
||||||
|
<div className="text-primary">
|
||||||
|
<strong>${item.pricePerDay}/day</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.pricePerHour && (
|
||||||
|
<div className="text-primary">
|
||||||
|
<strong>${item.pricePerHour}/hour</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 text-muted small">
|
||||||
|
<i className="bi bi-geo-alt"></i> {item.location}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.owner && (
|
||||||
|
<small className="text-muted">by {item.owner.firstName} {item.owner.lastName}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemList;
|
||||||
91
frontend/src/pages/Login.tsx
Normal file
91
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to login');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-6 col-lg-5">
|
||||||
|
<div className="card shadow">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<h2 className="text-center mb-4">Login</h2>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="email" className="form-label">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
id="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="password" className="form-label">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="text-center mt-3">
|
||||||
|
<p className="mb-0">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="text-decoration-none">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
295
frontend/src/pages/MyListings.tsx
Normal file
295
frontend/src/pages/MyListings.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { Item, Rental } from '../types';
|
||||||
|
import { rentalAPI } from '../services/api';
|
||||||
|
|
||||||
|
const MyListings: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [listings, setListings] = useState<Item[]>([]);
|
||||||
|
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyListings();
|
||||||
|
fetchRentalRequests();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const fetchMyListings = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(''); // Clear any previous errors
|
||||||
|
const response = await api.get('/items');
|
||||||
|
|
||||||
|
// Filter items to only show ones owned by current user
|
||||||
|
const myItems = response.data.items.filter((item: Item) => item.ownerId === user.id);
|
||||||
|
setListings(myItems);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching listings:', err);
|
||||||
|
// Only show error for actual API failures
|
||||||
|
if (err.response && err.response.status >= 500) {
|
||||||
|
setError('Failed to get your listings. Please try again later.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (itemId: string) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this listing?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/items/${itemId}`);
|
||||||
|
setListings(listings.filter(item => item.id !== itemId));
|
||||||
|
} catch (err: any) {
|
||||||
|
alert('Failed to delete listing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAvailability = async (item: Item) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/items/${item.id}`, {
|
||||||
|
...item,
|
||||||
|
availability: !item.availability
|
||||||
|
});
|
||||||
|
setListings(listings.map(i =>
|
||||||
|
i.id === item.id ? { ...i, availability: !i.availability } : i
|
||||||
|
));
|
||||||
|
} catch (err: any) {
|
||||||
|
alert('Failed to update availability');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRentalRequests = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rentalAPI.getMyListings();
|
||||||
|
setRentals(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching rental requests:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcceptRental = async (rentalId: string) => {
|
||||||
|
try {
|
||||||
|
await rentalAPI.updateRentalStatus(rentalId, 'confirmed');
|
||||||
|
// Refresh the rentals list
|
||||||
|
fetchRentalRequests();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to accept rental request:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectRental = async (rentalId: string) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/rentals/${rentalId}/status`, {
|
||||||
|
status: 'cancelled',
|
||||||
|
rejectionReason: 'Request declined by owner'
|
||||||
|
});
|
||||||
|
// Refresh the rentals list
|
||||||
|
fetchRentalRequests();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reject rental request:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>My Listings</h1>
|
||||||
|
<Link to="/create-item" className="btn btn-primary">
|
||||||
|
Add New Item
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const pendingCount = rentals.filter(r => r.status === 'pending').length;
|
||||||
|
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-info d-flex align-items-center" role="alert">
|
||||||
|
<i className="bi bi-bell-fill me-2"></i>
|
||||||
|
You have {pendingCount} pending rental request{pendingCount > 1 ? 's' : ''} to review.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{listings.length === 0 ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<p className="text-muted">You haven't listed any items yet.</p>
|
||||||
|
<Link to="/create-item" className="btn btn-primary mt-3">
|
||||||
|
List Your First Item
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
{listings.map((item) => (
|
||||||
|
<div key={item.id} className="col-md-6 col-lg-4 mb-4">
|
||||||
|
<Link
|
||||||
|
to={`/items/${item.id}/edit`}
|
||||||
|
className="text-decoration-none"
|
||||||
|
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('button') || target.closest('.rental-requests')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||||
|
{item.images && item.images[0] && (
|
||||||
|
<img
|
||||||
|
src={item.images[0]}
|
||||||
|
className="card-img-top"
|
||||||
|
alt={item.name}
|
||||||
|
style={{ height: '200px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title text-dark">
|
||||||
|
{item.name}
|
||||||
|
</h5>
|
||||||
|
<p className="card-text text-truncate text-dark">{item.description}</p>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className={`badge ${item.availability ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
{item.availability ? 'Available' : 'Not Available'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
{item.pricePerDay && (
|
||||||
|
<div className="text-muted small">
|
||||||
|
${item.pricePerDay}/day
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.pricePerHour && (
|
||||||
|
<div className="text-muted small">
|
||||||
|
${item.pricePerHour}/hour
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAvailability(item)}
|
||||||
|
className="btn btn-sm btn-outline-info"
|
||||||
|
>
|
||||||
|
{item.availability ? 'Mark Unavailable' : 'Mark Available'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const pendingRentals = rentals.filter(r =>
|
||||||
|
r.itemId === item.id && r.status === 'pending'
|
||||||
|
);
|
||||||
|
const acceptedRentals = rentals.filter(r =>
|
||||||
|
r.itemId === item.id && ['confirmed', 'active'].includes(r.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pendingRentals.length > 0 || acceptedRentals.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 border-top pt-3 rental-requests">
|
||||||
|
{pendingRentals.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h6 className="text-primary mb-2">
|
||||||
|
<i className="bi bi-bell-fill"></i> Pending Requests ({pendingRentals.length})
|
||||||
|
</h6>
|
||||||
|
{pendingRentals.map(rental => (
|
||||||
|
<div key={rental.id} className="border rounded p-2 mb-2 bg-light">
|
||||||
|
<div className="d-flex justify-content-between align-items-start">
|
||||||
|
<div className="small">
|
||||||
|
<strong>{rental.renter?.firstName} {rental.renter?.lastName}</strong><br/>
|
||||||
|
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}<br/>
|
||||||
|
<span className="text-muted">${rental.totalAmount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex gap-1">
|
||||||
|
<button
|
||||||
|
className="btn btn-success btn-sm"
|
||||||
|
onClick={() => handleAcceptRental(rental.id)}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => handleRejectRental(rental.id)}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{acceptedRentals.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h6 className="text-success mb-2 mt-3">
|
||||||
|
<i className="bi bi-check-circle-fill"></i> Accepted Rentals ({acceptedRentals.length})
|
||||||
|
</h6>
|
||||||
|
{acceptedRentals.map(rental => (
|
||||||
|
<div key={rental.id} className="border rounded p-2 mb-2 bg-light">
|
||||||
|
<div className="small">
|
||||||
|
<div className="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>{rental.renter?.firstName} {rental.renter?.lastName}</strong><br/>
|
||||||
|
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}<br/>
|
||||||
|
<span className="text-muted">${rental.totalAmount}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`badge ${rental.status === 'active' ? 'bg-success' : 'bg-info'}`}>
|
||||||
|
{rental.status === 'active' ? 'Active' : 'Confirmed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyListings;
|
||||||
200
frontend/src/pages/MyRentals.tsx
Normal file
200
frontend/src/pages/MyRentals.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { rentalAPI } from '../services/api';
|
||||||
|
import { Rental } from '../types';
|
||||||
|
|
||||||
|
const MyRentals: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'active' | 'past'>('active');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRentals();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRentals = async () => {
|
||||||
|
try {
|
||||||
|
const response = await rentalAPI.getMyRentals();
|
||||||
|
setRentals(response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to fetch rentals');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRental = async (rentalId: string) => {
|
||||||
|
if (!window.confirm('Are you sure you want to cancel this rental?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rentalAPI.updateRentalStatus(rentalId, 'cancelled');
|
||||||
|
fetchRentals(); // Refresh the list
|
||||||
|
} catch (err: any) {
|
||||||
|
alert('Failed to cancel rental');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter rentals based on status
|
||||||
|
const activeRentals = rentals.filter(r =>
|
||||||
|
['pending', 'confirmed', 'active'].includes(r.status)
|
||||||
|
);
|
||||||
|
const pastRentals = rentals.filter(r =>
|
||||||
|
['completed', 'cancelled'].includes(r.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayedRentals = activeTab === 'active' ? activeRentals : pastRentals;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<h1>My Rentals</h1>
|
||||||
|
|
||||||
|
<ul className="nav nav-tabs mb-4">
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${activeTab === 'active' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('active')}
|
||||||
|
>
|
||||||
|
Active Rentals ({activeRentals.length})
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${activeTab === 'past' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('past')}
|
||||||
|
>
|
||||||
|
Past Rentals ({pastRentals.length})
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{displayedRentals.length === 0 ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<p className="text-muted">
|
||||||
|
{activeTab === 'active'
|
||||||
|
? "You don't have any active rentals."
|
||||||
|
: "You don't have any past rentals."}
|
||||||
|
</p>
|
||||||
|
<Link to="/items" className="btn btn-primary mt-3">
|
||||||
|
Browse Items to Rent
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
{displayedRentals.map((rental) => (
|
||||||
|
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
|
||||||
|
<Link
|
||||||
|
to={rental.item ? `/items/${rental.item.id}` : '#'}
|
||||||
|
className="text-decoration-none"
|
||||||
|
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!rental.item || target.closest('button')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card h-100" style={{ cursor: rental.item ? 'pointer' : 'default' }}>
|
||||||
|
{rental.item?.images && rental.item.images[0] && (
|
||||||
|
<img
|
||||||
|
src={rental.item.images[0]}
|
||||||
|
className="card-img-top"
|
||||||
|
alt={rental.item.name}
|
||||||
|
style={{ height: '200px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title text-dark">
|
||||||
|
{rental.item ? rental.item.name : 'Item Unavailable'}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className={`badge ${
|
||||||
|
rental.status === 'active' ? 'bg-success' :
|
||||||
|
rental.status === 'pending' ? 'bg-warning' :
|
||||||
|
rental.status === 'confirmed' ? 'bg-info' :
|
||||||
|
rental.status === 'completed' ? 'bg-secondary' :
|
||||||
|
'bg-danger'
|
||||||
|
}`}>
|
||||||
|
{rental.status.charAt(0).toUpperCase() + rental.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
{rental.paymentStatus === 'paid' && (
|
||||||
|
<span className="badge bg-success ms-2">Paid</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-1 text-dark">
|
||||||
|
<strong>Rental Period:</strong><br />
|
||||||
|
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mb-1 text-dark">
|
||||||
|
<strong>Total:</strong> ${rental.totalAmount}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mb-1 text-dark">
|
||||||
|
<strong>Delivery:</strong> {rental.deliveryMethod === 'pickup' ? 'Pick-up' : 'Delivery'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rental.owner && (
|
||||||
|
<p className="mb-1 text-dark">
|
||||||
|
<strong>Owner:</strong> {rental.owner.firstName} {rental.owner.lastName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rental.status === 'cancelled' && rental.rejectionReason && (
|
||||||
|
<div className="alert alert-warning mt-2 mb-1 p-2 small">
|
||||||
|
<strong>Rejection reason:</strong> {rental.rejectionReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex gap-2 mt-3">
|
||||||
|
{rental.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
onClick={() => cancelRental(rental.id)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{rental.status === 'completed' && !rental.rating && (
|
||||||
|
<button className="btn btn-sm btn-primary">
|
||||||
|
Leave Review
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyRentals;
|
||||||
381
frontend/src/pages/Profile.tsx
Normal file
381
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { userAPI, itemAPI, rentalAPI } from '../services/api';
|
||||||
|
import { User, Item, Rental } from '../types';
|
||||||
|
import AddressAutocomplete from '../components/AddressAutocomplete';
|
||||||
|
|
||||||
|
const Profile: React.FC = () => {
|
||||||
|
const { user, updateUser, logout } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [profileData, setProfileData] = useState<User | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
profileImage: ''
|
||||||
|
});
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
itemsListed: 0,
|
||||||
|
acceptedRentals: 0,
|
||||||
|
totalRentals: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProfile();
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await userAPI.getProfile();
|
||||||
|
setProfileData(response.data);
|
||||||
|
setFormData({
|
||||||
|
firstName: response.data.firstName || '',
|
||||||
|
lastName: response.data.lastName || '',
|
||||||
|
email: response.data.email || '',
|
||||||
|
phone: response.data.phone || '',
|
||||||
|
address: response.data.address || '',
|
||||||
|
profileImage: response.data.profileImage || ''
|
||||||
|
});
|
||||||
|
if (response.data.profileImage) {
|
||||||
|
setImagePreview(response.data.profileImage);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to fetch profile');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch user's items
|
||||||
|
const itemsResponse = await itemAPI.getItems();
|
||||||
|
const allItems = itemsResponse.data.items || itemsResponse.data || [];
|
||||||
|
const myItems = allItems.filter((item: Item) => item.ownerId === user?.id);
|
||||||
|
|
||||||
|
// Fetch rentals where user is the owner (rentals on user's items)
|
||||||
|
const ownerRentalsResponse = await rentalAPI.getMyListings();
|
||||||
|
const ownerRentals: Rental[] = ownerRentalsResponse.data;
|
||||||
|
|
||||||
|
const acceptedRentals = ownerRentals.filter(r => ['confirmed', 'active'].includes(r.status));
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
itemsListed: myItems.length,
|
||||||
|
acceptedRentals: acceptedRentals.length,
|
||||||
|
totalRentals: ownerRentals.length
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch stats:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setImageFile(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updateData = {
|
||||||
|
...formData,
|
||||||
|
profileImage: imagePreview || formData.profileImage
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await userAPI.updateProfile(updateData);
|
||||||
|
setProfileData(response.data);
|
||||||
|
updateUser(response.data); // Update the auth context
|
||||||
|
setSuccess('Profile updated successfully!');
|
||||||
|
setEditing(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditing(false);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
// Reset form to original data
|
||||||
|
if (profileData) {
|
||||||
|
setFormData({
|
||||||
|
firstName: profileData.firstName || '',
|
||||||
|
lastName: profileData.lastName || '',
|
||||||
|
email: profileData.email || '',
|
||||||
|
phone: profileData.phone || '',
|
||||||
|
address: profileData.address || '',
|
||||||
|
profileImage: profileData.profileImage || ''
|
||||||
|
});
|
||||||
|
setImagePreview(profileData.profileImage || null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<h1 className="mb-4">My Profile</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="alert alert-success" role="alert">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="position-relative d-inline-block">
|
||||||
|
{imagePreview ? (
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Profile"
|
||||||
|
className="rounded-circle"
|
||||||
|
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
||||||
|
style={{ width: '150px', height: '150px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white" style={{ fontSize: '3rem' }}></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<label
|
||||||
|
htmlFor="profileImage"
|
||||||
|
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
|
||||||
|
style={{ width: '40px', height: '40px', padding: '0' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-camera-fill"></i>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="profileImage"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="d-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h5 className="mt-3">{profileData?.firstName} {profileData?.lastName}</h5>
|
||||||
|
<p className="text-muted">@{profileData?.username}</p>
|
||||||
|
{profileData?.isVerified && (
|
||||||
|
<span className="badge bg-success">
|
||||||
|
<i className="bi bi-check-circle-fill"></i> Verified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="firstName" className="form-label">First Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!editing}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="lastName" className="form-label">Last Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!editing}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="email" className="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!editing}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="phone" className="form-label">Phone Number</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className="form-control"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="+1 (555) 123-4567"
|
||||||
|
disabled={!editing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="address" className="form-label">Address</label>
|
||||||
|
{editing ? (
|
||||||
|
<AddressAutocomplete
|
||||||
|
id="address"
|
||||||
|
name="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFormData(prev => ({ ...prev, address: value }));
|
||||||
|
}}
|
||||||
|
placeholder="Enter your address"
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="address"
|
||||||
|
name="address"
|
||||||
|
value={formData.address}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mt-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Account Statistics</h5>
|
||||||
|
<div className="row text-center">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<h3 className="text-primary">{stats.itemsListed}</h3>
|
||||||
|
<p className="text-muted">Items Listed</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<h3 className="text-success">{stats.acceptedRentals}</h3>
|
||||||
|
<p className="text-muted">Accepted Rentals</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<h3 className="text-info">{stats.totalRentals}</h3>
|
||||||
|
<p className="text-muted">Total Rentals</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mt-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Account Settings</h5>
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i className="bi bi-bell me-2"></i>
|
||||||
|
Notification Settings
|
||||||
|
</div>
|
||||||
|
<i className="bi bi-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i className="bi bi-shield-lock me-2"></i>
|
||||||
|
Privacy & Security
|
||||||
|
</div>
|
||||||
|
<i className="bi bi-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i className="bi bi-credit-card me-2"></i>
|
||||||
|
Payment Methods
|
||||||
|
</div>
|
||||||
|
<i className="bi bi-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="list-group-item list-group-item-action d-flex justify-content-between align-items-center text-danger border-0 w-100 text-start"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<i className="bi bi-box-arrow-right me-2"></i>
|
||||||
|
Log Out
|
||||||
|
</div>
|
||||||
|
<i className="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
163
frontend/src/pages/Register.tsx
Normal file
163
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const Register: React.FC = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
phone: ''
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { register } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(formData);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to create account');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-6 col-lg-5">
|
||||||
|
<div className="card shadow">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<h2 className="text-center mb-4">Create Account</h2>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6 mb-3">
|
||||||
|
<label htmlFor="firstName" className="form-label">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6 mb-3">
|
||||||
|
<label htmlFor="lastName" className="form-label">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="username" className="form-label">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="email" className="form-label">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="phone" className="form-label">
|
||||||
|
Phone (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className="form-control"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="password" className="form-label">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Creating Account...' : 'Sign Up'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="text-center mt-3">
|
||||||
|
<p className="mb-0">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-decoration-none">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
||||||
470
frontend/src/pages/RentItem.tsx
Normal file
470
frontend/src/pages/RentItem.tsx
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Item } from '../types';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { itemAPI, rentalAPI } from '../services/api';
|
||||||
|
import AvailabilityCalendar from '../components/AvailabilityCalendar';
|
||||||
|
|
||||||
|
const RentItem: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [item, setItem] = useState<Item | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
deliveryMethod: 'pickup' as 'pickup' | 'delivery',
|
||||||
|
deliveryAddress: '',
|
||||||
|
cardNumber: '',
|
||||||
|
cardExpiry: '',
|
||||||
|
cardCVC: '',
|
||||||
|
cardName: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedPeriods, setSelectedPeriods] = useState<Array<{
|
||||||
|
id: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
const [totalCost, setTotalCost] = useState(0);
|
||||||
|
const [rentalDuration, setRentalDuration] = useState({ days: 0, hours: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItem();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
calculateTotal();
|
||||||
|
}, [selectedPeriods, item]);
|
||||||
|
|
||||||
|
const fetchItem = async () => {
|
||||||
|
try {
|
||||||
|
const response = await itemAPI.getItem(id!);
|
||||||
|
setItem(response.data);
|
||||||
|
|
||||||
|
// Check if item is available
|
||||||
|
if (!response.data.availability) {
|
||||||
|
setError('This item is not available for rent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is trying to rent their own item
|
||||||
|
if (response.data.ownerId === user?.id) {
|
||||||
|
setError('You cannot rent your own item');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = () => {
|
||||||
|
if (!item || selectedPeriods.length === 0) {
|
||||||
|
setTotalCost(0);
|
||||||
|
setRentalDuration({ days: 0, hours: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, we'll use the first selected period
|
||||||
|
const period = selectedPeriods[0];
|
||||||
|
const start = new Date(period.startDate);
|
||||||
|
const end = new Date(period.endDate);
|
||||||
|
|
||||||
|
// Add time if hourly rental
|
||||||
|
if (item.pricePerHour && period.startTime && period.endTime) {
|
||||||
|
const [startHour, startMin] = period.startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMin] = period.endTime.split(':').map(Number);
|
||||||
|
start.setHours(startHour, startMin);
|
||||||
|
end.setHours(endHour, endMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = end.getTime() - start.getTime();
|
||||||
|
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
let cost = 0;
|
||||||
|
let duration = { days: 0, hours: 0 };
|
||||||
|
|
||||||
|
if (item.pricePerHour && period.startTime && period.endTime) {
|
||||||
|
// Hourly rental
|
||||||
|
cost = diffHours * item.pricePerHour;
|
||||||
|
duration.hours = diffHours;
|
||||||
|
} else if (item.pricePerDay) {
|
||||||
|
// Daily rental
|
||||||
|
cost = diffDays * item.pricePerDay;
|
||||||
|
duration.days = diffDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotalCost(cost);
|
||||||
|
setRentalDuration(duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!user || !item) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedPeriods.length === 0) {
|
||||||
|
setError('Please select a rental period');
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = selectedPeriods[0];
|
||||||
|
const rentalData = {
|
||||||
|
itemId: item.id,
|
||||||
|
startDate: period.startDate.toISOString().split('T')[0],
|
||||||
|
endDate: period.endDate.toISOString().split('T')[0],
|
||||||
|
startTime: period.startTime || undefined,
|
||||||
|
endTime: period.endTime || undefined,
|
||||||
|
totalAmount: totalCost,
|
||||||
|
deliveryMethod: formData.deliveryMethod,
|
||||||
|
deliveryAddress: formData.deliveryMethod === 'delivery' ? formData.deliveryAddress : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
await rentalAPI.createRental(rentalData);
|
||||||
|
navigate('/my-rentals');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to create rental');
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
|
||||||
|
if (name === 'cardNumber') {
|
||||||
|
// Remove all non-digits
|
||||||
|
const cleaned = value.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Add spaces every 4 digits
|
||||||
|
const formatted = cleaned.match(/.{1,4}/g)?.join(' ') || cleaned;
|
||||||
|
|
||||||
|
// Limit to 16 digits (19 characters with spaces)
|
||||||
|
if (cleaned.length <= 16) {
|
||||||
|
setFormData(prev => ({ ...prev, [name]: formatted }));
|
||||||
|
}
|
||||||
|
} else if (name === 'cardExpiry') {
|
||||||
|
// Remove all non-digits
|
||||||
|
const cleaned = value.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Add slash after 2 digits
|
||||||
|
let formatted = cleaned;
|
||||||
|
if (cleaned.length >= 3) {
|
||||||
|
formatted = cleaned.slice(0, 2) + '/' + cleaned.slice(2, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 4 digits
|
||||||
|
if (cleaned.length <= 4) {
|
||||||
|
setFormData(prev => ({ ...prev, [name]: formatted }));
|
||||||
|
}
|
||||||
|
} else if (name === 'cardCVC') {
|
||||||
|
// Only allow digits and limit to 4
|
||||||
|
const cleaned = value.replace(/\D/g, '');
|
||||||
|
if (cleaned.length <= 4) {
|
||||||
|
setFormData(prev => ({ ...prev, [name]: cleaned }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item || error === 'You cannot rent your own item' || error === 'This item is not available for rent') {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error || 'Item not found'}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showHourlyOptions = !!item.pricePerHour;
|
||||||
|
const minDays = item.minimumRentalDays || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<h1>Rent: {item.name}</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Select Rental Period</h5>
|
||||||
|
|
||||||
|
<AvailabilityCalendar
|
||||||
|
unavailablePeriods={[
|
||||||
|
...(item.unavailablePeriods || []),
|
||||||
|
...selectedPeriods.map(p => ({ ...p, isRentalSelection: true }))
|
||||||
|
]}
|
||||||
|
onPeriodsChange={(periods) => {
|
||||||
|
// Only handle rental selections
|
||||||
|
const rentalSelections = periods.filter(p => p.isRentalSelection);
|
||||||
|
setSelectedPeriods(rentalSelections.map(p => {
|
||||||
|
const { isRentalSelection, ...rest } = p;
|
||||||
|
return rest;
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
priceType={showHourlyOptions ? "hour" : "day"}
|
||||||
|
isRentalMode={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rentalDuration.days < minDays && rentalDuration.days > 0 && !showHourlyOptions && (
|
||||||
|
<div className="alert alert-warning mt-3">
|
||||||
|
Minimum rental period is {minDays} days
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedPeriods.length === 0 && (
|
||||||
|
<div className="alert alert-info mt-3">
|
||||||
|
Please select your rental dates on the calendar above
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Delivery Options</h5>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="deliveryMethod" className="form-label">Delivery Method *</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="deliveryMethod"
|
||||||
|
name="deliveryMethod"
|
||||||
|
value={formData.deliveryMethod}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
{item.pickUpAvailable && <option value="pickup">Pick-up</option>}
|
||||||
|
{item.localDeliveryAvailable && <option value="delivery">Delivery</option>}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.deliveryMethod === 'delivery' && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="deliveryAddress" className="form-label">Delivery Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="deliveryAddress"
|
||||||
|
name="deliveryAddress"
|
||||||
|
value={formData.deliveryAddress}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Enter delivery address"
|
||||||
|
required={formData.deliveryMethod === 'delivery'}
|
||||||
|
/>
|
||||||
|
{item.localDeliveryRadius && (
|
||||||
|
<div className="form-text">
|
||||||
|
Delivery available within {item.localDeliveryRadius} miles
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Payment</h5>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Payment Method *</label>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="radio"
|
||||||
|
name="paymentMethod"
|
||||||
|
id="creditCard"
|
||||||
|
value="creditCard"
|
||||||
|
checked
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="creditCard">
|
||||||
|
Credit/Debit Card
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-12">
|
||||||
|
<label htmlFor="cardNumber" className="form-label">Card Number *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="cardNumber"
|
||||||
|
name="cardNumber"
|
||||||
|
value={formData.cardNumber}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="1234 5678 9012 3456"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="cardExpiry" className="form-label">Expiry Date *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="cardExpiry"
|
||||||
|
name="cardExpiry"
|
||||||
|
value={formData.cardExpiry}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="MM/YY"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="cardCVC" className="form-label">CVC *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="cardCVC"
|
||||||
|
name="cardCVC"
|
||||||
|
value={formData.cardCVC}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="123"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="cardName" className="form-label">Name on Card *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="cardName"
|
||||||
|
name="cardName"
|
||||||
|
value={formData.cardName}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="John Doe"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="alert alert-info small">
|
||||||
|
<i className="bi bi-info-circle"></i> Your payment information is secure and encrypted. You will only be charged after the owner accepts your rental request.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={submitting || selectedPeriods.length === 0 || totalCost === 0 || (rentalDuration.days < minDays && !showHourlyOptions)}
|
||||||
|
>
|
||||||
|
{submitting ? 'Processing...' : `Confirm Rental - $${totalCost.toFixed(2)}`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => navigate(`/items/${id}`)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Rental Summary</h5>
|
||||||
|
|
||||||
|
{item.images && item.images[0] && (
|
||||||
|
<img
|
||||||
|
src={item.images[0]}
|
||||||
|
alt={item.name}
|
||||||
|
className="img-fluid rounded mb-3"
|
||||||
|
style={{ width: '100%', height: '200px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h6>{item.name}</h6>
|
||||||
|
<p className="text-muted small">{item.location}</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Pricing:</strong>
|
||||||
|
{item.pricePerHour && (
|
||||||
|
<div>${item.pricePerHour}/hour</div>
|
||||||
|
)}
|
||||||
|
{item.pricePerDay && (
|
||||||
|
<div>${item.pricePerDay}/day</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rentalDuration.days > 0 || rentalDuration.hours > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Duration:</strong>
|
||||||
|
<div>
|
||||||
|
{rentalDuration.days > 0 && `${rentalDuration.days} days`}
|
||||||
|
{rentalDuration.hours > 0 && `${rentalDuration.hours} hours`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<strong>Total Cost:</strong>
|
||||||
|
<strong>${totalCost.toFixed(2)}</strong>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted">Select dates to see total cost</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.rules && (
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<div>
|
||||||
|
<strong>Rules:</strong>
|
||||||
|
<p className="small">{item.rules}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RentItem;
|
||||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
||||||
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ReportHandler } from 'web-vitals';
|
||||||
|
|
||||||
|
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
60
frontend/src/services/api.ts
Normal file
60
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:5001/api';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const authAPI = {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const itemAPI = {
|
||||||
|
getItems: (params?: any) => api.get('/items', { params }),
|
||||||
|
getItem: (id: string) => api.get(`/items/${id}`),
|
||||||
|
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'),
|
||||||
|
};
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
api.put(`/rentals/${id}/status`, { status }),
|
||||||
|
addReview: (id: string, data: any) =>
|
||||||
|
api.post(`/rentals/${id}/review`, data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
74
frontend/src/types/index.ts
Normal file
74
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
profileImage?: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Item {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
isPortable: boolean;
|
||||||
|
pickUpAvailable?: boolean;
|
||||||
|
localDeliveryAvailable?: boolean;
|
||||||
|
localDeliveryRadius?: number;
|
||||||
|
shippingAvailable?: boolean;
|
||||||
|
inPlaceUseAvailable?: boolean;
|
||||||
|
pricePerHour?: number;
|
||||||
|
pricePerDay?: number;
|
||||||
|
pricePerWeek?: number;
|
||||||
|
pricePerMonth?: number;
|
||||||
|
replacementCost: number;
|
||||||
|
location: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
images: string[];
|
||||||
|
condition: 'excellent' | 'good' | 'fair' | 'poor';
|
||||||
|
availability: boolean;
|
||||||
|
specifications: Record<string, any>;
|
||||||
|
rules?: string;
|
||||||
|
minimumRentalDays: number;
|
||||||
|
maximumRentalDays?: number;
|
||||||
|
needsTraining?: boolean;
|
||||||
|
unavailablePeriods?: Array<{
|
||||||
|
id: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}>;
|
||||||
|
ownerId: string;
|
||||||
|
owner?: User;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rental {
|
||||||
|
id: string;
|
||||||
|
itemId: string;
|
||||||
|
renterId: string;
|
||||||
|
ownerId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
totalAmount: number;
|
||||||
|
status: 'pending' | 'confirmed' | 'active' | 'completed' | 'cancelled';
|
||||||
|
paymentStatus: 'pending' | 'paid' | 'refunded';
|
||||||
|
deliveryMethod: 'pickup' | 'delivery';
|
||||||
|
deliveryAddress?: string;
|
||||||
|
notes?: string;
|
||||||
|
rating?: number;
|
||||||
|
review?: string;
|
||||||
|
rejectionReason?: string;
|
||||||
|
item?: Item;
|
||||||
|
renter?: User;
|
||||||
|
owner?: User;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user