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 Rental = require('./Rental');
|
||||
const Message = require('./Message');
|
||||
const ItemRequest = require('./ItemRequest');
|
||||
const ItemRequestResponse = require('./ItemRequestResponse');
|
||||
|
||||
User.hasMany(Item, { as: 'ownedItems', 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.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 = {
|
||||
sequelize,
|
||||
User,
|
||||
Item,
|
||||
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 messageRoutes = require("./routes/messages");
|
||||
const betaRoutes = require("./routes/beta");
|
||||
const itemRequestRoutes = require("./routes/itemRequests");
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -37,6 +38,7 @@ app.use("/api/users", userRoutes);
|
||||
app.use("/api/items", itemRoutes);
|
||||
app.use("/api/rentals", rentalRoutes);
|
||||
app.use("/api/messages", messageRoutes);
|
||||
app.use("/api/item-requests", itemRequestRoutes);
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
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 Messages from './pages/Messages';
|
||||
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 './App.css';
|
||||
|
||||
@@ -99,6 +103,24 @@ function App() {
|
||||
<MessageDetail />
|
||||
</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>
|
||||
</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
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/my-requests">
|
||||
<i className="bi bi-clipboard-check me-2"></i>My Requests
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/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>
|
||||
|
||||
|
||||
{/* How It Works - For Renters */}
|
||||
<div className="py-5">
|
||||
<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"),
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -97,3 +97,48 @@ export interface Rental {
|
||||
createdAt: 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