messages and reviews
This commit is contained in:
50
backend/models/Message.js
Normal file
50
backend/models/Message.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const Message = sequelize.define('Message', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
senderId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiverId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
isRead: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
parentMessageId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'Messages',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Message;
|
||||||
@@ -2,6 +2,7 @@ const sequelize = require('../config/database');
|
|||||||
const User = require('./User');
|
const User = require('./User');
|
||||||
const Item = require('./Item');
|
const Item = require('./Item');
|
||||||
const Rental = require('./Rental');
|
const Rental = require('./Rental');
|
||||||
|
const Message = require('./Message');
|
||||||
|
|
||||||
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' });
|
||||||
@@ -14,9 +15,17 @@ Rental.belongsTo(Item, { as: 'item', foreignKey: 'itemId' });
|
|||||||
Rental.belongsTo(User, { as: 'renter', foreignKey: 'renterId' });
|
Rental.belongsTo(User, { as: 'renter', foreignKey: 'renterId' });
|
||||||
Rental.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
Rental.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
||||||
|
|
||||||
|
User.hasMany(Message, { as: 'sentMessages', foreignKey: 'senderId' });
|
||||||
|
User.hasMany(Message, { as: 'receivedMessages', foreignKey: 'receiverId' });
|
||||||
|
Message.belongsTo(User, { as: 'sender', foreignKey: 'senderId' });
|
||||||
|
Message.belongsTo(User, { as: 'receiver', foreignKey: 'receiverId' });
|
||||||
|
Message.hasMany(Message, { as: 'replies', foreignKey: 'parentMessageId' });
|
||||||
|
Message.belongsTo(Message, { as: 'parentMessage', foreignKey: 'parentMessageId' });
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sequelize,
|
sequelize,
|
||||||
User,
|
User,
|
||||||
Item,
|
Item,
|
||||||
Rental
|
Rental,
|
||||||
|
Message
|
||||||
};
|
};
|
||||||
169
backend/routes/messages.js
Normal file
169
backend/routes/messages.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { Message, User } = require('../models');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all messages for the current user (inbox)
|
||||||
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const messages = await Message.findAll({
|
||||||
|
where: { receiverId: req.user.id },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'sender',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
res.json(messages);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get sent messages
|
||||||
|
router.get('/sent', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const messages = await Message.findAll({
|
||||||
|
where: { senderId: req.user.id },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'receiver',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
res.json(messages);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a single message with replies
|
||||||
|
router.get('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const message = await Message.findOne({
|
||||||
|
where: {
|
||||||
|
id: req.params.id,
|
||||||
|
[require('sequelize').Op.or]: [
|
||||||
|
{ senderId: req.user.id },
|
||||||
|
{ receiverId: req.user.id }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'sender',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'receiver',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Message,
|
||||||
|
as: 'replies',
|
||||||
|
include: [{
|
||||||
|
model: User,
|
||||||
|
as: 'sender',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return res.status(404).json({ error: 'Message not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as read if user is the receiver
|
||||||
|
if (message.receiverId === req.user.id && !message.isRead) {
|
||||||
|
await message.update({ isRead: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(message);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a new message
|
||||||
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { receiverId, subject, content, parentMessageId } = req.body;
|
||||||
|
|
||||||
|
// Check if receiver exists
|
||||||
|
const receiver = await User.findByPk(receiverId);
|
||||||
|
if (!receiver) {
|
||||||
|
return res.status(404).json({ error: 'Receiver not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent sending messages to self
|
||||||
|
if (receiverId === req.user.id) {
|
||||||
|
return res.status(400).json({ error: 'Cannot send messages to yourself' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await Message.create({
|
||||||
|
senderId: req.user.id,
|
||||||
|
receiverId,
|
||||||
|
subject,
|
||||||
|
content,
|
||||||
|
parentMessageId
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageWithSender = await Message.findByPk(message.id, {
|
||||||
|
include: [{
|
||||||
|
model: User,
|
||||||
|
as: 'sender',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(messageWithSender);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark message as read
|
||||||
|
router.put('/:id/read', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const message = await Message.findOne({
|
||||||
|
where: {
|
||||||
|
id: req.params.id,
|
||||||
|
receiverId: req.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return res.status(404).json({ error: 'Message not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await message.update({ isRead: true });
|
||||||
|
res.json(message);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get unread message count
|
||||||
|
router.get('/unread/count', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const count = await Message.count({
|
||||||
|
where: {
|
||||||
|
receiverId: req.user.id,
|
||||||
|
isRead: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json({ count });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -14,15 +14,32 @@ router.get('/profile', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findByPk(req.params.id, {
|
||||||
|
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/profile', authenticateToken, async (req, res) => {
|
router.put('/profile', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { firstName, lastName, phone, address } = req.body;
|
const { firstName, lastName, phone, address, profileImage } = req.body;
|
||||||
|
|
||||||
await req.user.update({
|
await req.user.update({
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
phone,
|
phone,
|
||||||
address
|
address,
|
||||||
|
profileImage
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedUser = await User.findByPk(req.user.id, {
|
const updatedUser = await User.findByPk(req.user.id, {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const authRoutes = require('./routes/auth');
|
|||||||
const userRoutes = require('./routes/users');
|
const userRoutes = require('./routes/users');
|
||||||
const itemRoutes = require('./routes/items');
|
const itemRoutes = require('./routes/items');
|
||||||
const rentalRoutes = require('./routes/rentals');
|
const rentalRoutes = require('./routes/rentals');
|
||||||
|
const messageRoutes = require('./routes/messages');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ app.use('/api/auth', authRoutes);
|
|||||||
app.use('/api/users', userRoutes);
|
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.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.json({ message: 'Rentall API is running!' });
|
res.json({ message: 'Rentall API is running!' });
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import CreateItem from './pages/CreateItem';
|
|||||||
import MyRentals from './pages/MyRentals';
|
import MyRentals from './pages/MyRentals';
|
||||||
import MyListings from './pages/MyListings';
|
import MyListings from './pages/MyListings';
|
||||||
import Profile from './pages/Profile';
|
import Profile from './pages/Profile';
|
||||||
|
import PublicProfile from './pages/PublicProfile';
|
||||||
|
import Messages from './pages/Messages';
|
||||||
|
import MessageDetail from './pages/MessageDetail';
|
||||||
import PrivateRoute from './components/PrivateRoute';
|
import PrivateRoute from './components/PrivateRoute';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -27,6 +30,7 @@ function App() {
|
|||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/items" element={<ItemList />} />
|
<Route path="/items" element={<ItemList />} />
|
||||||
<Route path="/items/:id" element={<ItemDetail />} />
|
<Route path="/items/:id" element={<ItemDetail />} />
|
||||||
|
<Route path="/users/:id" element={<PublicProfile />} />
|
||||||
<Route
|
<Route
|
||||||
path="/items/:id/edit"
|
path="/items/:id/edit"
|
||||||
element={
|
element={
|
||||||
@@ -75,6 +79,22 @@ function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/messages"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Messages />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/messages/:id"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<MessageDetail />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
252
frontend/src/components/ChatWindow.tsx
Normal file
252
frontend/src/components/ChatWindow.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { messageAPI } from '../services/api';
|
||||||
|
import { User, Message } from '../types';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
interface ChatWindowProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
recipient: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) => {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
fetchMessages();
|
||||||
|
}
|
||||||
|
}, [show, recipient.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch all messages between current user and recipient
|
||||||
|
const [sentRes, receivedRes] = await Promise.all([
|
||||||
|
messageAPI.getSentMessages(),
|
||||||
|
messageAPI.getMessages()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sentToRecipient = sentRes.data.filter((msg: Message) => msg.receiverId === recipient.id);
|
||||||
|
const receivedFromRecipient = receivedRes.data.filter((msg: Message) => msg.senderId === recipient.id);
|
||||||
|
|
||||||
|
// Combine and sort by date
|
||||||
|
const allMessages = [...sentToRecipient, ...receivedFromRecipient].sort(
|
||||||
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
setMessages(allMessages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch messages:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newMessage.trim()) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
const messageContent = newMessage;
|
||||||
|
setNewMessage(''); // Clear input immediately for better UX
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await messageAPI.sendMessage({
|
||||||
|
receiverId: recipient.id,
|
||||||
|
subject: `Message from ${currentUser?.firstName}`,
|
||||||
|
content: messageContent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the new message to the list
|
||||||
|
setMessages([...messages, response.data]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send message:', error);
|
||||||
|
setNewMessage(messageContent); // Restore message on error
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return 'Today';
|
||||||
|
}
|
||||||
|
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Yesterday';
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="position-fixed bottom-0 end-0 m-3 shadow-lg d-flex flex-column"
|
||||||
|
style={{
|
||||||
|
width: '350px',
|
||||||
|
height: '500px',
|
||||||
|
maxHeight: 'calc(100vh - 100px)',
|
||||||
|
zIndex: 1050,
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-primary text-white p-3 d-flex align-items-center justify-content-between flex-shrink-0">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{recipient.profileImage ? (
|
||||||
|
<img
|
||||||
|
src={recipient.profileImage}
|
||||||
|
alt={`${recipient.firstName} ${recipient.lastName}`}
|
||||||
|
className="rounded-circle me-2"
|
||||||
|
style={{ width: '35px', height: '35px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-white bg-opacity-25 d-flex align-items-center justify-content-center me-2"
|
||||||
|
style={{ width: '35px', height: '35px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h6 className="mb-0">{recipient.firstName} {recipient.lastName}</h6>
|
||||||
|
<small className="opacity-75">@{recipient.username}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close btn-close-white"
|
||||||
|
onClick={onClose}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div
|
||||||
|
className="p-3 overflow-auto flex-grow-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
minHeight: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<i className="bi bi-chat-dots" style={{ fontSize: '3rem', color: '#dee2e6' }}></i>
|
||||||
|
<p className="text-muted mt-2">Start a conversation with {recipient.firstName}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{messages.map((message, index) => {
|
||||||
|
const isCurrentUser = message.senderId === currentUser?.id;
|
||||||
|
const showDate = index === 0 ||
|
||||||
|
formatDate(message.createdAt) !== formatDate(messages[index - 1].createdAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={message.id}>
|
||||||
|
{showDate && (
|
||||||
|
<div className="text-center my-3">
|
||||||
|
<small className="text-muted bg-white px-2 py-1 rounded">
|
||||||
|
{formatDate(message.createdAt)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`d-flex mb-2 ${isCurrentUser ? 'justify-content-end' : ''}`}>
|
||||||
|
<div
|
||||||
|
className={`px-3 py-2 rounded-3 ${
|
||||||
|
isCurrentUser
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-white border'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
maxWidth: '75%',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-1" style={{ fontSize: '0.95rem' }}>
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
<small
|
||||||
|
className={isCurrentUser ? 'opacity-75' : 'text-muted'}
|
||||||
|
style={{ fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
{formatTime(message.createdAt)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<form onSubmit={handleSend} className="border-top p-3 flex-shrink-0">
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={sending || !newMessage.trim()}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
) : (
|
||||||
|
<i className="bi bi-send-fill"></i>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatWindow;
|
||||||
75
frontend/src/components/ConfirmationModal.tsx
Normal file
75
frontend/src/components/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ConfirmationModalProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
confirmButtonClass?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
confirmButtonClass = 'btn-danger',
|
||||||
|
loading = false
|
||||||
|
}) => {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">{title}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${confirmButtonClass}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
confirmText
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmationModal;
|
||||||
131
frontend/src/components/ItemReviews.tsx
Normal file
131
frontend/src/components/ItemReviews.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Rental } from '../types';
|
||||||
|
import { rentalAPI } from '../services/api';
|
||||||
|
|
||||||
|
interface ItemReviewsProps {
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
|
||||||
|
const [reviews, setReviews] = useState<Rental[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [averageRating, setAverageRating] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReviews();
|
||||||
|
}, [itemId]);
|
||||||
|
|
||||||
|
const fetchReviews = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch all rentals for this item
|
||||||
|
const response = await rentalAPI.getMyListings();
|
||||||
|
const allRentals: Rental[] = response.data;
|
||||||
|
|
||||||
|
// Filter for completed rentals with reviews for this specific item
|
||||||
|
const itemReviews = allRentals.filter(
|
||||||
|
rental => rental.itemId === itemId &&
|
||||||
|
rental.status === 'completed' &&
|
||||||
|
rental.rating &&
|
||||||
|
rental.review
|
||||||
|
);
|
||||||
|
|
||||||
|
setReviews(itemReviews);
|
||||||
|
|
||||||
|
// Calculate average rating
|
||||||
|
if (itemReviews.length > 0) {
|
||||||
|
const sum = itemReviews.reduce((acc, r) => acc + (r.rating || 0), 0);
|
||||||
|
setAverageRating(sum / itemReviews.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reviews:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStars = (rating: number) => {
|
||||||
|
return (
|
||||||
|
<span className="text-warning">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<i
|
||||||
|
key={star}
|
||||||
|
className={`bi ${star <= rating ? 'bi-star-fill' : 'bi-star'}`}
|
||||||
|
></i>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Reviews</h5>
|
||||||
|
<div className="text-center py-3">
|
||||||
|
<div className="spinner-border spinner-border-sm" role="status">
|
||||||
|
<span className="visually-hidden">Loading reviews...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Reviews</h5>
|
||||||
|
|
||||||
|
{reviews.length === 0 ? (
|
||||||
|
<p className="text-muted">No reviews yet. Be the first to rent and review this item!</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{renderStars(Math.round(averageRating))}
|
||||||
|
<span className="fw-bold">{averageRating.toFixed(1)}</span>
|
||||||
|
<span className="text-muted">({reviews.length} {reviews.length === 1 ? 'review' : 'reviews'})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-top pt-3">
|
||||||
|
{reviews.map((rental) => (
|
||||||
|
<div key={rental.id} className="mb-3 pb-3 border-bottom">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{rental.renter?.profileImage ? (
|
||||||
|
<img
|
||||||
|
src={rental.renter.profileImage}
|
||||||
|
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
|
||||||
|
className="rounded-circle"
|
||||||
|
style={{ width: '32px', height: '32px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
||||||
|
style={{ width: '32px', height: '32px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white" style={{ fontSize: '0.8rem' }}></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong>{rental.renter?.firstName} {rental.renter?.lastName}</strong>
|
||||||
|
<div className="small">
|
||||||
|
{renderStars(rental.rating || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small className="text-muted">
|
||||||
|
{new Date(rental.updatedAt).toLocaleDateString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<p className="mb-0">{rental.review}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemReviews;
|
||||||
83
frontend/src/components/LocationMap.tsx
Normal file
83
frontend/src/components/LocationMap.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface LocationMapProps {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
location: string;
|
||||||
|
itemName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationMap: React.FC<LocationMapProps> = ({ latitude, longitude, location, itemName }) => {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If we have coordinates, use them directly
|
||||||
|
if (latitude && longitude && mapRef.current) {
|
||||||
|
// Create a simple map using an iframe with OpenStreetMap
|
||||||
|
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${longitude-0.01},${latitude-0.01},${longitude+0.01},${latitude+0.01}&layer=mapnik&marker=${latitude},${longitude}`;
|
||||||
|
|
||||||
|
mapRef.current.innerHTML = `
|
||||||
|
<iframe
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
frameborder="0"
|
||||||
|
scrolling="no"
|
||||||
|
marginheight="0"
|
||||||
|
marginwidth="0"
|
||||||
|
src="${mapUrl}"
|
||||||
|
style="border: none; border-radius: 8px;"
|
||||||
|
></iframe>
|
||||||
|
`;
|
||||||
|
} else if (location && mapRef.current) {
|
||||||
|
// If we only have a location string, try to show it on the map
|
||||||
|
// For a more robust solution, you'd want to use a geocoding service
|
||||||
|
const encodedLocation = encodeURIComponent(location);
|
||||||
|
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=-180,-90,180,90&layer=mapnik&marker=`;
|
||||||
|
|
||||||
|
// For now, we'll show a static map with a search link
|
||||||
|
mapRef.current.innerHTML = `
|
||||||
|
<div class="text-center p-4">
|
||||||
|
<i class="bi bi-geo-alt-fill text-primary" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-2 mb-3"><strong>Location:</strong> ${location}</p>
|
||||||
|
<a
|
||||||
|
href="https://www.openstreetmap.org/search?query=${encodedLocation}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
>
|
||||||
|
<i class="bi bi-map me-2"></i>View on Map
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}, [latitude, longitude, location]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h5>Location</h5>
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
style={{
|
||||||
|
height: '300px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center justify-content-center h-100">
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">Loading map...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(latitude && longitude) && (
|
||||||
|
<p className="text-muted small mt-2">
|
||||||
|
<i className="bi bi-info-circle me-1"></i>
|
||||||
|
Exact location shown on map
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationMap;
|
||||||
121
frontend/src/components/MessageModal.tsx
Normal file
121
frontend/src/components/MessageModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { messageAPI } from '../services/api';
|
||||||
|
import { User } from '../types';
|
||||||
|
|
||||||
|
interface MessageModalProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
recipient: User;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, onSuccess }) => {
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await messageAPI.sendMessage({
|
||||||
|
receiverId: recipient.id,
|
||||||
|
subject,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
|
||||||
|
setSubject('');
|
||||||
|
setContent('');
|
||||||
|
onClose();
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to send message');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Send Message to {recipient.firstName} {recipient.lastName}</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="subject" className="form-label">Subject</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="content" className="form-label">Message</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="content"
|
||||||
|
rows={5}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
placeholder="Write your message here..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={sending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={sending || !subject || !content}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-send-fill me-2"></i>Send Message
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageModal;
|
||||||
@@ -75,6 +75,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="/messages">
|
||||||
|
<i className="bi bi-envelope me-2"></i>Messages
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<hr className="dropdown-divider" />
|
<hr className="dropdown-divider" />
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
146
frontend/src/components/ReviewModal.tsx
Normal file
146
frontend/src/components/ReviewModal.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { rentalAPI } from '../services/api';
|
||||||
|
import { Rental } from '../types';
|
||||||
|
|
||||||
|
interface ReviewModalProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
rental: Rental;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewModal: React.FC<ReviewModalProps> = ({ show, onClose, rental, onSuccess }) => {
|
||||||
|
const [rating, setRating] = useState(5);
|
||||||
|
const [review, setReview] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rentalAPI.addReview(rental.id, {
|
||||||
|
rating,
|
||||||
|
review
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setRating(5);
|
||||||
|
setReview('');
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to submit review');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStarClick = (value: number) => {
|
||||||
|
setRating(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Review Your Rental</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{rental.item && (
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<h6>{rental.item.name}</h6>
|
||||||
|
<small className="text-muted">
|
||||||
|
Rented from {new Date(rental.startDate).toLocaleDateString()} to {new Date(rental.endDate).toLocaleDateString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Rating</label>
|
||||||
|
<div className="d-flex justify-content-center gap-1" style={{ fontSize: '2rem' }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link p-0 text-decoration-none"
|
||||||
|
onClick={() => handleStarClick(star)}
|
||||||
|
style={{ color: star <= rating ? '#ffc107' : '#dee2e6' }}
|
||||||
|
>
|
||||||
|
<i className={`bi ${star <= rating ? 'bi-star-fill' : 'bi-star'}`}></i>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-2">
|
||||||
|
<small className="text-muted">
|
||||||
|
{rating === 1 && 'Poor'}
|
||||||
|
{rating === 2 && 'Fair'}
|
||||||
|
{rating === 3 && 'Good'}
|
||||||
|
{rating === 4 && 'Very Good'}
|
||||||
|
{rating === 5 && 'Excellent'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="review" className="form-label">Your Review</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="review"
|
||||||
|
rows={4}
|
||||||
|
value={review}
|
||||||
|
onChange={(e) => setReview(e.target.value)}
|
||||||
|
placeholder="Share your experience with this rental..."
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
></textarea>
|
||||||
|
<small className="text-muted">
|
||||||
|
Tell others about the item condition, owner communication, and overall experience
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={submitting || !review.trim()}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Submit Review'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReviewModal;
|
||||||
@@ -7,129 +7,264 @@ const Home: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section className="py-5 bg-light">
|
{/* Hero Section */}
|
||||||
|
<div className="bg-primary text-white py-5">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="row align-items-center">
|
<div className="row align-items-center min-vh-50">
|
||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
<h1 className="display-4 fw-bold mb-4">
|
<h1 className="display-4 fw-bold mb-4">
|
||||||
Rent Equipment from Your Neighbors
|
Rent Anything, From Anyone, Anywhere
|
||||||
</h1>
|
</h1>
|
||||||
<p className="lead mb-4">
|
<p className="lead mb-4">
|
||||||
Why buy when you can rent? Find gym equipment, tools, and musical instruments
|
Join the sharing economy. Rent items you need for a fraction of the cost,
|
||||||
available for rent in your area. Save money and space while getting access to
|
or earn money from things you already own.
|
||||||
everything you need.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="d-flex gap-3">
|
<div className="d-flex gap-3 flex-wrap">
|
||||||
<Link to="/items" className="btn btn-primary btn-lg">
|
<Link to="/items" className="btn btn-light btn-lg">
|
||||||
Browse Items
|
Start Renting
|
||||||
</Link>
|
</Link>
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link to="/create-item" className="btn btn-outline-primary btn-lg">
|
<Link to="/create-item" className="btn btn-outline-light btn-lg">
|
||||||
List Your Item
|
List Your Items
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link to="/register" className="btn btn-outline-primary btn-lg">
|
<Link to="/register" className="btn btn-outline-light btn-lg">
|
||||||
Start Renting
|
Start Earning
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-lg-6 text-center">
|
||||||
|
<i className="bi bi-box-seam" style={{ fontSize: '15rem', opacity: 0.3 }}></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How It Works - For Renters */}
|
||||||
|
<div className="py-5">
|
||||||
|
<div className="container">
|
||||||
|
<h2 className="text-center mb-5">For Renters: Get What You Need, When You Need It</h2>
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-md-4 text-center">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i className="bi bi-search text-primary" style={{ fontSize: '3rem' }}></i>
|
||||||
|
</div>
|
||||||
|
<h4>1. Find What You Need</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Browse our marketplace for tools, equipment, electronics, and more.
|
||||||
|
Filter by location, price, and availability.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4 text-center">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i className="bi bi-calendar-check text-primary" style={{ fontSize: '3rem' }}></i>
|
||||||
|
</div>
|
||||||
|
<h4>2. Book Your Rental</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Select your rental dates, choose delivery or pickup, and pay securely.
|
||||||
|
Your payment is held until the owner confirms.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4 text-center">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i className="bi bi-box-arrow-right text-primary" style={{ fontSize: '3rem' }}></i>
|
||||||
|
</div>
|
||||||
|
<h4>3. Enjoy & Return</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Use the item for your project or event, then return it as agreed.
|
||||||
|
Rate your experience to help the community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How It Works - For Owners */}
|
||||||
|
<div className="py-5 bg-light">
|
||||||
|
<div className="container">
|
||||||
|
<h2 className="text-center mb-5">For Owners: Turn Your Idle Items Into Income</h2>
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-md-4 text-center">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i className="bi bi-camera text-success" style={{ fontSize: '3rem' }}></i>
|
||||||
|
</div>
|
||||||
|
<h4>1. List Your Items</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Take photos, set your price, and choose when your items are available.
|
||||||
|
List anything from power tools to party supplies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4 text-center">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i className="bi bi-bell text-success" style={{ fontSize: '3rem' }}></i>
|
||||||
|
</div>
|
||||||
|
<h4>2. Accept Requests</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Review rental requests and accept the ones that work for you.
|
||||||
|
Set your own rules and requirements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4 text-center">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i className="bi bi-cash-stack text-success" style={{ fontSize: '3rem' }}></i>
|
||||||
|
</div>
|
||||||
|
<h4>3. Get Paid</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
Earn money from items sitting in your garage.
|
||||||
|
Payments are processed securely after each rental.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Popular Categories */}
|
||||||
|
<div className="py-5">
|
||||||
|
<div className="container">
|
||||||
|
<h2 className="text-center mb-5">Popular Rental Categories</h2>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-6 col-md-4 col-lg-2">
|
||||||
|
<div className="card h-100 text-center border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<i className="bi bi-tools text-primary mb-2" style={{ fontSize: '2rem' }}></i>
|
||||||
|
<h6 className="mb-0">Tools</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 col-md-4 col-lg-2">
|
||||||
|
<div className="card h-100 text-center border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<i className="bi bi-camera-fill text-primary mb-2" style={{ fontSize: '2rem' }}></i>
|
||||||
|
<h6 className="mb-0">Electronics</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 col-md-4 col-lg-2">
|
||||||
|
<div className="card h-100 text-center border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<i className="bi bi-bicycle text-primary mb-2" style={{ fontSize: '2rem' }}></i>
|
||||||
|
<h6 className="mb-0">Sports</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 col-md-4 col-lg-2">
|
||||||
|
<div className="card h-100 text-center border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<i className="bi bi-music-note-beamed text-primary mb-2" style={{ fontSize: '2rem' }}></i>
|
||||||
|
<h6 className="mb-0">Music</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 col-md-4 col-lg-2">
|
||||||
|
<div className="card h-100 text-center border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<i className="bi bi-balloon text-primary mb-2" style={{ fontSize: '2rem' }}></i>
|
||||||
|
<h6 className="mb-0">Party</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 col-md-4 col-lg-2">
|
||||||
|
<div className="card h-100 text-center border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<i className="bi bi-tree text-primary mb-2" style={{ fontSize: '2rem' }}></i>
|
||||||
|
<h6 className="mb-0">Outdoor</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefits Section */}
|
||||||
|
<div className="py-5 bg-light">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row align-items-center">
|
||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
<img
|
<h2 className="mb-4">Why Choose Rentall?</h2>
|
||||||
src="https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600"
|
<div className="d-flex mb-3">
|
||||||
alt="Equipment rental"
|
<i className="bi bi-check-circle-fill text-success me-3" style={{ fontSize: '1.5rem' }}></i>
|
||||||
className="img-fluid rounded shadow"
|
<div>
|
||||||
/>
|
<h5>Save Money</h5>
|
||||||
|
<p className="text-muted">Why buy when you can rent? Access expensive items for a fraction of the purchase price.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex mb-3">
|
||||||
|
<i className="bi bi-check-circle-fill text-success me-3" style={{ fontSize: '1.5rem' }}></i>
|
||||||
|
<div>
|
||||||
|
<h5>Earn Extra Income</h5>
|
||||||
|
<p className="text-muted">Turn your unused items into a revenue stream. Your garage could be a goldmine.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex mb-3">
|
||||||
|
<i className="bi bi-check-circle-fill text-success me-3" style={{ fontSize: '1.5rem' }}></i>
|
||||||
|
<div>
|
||||||
|
<h5>Build Community</h5>
|
||||||
|
<p className="text-muted">Connect with neighbors and help each other. Sharing builds stronger communities.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex">
|
||||||
|
<i className="bi bi-check-circle-fill text-success me-3" style={{ fontSize: '1.5rem' }}></i>
|
||||||
|
<div>
|
||||||
|
<h5>Reduce Waste</h5>
|
||||||
|
<p className="text-muted">Share instead of everyone buying. It's better for your wallet and the planet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center">
|
||||||
|
<i className="bi bi-shield-check" style={{ fontSize: '15rem', color: '#e0e0e0' }}></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="py-5">
|
{/* CTA Section */}
|
||||||
<div className="container">
|
<div className="bg-primary text-white py-5">
|
||||||
<h2 className="text-center mb-5">Popular Categories</h2>
|
<div className="container text-center">
|
||||||
<div className="row g-4">
|
<h2 className="mb-4">Ready to Get Started?</h2>
|
||||||
<div className="col-md-4">
|
<p className="lead mb-4">
|
||||||
<div className="card h-100 shadow-sm">
|
Join thousands of people sharing and renting in your community
|
||||||
<div className="card-body text-center">
|
</p>
|
||||||
<i className="bi bi-tools display-3 text-primary mb-3"></i>
|
<div className="d-flex gap-3 justify-content-center flex-wrap">
|
||||||
<h4>Tools</h4>
|
<Link to="/items" className="btn btn-light btn-lg">
|
||||||
<p className="text-muted">
|
Browse Rentals
|
||||||
Power tools, hand tools, and equipment for your DIY projects
|
</Link>
|
||||||
</p>
|
{user ? (
|
||||||
<Link to="/items?tags=tools" className="btn btn-sm btn-outline-primary">
|
<Link to="/create-item" className="btn btn-outline-light btn-lg">
|
||||||
Browse Tools
|
List an Item
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<Link to="/register" className="btn btn-outline-light btn-lg">
|
||||||
</div>
|
Sign Up Free
|
||||||
<div className="col-md-4">
|
</Link>
|
||||||
<div className="card h-100 shadow-sm">
|
)}
|
||||||
<div className="card-body text-center">
|
|
||||||
<i className="bi bi-heart-pulse display-3 text-primary mb-3"></i>
|
|
||||||
<h4>Gym Equipment</h4>
|
|
||||||
<p className="text-muted">
|
|
||||||
Weights, machines, and fitness gear for your workout needs
|
|
||||||
</p>
|
|
||||||
<Link to="/items?tags=gym" className="btn btn-sm btn-outline-primary">
|
|
||||||
Browse Gym Equipment
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-4">
|
|
||||||
<div className="card h-100 shadow-sm">
|
|
||||||
<div className="card-body text-center">
|
|
||||||
<i className="bi bi-music-note-beamed display-3 text-primary mb-3"></i>
|
|
||||||
<h4>Musical Instruments</h4>
|
|
||||||
<p className="text-muted">
|
|
||||||
Guitars, keyboards, drums, and more for musicians
|
|
||||||
</p>
|
|
||||||
<Link to="/items?tags=music" className="btn btn-sm btn-outline-primary">
|
|
||||||
Browse Instruments
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="py-5 bg-light">
|
{/* Stats Section */}
|
||||||
|
<div className="py-5">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h2 className="text-center mb-5">How It Works</h2>
|
<div className="row text-center">
|
||||||
<div className="row g-4">
|
<div className="col-md-3">
|
||||||
<div className="col-md-3 text-center">
|
<h2 className="text-primary">1000+</h2>
|
||||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
<p className="text-muted">Active Items</p>
|
||||||
<span className="fs-3 fw-bold">1</span>
|
|
||||||
</div>
|
|
||||||
<h5 className="mt-3">Search</h5>
|
|
||||||
<p className="text-muted">Find the equipment you need in your area</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3 text-center">
|
<div className="col-md-3">
|
||||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
<h2 className="text-primary">500+</h2>
|
||||||
<span className="fs-3 fw-bold">2</span>
|
<p className="text-muted">Happy Renters</p>
|
||||||
</div>
|
|
||||||
<h5 className="mt-3">Book</h5>
|
|
||||||
<p className="text-muted">Reserve items for the dates you need</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3 text-center">
|
<div className="col-md-3">
|
||||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
<h2 className="text-primary">$50k+</h2>
|
||||||
<span className="fs-3 fw-bold">3</span>
|
<p className="text-muted">Earned by Owners</p>
|
||||||
</div>
|
|
||||||
<h5 className="mt-3">Pick Up</h5>
|
|
||||||
<p className="text-muted">Collect items or have them delivered</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3 text-center">
|
<div className="col-md-3">
|
||||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
<h2 className="text-primary">4.8★</h2>
|
||||||
<span className="fs-3 fw-bold">4</span>
|
<p className="text-muted">Average Rating</p>
|
||||||
</div>
|
|
||||||
<h5 className="mt-3">Return</h5>
|
|
||||||
<p className="text-muted">Return items when you're done</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { Item, Rental } from '../types';
|
import { Item, Rental } from '../types';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { itemAPI, rentalAPI } from '../services/api';
|
import { itemAPI, rentalAPI } from '../services/api';
|
||||||
|
import LocationMap from '../components/LocationMap';
|
||||||
|
import ItemReviews from '../components/ItemReviews';
|
||||||
|
|
||||||
const ItemDetail: React.FC = () => {
|
const ItemDetail: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -116,6 +118,30 @@ const ItemDetail: React.FC = () => {
|
|||||||
<div className="col-md-8">
|
<div className="col-md-8">
|
||||||
<h1>{item.name}</h1>
|
<h1>{item.name}</h1>
|
||||||
<p className="text-muted">{item.location}</p>
|
<p className="text-muted">{item.location}</p>
|
||||||
|
{item.owner && (
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center mt-2 mb-3"
|
||||||
|
onClick={() => navigate(`/users/${item.ownerId}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{item.owner.profileImage ? (
|
||||||
|
<img
|
||||||
|
src={item.owner.profileImage}
|
||||||
|
alt={`${item.owner.firstName} ${item.owner.lastName}`}
|
||||||
|
className="rounded-circle me-2"
|
||||||
|
style={{ width: '30px', height: '30px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
|
||||||
|
style={{ width: '30px', height: '30px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white" style={{ fontSize: '0.8rem' }}></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-muted">{item.owner.firstName} {item.owner.lastName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
{item.tags.map((tag, index) => (
|
{item.tags.map((tag, index) => (
|
||||||
@@ -165,6 +191,13 @@ const ItemDetail: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LocationMap
|
||||||
|
latitude={item.latitude}
|
||||||
|
longitude={item.longitude}
|
||||||
|
location={item.location}
|
||||||
|
itemName={item.name}
|
||||||
|
/>
|
||||||
|
|
||||||
{item.rules && (
|
{item.rules && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h5>Rules</h5>
|
<h5>Rules</h5>
|
||||||
@@ -172,6 +205,8 @@ const ItemDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ItemReviews itemId={item.id} />
|
||||||
|
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
<button className="btn btn-primary" onClick={handleEdit}>
|
<button className="btn btn-primary" onClick={handleEdit}>
|
||||||
|
|||||||
215
frontend/src/pages/MessageDetail.tsx
Normal file
215
frontend/src/pages/MessageDetail.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Message } from '../types';
|
||||||
|
import { messageAPI } from '../services/api';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const MessageDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [message, setMessage] = useState<Message | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [replyContent, setReplyContent] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMessage();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchMessage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await messageAPI.getMessage(id!);
|
||||||
|
setMessage(response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to fetch message');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReply = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
|
||||||
|
await messageAPI.sendMessage({
|
||||||
|
receiverId: recipientId,
|
||||||
|
subject: `Re: ${message.subject}`,
|
||||||
|
content: replyContent,
|
||||||
|
parentMessageId: message.id
|
||||||
|
});
|
||||||
|
|
||||||
|
setReplyContent('');
|
||||||
|
fetchMessage(); // Refresh to show the new reply
|
||||||
|
alert('Reply sent successfully!');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to send reply');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (!message) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
Message not found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isReceiver = message.receiverId === user?.id;
|
||||||
|
const otherUser = isReceiver ? message.sender : message.receiver;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<button
|
||||||
|
className="btn btn-link text-decoration-none mb-3"
|
||||||
|
onClick={() => navigate('/messages')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-left"></i> Back to Messages
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{otherUser?.profileImage ? (
|
||||||
|
<img
|
||||||
|
src={otherUser.profileImage}
|
||||||
|
alt={`${otherUser.firstName} ${otherUser.lastName}`}
|
||||||
|
className="rounded-circle me-3"
|
||||||
|
style={{ width: '50px', height: '50px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
|
||||||
|
style={{ width: '50px', height: '50px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h5 className="mb-0">{message.subject}</h5>
|
||||||
|
<small className="text-muted">
|
||||||
|
{isReceiver ? 'From' : 'To'}: {otherUser?.firstName} {otherUser?.lastName} • {formatDateTime(message.createdAt)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p style={{ whiteSpace: 'pre-wrap' }}>{message.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.replies && message.replies.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h6>Replies</h6>
|
||||||
|
{message.replies.map((reply) => (
|
||||||
|
<div key={reply.id} className="card mb-2">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
{reply.sender?.profileImage ? (
|
||||||
|
<img
|
||||||
|
src={reply.sender.profileImage}
|
||||||
|
alt={`${reply.sender.firstName} ${reply.sender.lastName}`}
|
||||||
|
className="rounded-circle me-2"
|
||||||
|
style={{ width: '30px', height: '30px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
|
||||||
|
style={{ width: '30px', height: '30px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white" style={{ fontSize: '0.8rem' }}></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong>{reply.sender?.firstName} {reply.sender?.lastName}</strong>
|
||||||
|
<small className="text-muted ms-2">{formatDateTime(reply.createdAt)}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mb-0" style={{ whiteSpace: 'pre-wrap' }}>{reply.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card mt-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h6>Send Reply</h6>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleReply}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
rows={4}
|
||||||
|
value={replyContent}
|
||||||
|
onChange={(e) => setReplyContent(e.target.value)}
|
||||||
|
placeholder="Type your reply..."
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={sending || !replyContent.trim()}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-send-fill me-2"></i>Send Reply
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageDetail;
|
||||||
166
frontend/src/pages/Messages.tsx
Normal file
166
frontend/src/pages/Messages.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Message, User } from '../types';
|
||||||
|
import { messageAPI } from '../services/api';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import ChatWindow from '../components/ChatWindow';
|
||||||
|
|
||||||
|
const Messages: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedRecipient, setSelectedRecipient] = useState<User | null>(null);
|
||||||
|
const [showChat, setShowChat] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMessages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
try {
|
||||||
|
const response = await messageAPI.getMessages();
|
||||||
|
setMessages(response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to fetch messages');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (diffInHours < 24) {
|
||||||
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
} else if (diffInHours < 48) {
|
||||||
|
return 'Yesterday';
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessageClick = async (message: Message) => {
|
||||||
|
// Mark as read if unread
|
||||||
|
if (!message.isRead) {
|
||||||
|
try {
|
||||||
|
await messageAPI.markAsRead(message.id);
|
||||||
|
// Update local state
|
||||||
|
setMessages(messages.map(m =>
|
||||||
|
m.id === message.id ? { ...m, isRead: true } : m
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to mark message as read:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open chat with sender
|
||||||
|
if (message.sender) {
|
||||||
|
setSelectedRecipient(message.sender);
|
||||||
|
setShowChat(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<h1 className="mb-4">Messages</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<i className="bi bi-envelope" style={{ fontSize: '3rem', color: '#ccc' }}></i>
|
||||||
|
<p className="text-muted mt-2">No messages in your inbox</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="list-group">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`list-group-item list-group-item-action ${!message.isRead ? 'border-start border-primary border-4' : ''}`}
|
||||||
|
onClick={() => handleMessageClick(message)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: !message.isRead ? '#f0f7ff' : 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="d-flex w-100 justify-content-between">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{message.sender?.profileImage ? (
|
||||||
|
<img
|
||||||
|
src={message.sender.profileImage}
|
||||||
|
alt={`${message.sender.firstName} ${message.sender.lastName}`}
|
||||||
|
className="rounded-circle me-3"
|
||||||
|
style={{ width: '40px', height: '40px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
|
||||||
|
style={{ width: '40px', height: '40px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<h6 className={`mb-1 ${!message.isRead ? 'fw-bold' : ''}`}>
|
||||||
|
{message.sender?.firstName} {message.sender?.lastName}
|
||||||
|
</h6>
|
||||||
|
{!message.isRead && (
|
||||||
|
<span className="badge bg-primary ms-2">New</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`mb-1 text-truncate ${!message.isRead ? 'fw-semibold' : ''}`} style={{ maxWidth: '400px' }}>
|
||||||
|
{message.subject}
|
||||||
|
</p>
|
||||||
|
<small className="text-muted text-truncate d-block" style={{ maxWidth: '400px' }}>
|
||||||
|
{message.content}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small className="text-muted">{formatDate(message.createdAt)}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRecipient && (
|
||||||
|
<ChatWindow
|
||||||
|
show={showChat}
|
||||||
|
onClose={() => {
|
||||||
|
setShowChat(false);
|
||||||
|
setSelectedRecipient(null);
|
||||||
|
}}
|
||||||
|
recipient={selectedRecipient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Messages;
|
||||||
@@ -3,6 +3,8 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { rentalAPI } from '../services/api';
|
import { rentalAPI } from '../services/api';
|
||||||
import { Rental } from '../types';
|
import { Rental } from '../types';
|
||||||
|
import ReviewModal from '../components/ReviewModal';
|
||||||
|
import ConfirmationModal from '../components/ConfirmationModal';
|
||||||
|
|
||||||
const MyRentals: React.FC = () => {
|
const MyRentals: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -10,6 +12,11 @@ const MyRentals: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'active' | 'past'>('active');
|
const [activeTab, setActiveTab] = useState<'active' | 'past'>('active');
|
||||||
|
const [showReviewModal, setShowReviewModal] = useState(false);
|
||||||
|
const [selectedRental, setSelectedRental] = useState<Rental | null>(null);
|
||||||
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||||
|
const [rentalToCancel, setRentalToCancel] = useState<string | null>(null);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRentals();
|
fetchRentals();
|
||||||
@@ -26,17 +33,37 @@ const MyRentals: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelRental = async (rentalId: string) => {
|
const handleCancelClick = (rentalId: string) => {
|
||||||
if (!window.confirm('Are you sure you want to cancel this rental?')) return;
|
setRentalToCancel(rentalId);
|
||||||
|
setShowCancelModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCancelRental = async () => {
|
||||||
|
if (!rentalToCancel) return;
|
||||||
|
|
||||||
|
setCancelling(true);
|
||||||
try {
|
try {
|
||||||
await rentalAPI.updateRentalStatus(rentalId, 'cancelled');
|
await rentalAPI.updateRentalStatus(rentalToCancel, 'cancelled');
|
||||||
fetchRentals(); // Refresh the list
|
fetchRentals(); // Refresh the list
|
||||||
|
setShowCancelModal(false);
|
||||||
|
setRentalToCancel(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert('Failed to cancel rental');
|
alert('Failed to cancel rental');
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReviewClick = (rental: Rental) => {
|
||||||
|
setSelectedRental(rental);
|
||||||
|
setShowReviewModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviewSuccess = () => {
|
||||||
|
fetchRentals(); // Refresh to show the review has been added
|
||||||
|
alert('Thank you for your review!');
|
||||||
|
};
|
||||||
|
|
||||||
// Filter rentals based on status
|
// Filter rentals based on status
|
||||||
const activeRentals = rentals.filter(r =>
|
const activeRentals = rentals.filter(r =>
|
||||||
['pending', 'confirmed', 'active'].includes(r.status)
|
['pending', 'confirmed', 'active'].includes(r.status)
|
||||||
@@ -175,16 +202,25 @@ const MyRentals: React.FC = () => {
|
|||||||
{rental.status === 'pending' && (
|
{rental.status === 'pending' && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-danger"
|
className="btn btn-sm btn-danger"
|
||||||
onClick={() => cancelRental(rental.id)}
|
onClick={() => handleCancelClick(rental.id)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{rental.status === 'completed' && !rental.rating && (
|
{rental.status === 'completed' && !rental.rating && (
|
||||||
<button className="btn btn-sm btn-primary">
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => handleReviewClick(rental)}
|
||||||
|
>
|
||||||
Leave Review
|
Leave Review
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{rental.status === 'completed' && rental.rating && (
|
||||||
|
<div className="text-success small">
|
||||||
|
<i className="bi bi-check-circle-fill me-1"></i>
|
||||||
|
Reviewed ({rental.rating}/5)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +229,33 @@ const MyRentals: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedRental && (
|
||||||
|
<ReviewModal
|
||||||
|
show={showReviewModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowReviewModal(false);
|
||||||
|
setSelectedRental(null);
|
||||||
|
}}
|
||||||
|
rental={selectedRental}
|
||||||
|
onSuccess={handleReviewSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
show={showCancelModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowCancelModal(false);
|
||||||
|
setRentalToCancel(null);
|
||||||
|
}}
|
||||||
|
onConfirm={confirmCancelRental}
|
||||||
|
title="Cancel Rental"
|
||||||
|
message="Are you sure you want to cancel this rental? This action cannot be undone."
|
||||||
|
confirmText="Yes, Cancel Rental"
|
||||||
|
cancelText="Keep Rental"
|
||||||
|
confirmButtonClass="btn-danger"
|
||||||
|
loading={cancelling}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
160
frontend/src/pages/PublicProfile.tsx
Normal file
160
frontend/src/pages/PublicProfile.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { User, Item } from '../types';
|
||||||
|
import { userAPI, itemAPI } from '../services/api';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const PublicProfile: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserProfile();
|
||||||
|
fetchUserItems();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchUserProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await userAPI.getPublicProfile(id!);
|
||||||
|
setUser(response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to fetch user profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUserItems = async () => {
|
||||||
|
try {
|
||||||
|
const response = await itemAPI.getItems();
|
||||||
|
const allItems = response.data.items || response.data || [];
|
||||||
|
const userItems = allItems.filter((item: Item) => item.ownerId === id);
|
||||||
|
setItems(userItems);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch user items:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 || !user) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-5">
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error || 'User not found'}
|
||||||
|
</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-body">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
{user.profileImage ? (
|
||||||
|
<img
|
||||||
|
src={user.profileImage}
|
||||||
|
alt={`${user.firstName} ${user.lastName}`}
|
||||||
|
className="rounded-circle mb-3"
|
||||||
|
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center mb-3 mx-auto"
|
||||||
|
style={{ width: '150px', height: '150px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white" style={{ fontSize: '3rem' }}></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3>{user.firstName} {user.lastName}</h3>
|
||||||
|
<p className="text-muted">@{user.username}</p>
|
||||||
|
{user.isVerified && (
|
||||||
|
<span className="badge bg-success">
|
||||||
|
<i className="bi bi-check-circle-fill"></i> Verified User
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{currentUser && currentUser.id !== user.id && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary mt-3"
|
||||||
|
onClick={() => navigate('/messages')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-chat-dots-fill me-2"></i>Message
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mt-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Items Listed ({items.length})</h5>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="row">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="col-md-6 mb-3">
|
||||||
|
<div
|
||||||
|
className="card h-100 cursor-pointer"
|
||||||
|
onClick={() => navigate(`/items/${item.id}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{item.images.length > 0 ? (
|
||||||
|
<img
|
||||||
|
src={item.images[0]}
|
||||||
|
className="card-img-top"
|
||||||
|
alt={item.name}
|
||||||
|
style={{ height: '200px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-light d-flex align-items-center justify-content-center" style={{ height: '200px' }}>
|
||||||
|
<span className="text-muted">No image</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="card-body">
|
||||||
|
<h6 className="card-title">{item.name}</h6>
|
||||||
|
<p className="card-text text-muted small">{item.location}</p>
|
||||||
|
<div>
|
||||||
|
{item.pricePerDay && (
|
||||||
|
<span className="badge bg-primary">${item.pricePerDay}/day</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted">No items listed yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublicProfile;
|
||||||
@@ -36,6 +36,7 @@ export const authAPI = {
|
|||||||
export const userAPI = {
|
export const userAPI = {
|
||||||
getProfile: () => api.get('/users/profile'),
|
getProfile: () => api.get('/users/profile'),
|
||||||
updateProfile: (data: any) => api.put('/users/profile', data),
|
updateProfile: (data: any) => api.put('/users/profile', data),
|
||||||
|
getPublicProfile: (userId: string) => api.get(`/users/${userId}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const itemAPI = {
|
export const itemAPI = {
|
||||||
@@ -57,4 +58,13 @@ export const rentalAPI = {
|
|||||||
api.post(`/rentals/${id}/review`, data),
|
api.post(`/rentals/${id}/review`, data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const messageAPI = {
|
||||||
|
getMessages: () => api.get('/messages'),
|
||||||
|
getSentMessages: () => api.get('/messages/sent'),
|
||||||
|
getMessage: (id: string) => api.get(`/messages/${id}`),
|
||||||
|
sendMessage: (data: any) => api.post('/messages', data),
|
||||||
|
markAsRead: (id: string) => api.put(`/messages/${id}/read`),
|
||||||
|
getUnreadCount: () => api.get('/messages/unread/count'),
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
@@ -10,6 +10,21 @@ export interface User {
|
|||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
senderId: string;
|
||||||
|
receiverId: string;
|
||||||
|
subject: string;
|
||||||
|
content: string;
|
||||||
|
isRead: boolean;
|
||||||
|
parentMessageId?: string;
|
||||||
|
sender?: User;
|
||||||
|
receiver?: User;
|
||||||
|
replies?: Message[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user