started dockerfiles and itemrequest
This commit is contained in:
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p uploads/profiles
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
76
backend/models/ItemRequest.js
Normal file
76
backend/models/ItemRequest.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const ItemRequest = sequelize.define('ItemRequest', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
address1: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
address2: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
city: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
zipCode: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
country: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
latitude: {
|
||||||
|
type: DataTypes.DECIMAL(10, 8)
|
||||||
|
},
|
||||||
|
longitude: {
|
||||||
|
type: DataTypes.DECIMAL(11, 8)
|
||||||
|
},
|
||||||
|
maxPricePerHour: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2)
|
||||||
|
},
|
||||||
|
maxPricePerDay: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2)
|
||||||
|
},
|
||||||
|
preferredStartDate: {
|
||||||
|
type: DataTypes.DATE
|
||||||
|
},
|
||||||
|
preferredEndDate: {
|
||||||
|
type: DataTypes.DATE
|
||||||
|
},
|
||||||
|
isFlexibleDates: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('open', 'fulfilled', 'closed'),
|
||||||
|
defaultValue: 'open'
|
||||||
|
},
|
||||||
|
requesterId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responseCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = ItemRequest;
|
||||||
59
backend/models/ItemRequestResponse.js
Normal file
59
backend/models/ItemRequestResponse.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const ItemRequestResponse = sequelize.define('ItemRequestResponse', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
itemRequestId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'ItemRequests',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responderId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
offerPricePerHour: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2)
|
||||||
|
},
|
||||||
|
offerPricePerDay: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2)
|
||||||
|
},
|
||||||
|
availableStartDate: {
|
||||||
|
type: DataTypes.DATE
|
||||||
|
},
|
||||||
|
availableEndDate: {
|
||||||
|
type: DataTypes.DATE
|
||||||
|
},
|
||||||
|
existingItemId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'Items',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('pending', 'accepted', 'declined', 'expired'),
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
contactInfo: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = ItemRequestResponse;
|
||||||
@@ -3,6 +3,8 @@ const User = require('./User');
|
|||||||
const Item = require('./Item');
|
const Item = require('./Item');
|
||||||
const Rental = require('./Rental');
|
const Rental = require('./Rental');
|
||||||
const Message = require('./Message');
|
const Message = require('./Message');
|
||||||
|
const ItemRequest = require('./ItemRequest');
|
||||||
|
const ItemRequestResponse = require('./ItemRequestResponse');
|
||||||
|
|
||||||
User.hasMany(Item, { as: 'ownedItems', foreignKey: 'ownerId' });
|
User.hasMany(Item, { as: 'ownedItems', foreignKey: 'ownerId' });
|
||||||
Item.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
Item.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
||||||
@@ -22,10 +24,21 @@ Message.belongsTo(User, { as: 'receiver', foreignKey: 'receiverId' });
|
|||||||
Message.hasMany(Message, { as: 'replies', foreignKey: 'parentMessageId' });
|
Message.hasMany(Message, { as: 'replies', foreignKey: 'parentMessageId' });
|
||||||
Message.belongsTo(Message, { as: 'parentMessage', foreignKey: 'parentMessageId' });
|
Message.belongsTo(Message, { as: 'parentMessage', foreignKey: 'parentMessageId' });
|
||||||
|
|
||||||
|
User.hasMany(ItemRequest, { as: 'itemRequests', foreignKey: 'requesterId' });
|
||||||
|
ItemRequest.belongsTo(User, { as: 'requester', foreignKey: 'requesterId' });
|
||||||
|
|
||||||
|
User.hasMany(ItemRequestResponse, { as: 'itemRequestResponses', foreignKey: 'responderId' });
|
||||||
|
ItemRequest.hasMany(ItemRequestResponse, { as: 'responses', foreignKey: 'itemRequestId' });
|
||||||
|
ItemRequestResponse.belongsTo(User, { as: 'responder', foreignKey: 'responderId' });
|
||||||
|
ItemRequestResponse.belongsTo(ItemRequest, { as: 'itemRequest', foreignKey: 'itemRequestId' });
|
||||||
|
ItemRequestResponse.belongsTo(Item, { as: 'existingItem', foreignKey: 'existingItemId' });
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sequelize,
|
sequelize,
|
||||||
User,
|
User,
|
||||||
Item,
|
Item,
|
||||||
Rental,
|
Rental,
|
||||||
Message
|
Message,
|
||||||
|
ItemRequest,
|
||||||
|
ItemRequestResponse
|
||||||
};
|
};
|
||||||
286
backend/routes/itemRequests.js
Normal file
286
backend/routes/itemRequests.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const { ItemRequest, ItemRequestResponse, User, Item } = require('../models');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
status = 'open',
|
||||||
|
page = 1,
|
||||||
|
limit = 20
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const where = { status };
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ title: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows } = await ItemRequest.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'requester',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
requests: rows,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: parseInt(page),
|
||||||
|
totalRequests: count
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/my-requests', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requests = await ItemRequest.findAll({
|
||||||
|
where: { requesterId: req.user.id },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'requester',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: ItemRequestResponse,
|
||||||
|
as: 'responses',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'responder',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Item,
|
||||||
|
as: 'existingItem'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(requests);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const request = await ItemRequest.findByPk(req.params.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'requester',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: ItemRequestResponse,
|
||||||
|
as: 'responses',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'responder',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Item,
|
||||||
|
as: 'existingItem'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({ error: 'Item request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(request);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const request = await ItemRequest.create({
|
||||||
|
...req.body,
|
||||||
|
requesterId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestWithRequester = await ItemRequest.findByPk(request.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'requester',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(requestWithRequester);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const request = await ItemRequest.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({ error: 'Item request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.requesterId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await request.update(req.body);
|
||||||
|
|
||||||
|
const updatedRequest = await ItemRequest.findByPk(request.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'requester',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedRequest);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const request = await ItemRequest.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({ error: 'Item request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.requesterId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await request.destroy();
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/responses', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const request = await ItemRequest.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({ error: 'Item request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.requesterId === req.user.id) {
|
||||||
|
return res.status(400).json({ error: 'Cannot respond to your own request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== 'open') {
|
||||||
|
return res.status(400).json({ error: 'Cannot respond to closed request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await ItemRequestResponse.create({
|
||||||
|
...req.body,
|
||||||
|
itemRequestId: req.params.id,
|
||||||
|
responderId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await request.increment('responseCount');
|
||||||
|
|
||||||
|
const responseWithDetails = await ItemRequestResponse.findByPk(response.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'responder',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Item,
|
||||||
|
as: 'existingItem'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(responseWithDetails);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/responses/:responseId/status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.body;
|
||||||
|
const response = await ItemRequestResponse.findByPk(req.params.responseId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: ItemRequest,
|
||||||
|
as: 'itemRequest'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return res.status(404).json({ error: 'Response not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.itemRequest.requesterId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Only the requester can update response status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await response.update({ status });
|
||||||
|
|
||||||
|
if (status === 'accepted') {
|
||||||
|
await response.itemRequest.update({ status: 'fulfilled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResponse = await ItemRequestResponse.findByPk(response.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'responder',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Item,
|
||||||
|
as: 'existingItem'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -18,6 +18,7 @@ const itemRoutes = require("./routes/items");
|
|||||||
const rentalRoutes = require("./routes/rentals");
|
const rentalRoutes = require("./routes/rentals");
|
||||||
const messageRoutes = require("./routes/messages");
|
const messageRoutes = require("./routes/messages");
|
||||||
const betaRoutes = require("./routes/beta");
|
const betaRoutes = require("./routes/beta");
|
||||||
|
const itemRequestRoutes = require("./routes/itemRequests");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ app.use("/api/users", userRoutes);
|
|||||||
app.use("/api/items", itemRoutes);
|
app.use("/api/items", itemRoutes);
|
||||||
app.use("/api/rentals", rentalRoutes);
|
app.use("/api/rentals", rentalRoutes);
|
||||||
app.use("/api/messages", messageRoutes);
|
app.use("/api/messages", messageRoutes);
|
||||||
|
app.use("/api/item-requests", itemRequestRoutes);
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.json({ message: "CommunityRentals.App API is running!" });
|
res.json({ message: "CommunityRentals.App API is running!" });
|
||||||
|
|||||||
34
frontend/Dockerfile
Normal file
34
frontend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
RUN echo 'server { \
|
||||||
|
listen 80; \
|
||||||
|
location / { \
|
||||||
|
root /usr/share/nginx/html; \
|
||||||
|
index index.html index.htm; \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
error_page 500 502 503 504 /50x.html; \
|
||||||
|
location = /50x.html { \
|
||||||
|
root /usr/share/nginx/html; \
|
||||||
|
} \
|
||||||
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -18,6 +18,10 @@ import Profile from './pages/Profile';
|
|||||||
import PublicProfile from './pages/PublicProfile';
|
import PublicProfile from './pages/PublicProfile';
|
||||||
import Messages from './pages/Messages';
|
import Messages from './pages/Messages';
|
||||||
import MessageDetail from './pages/MessageDetail';
|
import MessageDetail from './pages/MessageDetail';
|
||||||
|
import ItemRequests from './pages/ItemRequests';
|
||||||
|
import ItemRequestDetail from './pages/ItemRequestDetail';
|
||||||
|
import CreateItemRequest from './pages/CreateItemRequest';
|
||||||
|
import MyRequests from './pages/MyRequests';
|
||||||
import PrivateRoute from './components/PrivateRoute';
|
import PrivateRoute from './components/PrivateRoute';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -99,6 +103,24 @@ function App() {
|
|||||||
<MessageDetail />
|
<MessageDetail />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/item-requests" element={<ItemRequests />} />
|
||||||
|
<Route path="/item-requests/:id" element={<ItemRequestDetail />} />
|
||||||
|
<Route
|
||||||
|
path="/create-item-request"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<CreateItemRequest />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my-requests"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<MyRequests />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
111
frontend/src/components/ItemRequestCard.tsx
Normal file
111
frontend/src/components/ItemRequestCard.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ItemRequest } from '../types';
|
||||||
|
|
||||||
|
interface ItemRequestCardProps {
|
||||||
|
request: ItemRequest;
|
||||||
|
showActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemRequestCard: React.FC<ItemRequestCardProps> = ({ request, showActions = true }) => {
|
||||||
|
const formatDate = (dateString?: string) => {
|
||||||
|
if (!dateString) return 'Flexible';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationString = () => {
|
||||||
|
const parts = [];
|
||||||
|
if (request.city) parts.push(request.city);
|
||||||
|
if (request.state) parts.push(request.state);
|
||||||
|
return parts.join(', ') || 'Location not specified';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open':
|
||||||
|
return 'success';
|
||||||
|
case 'fulfilled':
|
||||||
|
return 'primary';
|
||||||
|
case 'closed':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card h-100 shadow-sm hover-shadow">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<h5 className="card-title text-truncate flex-grow-1 me-2">{request.title}</h5>
|
||||||
|
<span className={`badge bg-${getStatusColor(request.status)}`}>
|
||||||
|
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="card-text text-muted small mb-2">
|
||||||
|
{request.description.length > 100
|
||||||
|
? `${request.description.substring(0, 100)}...`
|
||||||
|
: request.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-geo-alt me-1"></i>
|
||||||
|
{getLocationString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-person me-1"></i>
|
||||||
|
Requested by {request.requester?.firstName || 'Unknown'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(request.maxPricePerDay || request.maxPricePerHour) && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-currency-dollar me-1"></i>
|
||||||
|
Budget:
|
||||||
|
{request.maxPricePerDay && ` $${request.maxPricePerDay}/day`}
|
||||||
|
{request.maxPricePerHour && ` $${request.maxPricePerHour}/hour`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-calendar me-1"></i>
|
||||||
|
Dates: {formatDate(request.preferredStartDate)} - {formatDate(request.preferredEndDate)}
|
||||||
|
{request.isFlexibleDates && ' (Flexible)'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex justify-content-between align-items-center">
|
||||||
|
<small className="text-muted">
|
||||||
|
{request.responseCount || 0} response{request.responseCount !== 1 ? 's' : ''}
|
||||||
|
</small>
|
||||||
|
<small className="text-muted">
|
||||||
|
{new Date(request.createdAt).toLocaleDateString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showActions && (
|
||||||
|
<div className="d-grid gap-2 mt-3">
|
||||||
|
<Link
|
||||||
|
to={`/item-requests/${request.id}`}
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemRequestCard;
|
||||||
@@ -88,6 +88,11 @@ const Navbar: React.FC = () => {
|
|||||||
<i className="bi bi-list-ul me-2"></i>My Listings
|
<i className="bi bi-list-ul me-2"></i>My Listings
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link className="dropdown-item" to="/my-requests">
|
||||||
|
<i className="bi bi-clipboard-check me-2"></i>My Requests
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link className="dropdown-item" to="/messages">
|
<Link className="dropdown-item" to="/messages">
|
||||||
<i className="bi bi-envelope me-2"></i>Messages
|
<i className="bi bi-envelope me-2"></i>Messages
|
||||||
|
|||||||
257
frontend/src/components/RequestResponseModal.tsx
Normal file
257
frontend/src/components/RequestResponseModal.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { itemRequestAPI, itemAPI } from '../services/api';
|
||||||
|
import { ItemRequest, Item } from '../types';
|
||||||
|
|
||||||
|
interface RequestResponseModalProps {
|
||||||
|
show: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
request: ItemRequest | null;
|
||||||
|
onResponseSubmitted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
||||||
|
show,
|
||||||
|
onHide,
|
||||||
|
request,
|
||||||
|
onResponseSubmitted
|
||||||
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [userItems, setUserItems] = useState<Item[]>([]);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
message: '',
|
||||||
|
offerPricePerHour: '',
|
||||||
|
offerPricePerDay: '',
|
||||||
|
availableStartDate: '',
|
||||||
|
availableEndDate: '',
|
||||||
|
existingItemId: '',
|
||||||
|
contactInfo: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show && user) {
|
||||||
|
fetchUserItems();
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}, [show, user]);
|
||||||
|
|
||||||
|
const fetchUserItems = async () => {
|
||||||
|
try {
|
||||||
|
const response = await itemAPI.getItems({ owner: user?.id });
|
||||||
|
setUserItems(response.data.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch user items:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
message: '',
|
||||||
|
offerPricePerHour: '',
|
||||||
|
offerPricePerDay: '',
|
||||||
|
availableStartDate: '',
|
||||||
|
availableEndDate: '',
|
||||||
|
existingItemId: '',
|
||||||
|
contactInfo: ''
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!request || !user) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const responseData = {
|
||||||
|
...formData,
|
||||||
|
offerPricePerHour: formData.offerPricePerHour ? parseFloat(formData.offerPricePerHour) : null,
|
||||||
|
offerPricePerDay: formData.offerPricePerDay ? parseFloat(formData.offerPricePerDay) : null,
|
||||||
|
existingItemId: formData.existingItemId || null,
|
||||||
|
availableStartDate: formData.availableStartDate || null,
|
||||||
|
availableEndDate: formData.availableEndDate || null
|
||||||
|
};
|
||||||
|
|
||||||
|
await itemRequestAPI.respondToRequest(request.id, responseData);
|
||||||
|
onResponseSubmitted();
|
||||||
|
onHide();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to submit response');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`modal fade ${show ? 'show d-block' : ''}`} tabIndex={-1} style={{ backgroundColor: show ? 'rgba(0,0,0,0.5)' : 'transparent' }}>
|
||||||
|
<div className="modal-dialog modal-lg">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Respond to Request</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onHide}></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="mb-3 p-3 bg-light rounded">
|
||||||
|
<h6>{request.title}</h6>
|
||||||
|
<p className="text-muted small mb-0">{request.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="message" className="form-label">Your Message *</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
rows={4}
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Explain how you can help, availability, condition of the item, etc."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userItems.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="existingItemId" className="form-label">Do you have an existing listing for this item?</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="existingItemId"
|
||||||
|
name="existingItemId"
|
||||||
|
value={formData.existingItemId}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="">No existing listing</option>
|
||||||
|
{userItems.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} - ${item.pricePerDay}/day
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="form-text">
|
||||||
|
If you have an existing listing that matches this request, select it here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="offerPricePerDay" className="form-label">Your Price per Day</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id="offerPricePerDay"
|
||||||
|
name="offerPricePerDay"
|
||||||
|
value={formData.offerPricePerDay}
|
||||||
|
onChange={handleChange}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="offerPricePerHour" className="form-label">Your Price per Hour</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id="offerPricePerHour"
|
||||||
|
name="offerPricePerHour"
|
||||||
|
value={formData.offerPricePerHour}
|
||||||
|
onChange={handleChange}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="availableStartDate" className="form-label">Available From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
id="availableStartDate"
|
||||||
|
name="availableStartDate"
|
||||||
|
value={formData.availableStartDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="availableEndDate" className="form-label">Available Until</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
id="availableEndDate"
|
||||||
|
name="availableEndDate"
|
||||||
|
value={formData.availableEndDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={formData.availableStartDate || new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="contactInfo" className="form-label">Contact Information</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="contactInfo"
|
||||||
|
name="contactInfo"
|
||||||
|
value={formData.contactInfo}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Phone number, email, or preferred contact method"
|
||||||
|
/>
|
||||||
|
<div className="form-text">
|
||||||
|
How should the requester contact you if they're interested?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onHide}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !formData.message.trim()}
|
||||||
|
>
|
||||||
|
{loading ? 'Submitting...' : 'Submit Response'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequestResponseModal;
|
||||||
303
frontend/src/pages/CreateItemRequest.tsx
Normal file
303
frontend/src/pages/CreateItemRequest.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { itemRequestAPI } from '../services/api';
|
||||||
|
import AddressAutocomplete from '../components/AddressAutocomplete';
|
||||||
|
|
||||||
|
const CreateItemRequest: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
address1: '',
|
||||||
|
address2: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zipCode: '',
|
||||||
|
country: 'US',
|
||||||
|
latitude: undefined as number | undefined,
|
||||||
|
longitude: undefined as number | undefined,
|
||||||
|
maxPricePerHour: '',
|
||||||
|
maxPricePerDay: '',
|
||||||
|
preferredStartDate: '',
|
||||||
|
preferredEndDate: '',
|
||||||
|
isFlexibleDates: true
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressChange = (value: string, lat?: number, lon?: number) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
address1: value,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
city: prev.city,
|
||||||
|
state: prev.state,
|
||||||
|
zipCode: prev.zipCode,
|
||||||
|
country: prev.country
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestData = {
|
||||||
|
...formData,
|
||||||
|
maxPricePerHour: formData.maxPricePerHour ? parseFloat(formData.maxPricePerHour) : null,
|
||||||
|
maxPricePerDay: formData.maxPricePerDay ? parseFloat(formData.maxPricePerDay) : null,
|
||||||
|
preferredStartDate: formData.preferredStartDate || null,
|
||||||
|
preferredEndDate: formData.preferredEndDate || null
|
||||||
|
};
|
||||||
|
|
||||||
|
await itemRequestAPI.createItemRequest(requestData);
|
||||||
|
navigate('/my-requests');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to create item request');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
Please log in to create item requests.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2 className="mb-0">Request an Item</h2>
|
||||||
|
<p className="text-muted mb-0">Can't find what you need? Request it and let others know!</p>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="title" className="form-label">What are you looking for? *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g., Power drill, Camera lens, Camping tent"
|
||||||
|
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}
|
||||||
|
placeholder="Describe what you need it for, any specific requirements, condition preferences, etc."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="maxPricePerDay" className="form-label">Max Price per Day</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id="maxPricePerDay"
|
||||||
|
name="maxPricePerDay"
|
||||||
|
value={formData.maxPricePerDay}
|
||||||
|
onChange={handleChange}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="maxPricePerHour" className="form-label">Max Price per Hour</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
id="maxPricePerHour"
|
||||||
|
name="maxPricePerHour"
|
||||||
|
value={formData.maxPricePerHour}
|
||||||
|
onChange={handleChange}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Address</label>
|
||||||
|
<AddressAutocomplete
|
||||||
|
value={formData.address1}
|
||||||
|
onChange={handleAddressChange}
|
||||||
|
placeholder="Enter your address or area"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="address2" className="form-label">Apartment, suite, etc.</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="address2"
|
||||||
|
name="address2"
|
||||||
|
value={formData.address2}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Apt 2B, Suite 100, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="city" className="form-label">City</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="city"
|
||||||
|
name="city"
|
||||||
|
value={formData.city}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="City"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="state" className="form-label">State</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="state"
|
||||||
|
name="state"
|
||||||
|
value={formData.state}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="State"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="zipCode" className="form-label">ZIP Code</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="zipCode"
|
||||||
|
name="zipCode"
|
||||||
|
value={formData.zipCode}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="12345"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="isFlexibleDates"
|
||||||
|
name="isFlexibleDates"
|
||||||
|
checked={formData.isFlexibleDates}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="isFlexibleDates">
|
||||||
|
I'm flexible with dates
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!formData.isFlexibleDates && (
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="preferredStartDate" className="form-label">Preferred Start Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
id="preferredStartDate"
|
||||||
|
name="preferredStartDate"
|
||||||
|
value={formData.preferredStartDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="preferredEndDate" className="form-label">Preferred End Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
id="preferredEndDate"
|
||||||
|
name="preferredEndDate"
|
||||||
|
value={formData.preferredEndDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={formData.preferredStartDate || new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Creating Request...' : 'Create Request'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateItemRequest;
|
||||||
@@ -133,6 +133,7 @@ const Home: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* How It Works - For Renters */}
|
{/* How It Works - For Renters */}
|
||||||
<div className="py-5">
|
<div className="py-5">
|
||||||
<div className="container-fluid" style={{ maxWidth: '1800px' }}>
|
<div className="container-fluid" style={{ maxWidth: '1800px' }}>
|
||||||
|
|||||||
362
frontend/src/pages/ItemRequestDetail.tsx
Normal file
362
frontend/src/pages/ItemRequestDetail.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { itemRequestAPI } from '../services/api';
|
||||||
|
import { ItemRequest, ItemRequestResponse } from '../types';
|
||||||
|
import RequestResponseModal from '../components/RequestResponseModal';
|
||||||
|
|
||||||
|
const ItemRequestDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [request, setRequest] = useState<ItemRequest | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showResponseModal, setShowResponseModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchRequest();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchRequest = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await itemRequestAPI.getItemRequest(id!);
|
||||||
|
setRequest(response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to fetch request details');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResponseSubmitted = () => {
|
||||||
|
fetchRequest();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open':
|
||||||
|
return 'success';
|
||||||
|
case 'fulfilled':
|
||||||
|
return 'primary';
|
||||||
|
case 'closed':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResponseStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'accepted':
|
||||||
|
return 'success';
|
||||||
|
case 'declined':
|
||||||
|
return 'danger';
|
||||||
|
case 'expired':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string) => {
|
||||||
|
if (!dateString) return 'Not specified';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationString = () => {
|
||||||
|
if (!request) return '';
|
||||||
|
const parts = [];
|
||||||
|
if (request.city) parts.push(request.city);
|
||||||
|
if (request.state) parts.push(request.state);
|
||||||
|
return parts.join(', ') || 'Location not specified';
|
||||||
|
};
|
||||||
|
|
||||||
|
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 || !request) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error || 'Request not found'}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = user?.id === request.requesterId;
|
||||||
|
const canRespond = user && !isOwner && request.status === 'open';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="card-title">{request.title}</h1>
|
||||||
|
<p className="text-muted mb-2">
|
||||||
|
Requested by {request.requester?.firstName || 'Unknown'} {request.requester?.lastName || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`badge bg-${getStatusColor(request.status)} fs-6`}>
|
||||||
|
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Description</h5>
|
||||||
|
<p className="card-text">{request.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<h6><i className="bi bi-geo-alt me-2"></i>Location</h6>
|
||||||
|
<p className="text-muted">{getLocationString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<h6><i className="bi bi-calendar me-2"></i>Timeline</h6>
|
||||||
|
<p className="text-muted">
|
||||||
|
{request.isFlexibleDates ? (
|
||||||
|
'Flexible dates'
|
||||||
|
) : (
|
||||||
|
`${formatDate(request.preferredStartDate)} - ${formatDate(request.preferredEndDate)}`
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(request.maxPricePerDay || request.maxPricePerHour) && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h6><i className="bi bi-currency-dollar me-2"></i>Budget</h6>
|
||||||
|
<div className="text-muted">
|
||||||
|
{request.maxPricePerDay && <div>Up to ${request.maxPricePerDay} per day</div>}
|
||||||
|
{request.maxPricePerHour && <div>Up to ${request.maxPricePerHour} per hour</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<small className="text-muted">
|
||||||
|
Created on {new Date(request.createdAt).toLocaleDateString()} •
|
||||||
|
{request.responseCount || 0} response{request.responseCount !== 1 ? 's' : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canRespond && (
|
||||||
|
<div className="d-grid">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowResponseModal(true)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-reply me-2"></i>
|
||||||
|
Respond to Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link to={`/my-requests`} className="btn btn-outline-primary">
|
||||||
|
<i className="bi bi-arrow-left me-2"></i>
|
||||||
|
Back to My Requests
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{request.responses && request.responses.length > 0 && (
|
||||||
|
<div className="card mt-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="mb-4">Responses ({request.responses.length})</h5>
|
||||||
|
|
||||||
|
{request.responses.map((response: ItemRequestResponse) => (
|
||||||
|
<div key={response.id} className="border-bottom pb-4 mb-4 last:border-bottom-0 last:pb-0 last:mb-0">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<div className="me-3">
|
||||||
|
<div className="bg-light rounded-circle d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px' }}>
|
||||||
|
<i className="bi bi-person"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{response.responder?.firstName || 'Unknown'} {response.responder?.lastName || ''}</strong>
|
||||||
|
<br />
|
||||||
|
<small className="text-muted">
|
||||||
|
{new Date(response.createdAt).toLocaleDateString()} at {new Date(response.createdAt).toLocaleTimeString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`badge bg-${getResponseStatusColor(response.status)}`}>
|
||||||
|
{response.status.charAt(0).toUpperCase() + response.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-5">
|
||||||
|
<p className="mb-3">{response.message}</p>
|
||||||
|
|
||||||
|
{(response.offerPricePerDay || response.offerPricePerHour) && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<strong>Offered Price:</strong>
|
||||||
|
<div className="text-muted">
|
||||||
|
{response.offerPricePerDay && <div>${response.offerPricePerDay} per day</div>}
|
||||||
|
{response.offerPricePerHour && <div>${response.offerPricePerHour} per hour</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(response.availableStartDate || response.availableEndDate) && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<strong>Availability:</strong>
|
||||||
|
<div className="text-muted">
|
||||||
|
{formatDate(response.availableStartDate)} - {formatDate(response.availableEndDate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{response.contactInfo && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<strong>Contact:</strong>
|
||||||
|
<span className="text-muted ms-2">{response.contactInfo}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{response.existingItem && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<strong>Related Item:</strong>
|
||||||
|
<Link to={`/items/${response.existingItem.id}`} className="ms-2 text-decoration-none">
|
||||||
|
{response.existingItem.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<h6>Request Summary</h6>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<span className={`badge bg-${getStatusColor(request.status)} ms-2`}>
|
||||||
|
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Requested by:</strong>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Link to={`/users/${request.requester?.id}`} className="text-decoration-none">
|
||||||
|
{request.requester?.firstName || 'Unknown'} {request.requester?.lastName || ''}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(request.maxPricePerDay || request.maxPricePerHour) && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Budget Range:</strong>
|
||||||
|
<div className="text-muted mt-1">
|
||||||
|
{request.maxPricePerDay && <div>≤ ${request.maxPricePerDay}/day</div>}
|
||||||
|
{request.maxPricePerHour && <div>≤ ${request.maxPricePerHour}/hour</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Timeline:</strong>
|
||||||
|
<div className="text-muted mt-1">
|
||||||
|
{request.isFlexibleDates ? (
|
||||||
|
'Flexible dates'
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div>From: {formatDate(request.preferredStartDate)}</div>
|
||||||
|
<div>To: {formatDate(request.preferredEndDate)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Location:</strong>
|
||||||
|
<div className="text-muted mt-1">{getLocationString()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Responses:</strong>
|
||||||
|
<div className="text-muted mt-1">{request.responseCount || 0} received</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
{canRespond ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={() => setShowResponseModal(true)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-reply me-2"></i>
|
||||||
|
Respond to Request
|
||||||
|
</button>
|
||||||
|
) : user && !isOwner ? (
|
||||||
|
<div className="text-muted text-center">
|
||||||
|
<small>This request is {request.status}</small>
|
||||||
|
</div>
|
||||||
|
) : !user ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<Link to="/login" className="btn btn-outline-primary">
|
||||||
|
Log in to Respond
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-left me-2"></i>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RequestResponseModal
|
||||||
|
show={showResponseModal}
|
||||||
|
onHide={() => setShowResponseModal(false)}
|
||||||
|
request={request}
|
||||||
|
onResponseSubmitted={handleResponseSubmitted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemRequestDetail;
|
||||||
211
frontend/src/pages/ItemRequests.tsx
Normal file
211
frontend/src/pages/ItemRequests.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { itemRequestAPI } from '../services/api';
|
||||||
|
import { ItemRequest } from '../types';
|
||||||
|
import ItemRequestCard from '../components/ItemRequestCard';
|
||||||
|
|
||||||
|
const ItemRequests: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [requests, setRequests] = useState<ItemRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalRequests, setTotalRequests] = useState(0);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
search: '',
|
||||||
|
status: 'open'
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRequests();
|
||||||
|
}, [currentPage, filters]);
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await itemRequestAPI.getItemRequests({
|
||||||
|
page: currentPage,
|
||||||
|
limit: 20,
|
||||||
|
...filters
|
||||||
|
});
|
||||||
|
|
||||||
|
setRequests(response.data.requests);
|
||||||
|
setTotalPages(response.data.totalPages);
|
||||||
|
setTotalRequests(response.data.totalRequests);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to fetch item requests');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFilters(prev => ({ ...prev, [name]: value }));
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchRequests();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1>Item Requests</h1>
|
||||||
|
<p className="text-muted">Help others by fulfilling their item requests</p>
|
||||||
|
</div>
|
||||||
|
{user && (
|
||||||
|
<Link to="/create-item-request" className="btn btn-primary">
|
||||||
|
<i className="bi bi-plus-circle me-2"></i>
|
||||||
|
Create Request
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<form onSubmit={handleSearch}>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Search item requests..."
|
||||||
|
name="search"
|
||||||
|
value={filters.search}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-outline-secondary" type="submit">
|
||||||
|
<i className="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
name="status"
|
||||||
|
value={filters.status}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
>
|
||||||
|
<option value="open">Open Requests</option>
|
||||||
|
<option value="fulfilled">Fulfilled Requests</option>
|
||||||
|
<option value="closed">Closed Requests</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Showing {requests.length} of {totalRequests} requests
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<i className="bi bi-inbox display-1 text-muted"></i>
|
||||||
|
<h3 className="mt-3">No requests found</h3>
|
||||||
|
<p className="text-muted">
|
||||||
|
{filters.search
|
||||||
|
? "Try adjusting your search terms or filters."
|
||||||
|
: "Be the first to create an item request!"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{user && !filters.search && (
|
||||||
|
<Link to="/create-item-request" className="btn btn-primary">
|
||||||
|
Create First Request
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="row g-4">
|
||||||
|
{requests.map((request) => (
|
||||||
|
<div key={request.id} className="col-md-6 col-lg-4">
|
||||||
|
<ItemRequestCard request={request} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<nav className="mt-4" aria-label="Page navigation">
|
||||||
|
<ul className="pagination justify-content-center">
|
||||||
|
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
const page = i + Math.max(1, currentPage - 2);
|
||||||
|
if (page > totalPages) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={page} className={`page-item ${currentPage === page ? 'active' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => handlePageChange(page)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!user && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="alert alert-info" role="alert">
|
||||||
|
<i className="bi bi-info-circle me-2"></i>
|
||||||
|
<Link to="/login" className="alert-link">Log in</Link> to create your own item requests or respond to existing ones.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemRequests;
|
||||||
265
frontend/src/pages/MyRequests.tsx
Normal file
265
frontend/src/pages/MyRequests.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { itemRequestAPI } from '../services/api';
|
||||||
|
import { ItemRequest, ItemRequestResponse } from '../types';
|
||||||
|
import ConfirmationModal from '../components/ConfirmationModal';
|
||||||
|
|
||||||
|
const MyRequests: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [requests, setRequests] = useState<ItemRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deleteModal, setDeleteModal] = useState<{ show: boolean; requestId: string | null }>({
|
||||||
|
show: false,
|
||||||
|
requestId: null
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchMyRequests();
|
||||||
|
} else {
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
|
const fetchMyRequests = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await itemRequestAPI.getMyRequests();
|
||||||
|
setRequests(response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to fetch your requests');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteModal.requestId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await itemRequestAPI.deleteItemRequest(deleteModal.requestId);
|
||||||
|
setRequests(prev => prev.filter(req => req.id !== deleteModal.requestId));
|
||||||
|
setDeleteModal({ show: false, requestId: null });
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to delete request');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResponseStatusUpdate = async (responseId: string, status: string) => {
|
||||||
|
try {
|
||||||
|
await itemRequestAPI.updateResponseStatus(responseId, status);
|
||||||
|
fetchMyRequests();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to update response status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open':
|
||||||
|
return 'success';
|
||||||
|
case 'fulfilled':
|
||||||
|
return 'primary';
|
||||||
|
case 'closed':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResponseStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'accepted':
|
||||||
|
return 'success';
|
||||||
|
case 'declined':
|
||||||
|
return 'danger';
|
||||||
|
case 'expired':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1>My Item Requests</h1>
|
||||||
|
<p className="text-muted">Manage your item requests and view responses</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/create-item-request" className="btn btn-primary">
|
||||||
|
<i className="bi bi-plus-circle me-2"></i>
|
||||||
|
Create New Request
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : requests.length === 0 ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<i className="bi bi-clipboard-x display-1 text-muted"></i>
|
||||||
|
<h3 className="mt-3">No requests yet</h3>
|
||||||
|
<p className="text-muted">Create your first item request to get started!</p>
|
||||||
|
<Link to="/create-item-request" className="btn btn-primary">
|
||||||
|
Create Request
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-4">
|
||||||
|
{requests.map((request) => (
|
||||||
|
<div key={request.id} className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<h5 className="card-title">{request.title}</h5>
|
||||||
|
<p className="card-text text-muted mb-2">
|
||||||
|
{request.description.length > 200
|
||||||
|
? `${request.description.substring(0, 200)}...`
|
||||||
|
: request.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<small className="text-muted">
|
||||||
|
Created on {new Date(request.createdAt).toLocaleDateString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="text-end">
|
||||||
|
<span className={`badge bg-${getStatusColor(request.status)} mb-2`}>
|
||||||
|
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<small className="text-muted">
|
||||||
|
{request.responseCount || 0} response{request.responseCount !== 1 ? 's' : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
{(request.maxPricePerDay || request.maxPricePerHour) && (
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-currency-dollar me-1"></i>
|
||||||
|
Budget:
|
||||||
|
{request.maxPricePerDay && ` $${request.maxPricePerDay}/day`}
|
||||||
|
{request.maxPricePerHour && ` $${request.maxPricePerHour}/hour`}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-calendar me-1"></i>
|
||||||
|
Dates: {request.isFlexibleDates ? 'Flexible' :
|
||||||
|
`${request.preferredStartDate ? new Date(request.preferredStartDate).toLocaleDateString() : 'TBD'} - ${request.preferredEndDate ? new Date(request.preferredEndDate).toLocaleDateString() : 'TBD'}`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{request.responses && request.responses.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h6>Responses:</h6>
|
||||||
|
{request.responses.map((response: ItemRequestResponse) => (
|
||||||
|
<div key={response.id} className="border-start ps-3 mb-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<strong>{response.responder?.firstName || 'Unknown'}</strong>
|
||||||
|
<small className="text-muted ms-2">
|
||||||
|
{new Date(response.createdAt).toLocaleDateString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<span className={`badge bg-${getResponseStatusColor(response.status)}`}>
|
||||||
|
{response.status.charAt(0).toUpperCase() + response.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2">{response.message}</p>
|
||||||
|
{(response.offerPricePerDay || response.offerPricePerHour) && (
|
||||||
|
<p className="mb-2 text-muted small">
|
||||||
|
Offered price:
|
||||||
|
{response.offerPricePerDay && ` $${response.offerPricePerDay}/day`}
|
||||||
|
{response.offerPricePerHour && ` $${response.offerPricePerHour}/hour`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{response.contactInfo && (
|
||||||
|
<p className="mb-2 text-muted small">
|
||||||
|
Contact: {response.contactInfo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{response.status === 'pending' && (
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-success"
|
||||||
|
onClick={() => handleResponseStatusUpdate(response.id, 'accepted')}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
onClick={() => handleResponseStatusUpdate(response.id, 'declined')}
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
<Link
|
||||||
|
to={`/item-requests/${request.id}`}
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
>
|
||||||
|
<i className="bi bi-eye me-1"></i>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
{request.status === 'open' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-sm"
|
||||||
|
onClick={() => setDeleteModal({ show: true, requestId: request.id })}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash me-1"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
show={deleteModal.show}
|
||||||
|
title="Delete Item Request"
|
||||||
|
message="Are you sure you want to delete this item request? This action cannot be undone."
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onClose={() => setDeleteModal({ show: false, requestId: null })}
|
||||||
|
confirmText="Delete"
|
||||||
|
confirmButtonClass="btn-danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyRequests;
|
||||||
@@ -80,4 +80,16 @@ export const messageAPI = {
|
|||||||
getUnreadCount: () => api.get("/messages/unread/count"),
|
getUnreadCount: () => api.get("/messages/unread/count"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const itemRequestAPI = {
|
||||||
|
getItemRequests: (params?: any) => api.get("/item-requests", { params }),
|
||||||
|
getItemRequest: (id: string) => api.get(`/item-requests/${id}`),
|
||||||
|
createItemRequest: (data: any) => api.post("/item-requests", data),
|
||||||
|
updateItemRequest: (id: string, data: any) => api.put(`/item-requests/${id}`, data),
|
||||||
|
deleteItemRequest: (id: string) => api.delete(`/item-requests/${id}`),
|
||||||
|
getMyRequests: () => api.get("/item-requests/my-requests"),
|
||||||
|
respondToRequest: (id: string, data: any) => api.post(`/item-requests/${id}/responses`, data),
|
||||||
|
updateResponseStatus: (responseId: string, status: string) =>
|
||||||
|
api.put(`/item-requests/responses/${responseId}/status`, { status }),
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -97,3 +97,48 @@ export interface Rental {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ItemRequest {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
address1?: string;
|
||||||
|
address2?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zipCode?: string;
|
||||||
|
country?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
maxPricePerHour?: number;
|
||||||
|
maxPricePerDay?: number;
|
||||||
|
preferredStartDate?: string;
|
||||||
|
preferredEndDate?: string;
|
||||||
|
isFlexibleDates: boolean;
|
||||||
|
status: "open" | "fulfilled" | "closed";
|
||||||
|
requesterId: string;
|
||||||
|
requester?: User;
|
||||||
|
responseCount: number;
|
||||||
|
responses?: ItemRequestResponse[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemRequestResponse {
|
||||||
|
id: string;
|
||||||
|
itemRequestId: string;
|
||||||
|
responderId: string;
|
||||||
|
message: string;
|
||||||
|
offerPricePerHour?: number;
|
||||||
|
offerPricePerDay?: number;
|
||||||
|
availableStartDate?: string;
|
||||||
|
availableEndDate?: string;
|
||||||
|
existingItemId?: string;
|
||||||
|
status: "pending" | "accepted" | "declined" | "expired";
|
||||||
|
contactInfo?: string;
|
||||||
|
responder?: User;
|
||||||
|
existingItem?: Item;
|
||||||
|
itemRequest?: ItemRequest;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user