messages and reviews

This commit is contained in:
jackiettran
2025-07-17 00:16:01 -04:00
parent aa3adc58ca
commit 1dbe821e70
21 changed files with 1981 additions and 102 deletions

50
backend/models/Message.js Normal file
View 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;

View File

@@ -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
View 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;

View File

@@ -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, {

View File

@@ -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!' });

View File

@@ -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>

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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>

View 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;

View File

@@ -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> </div>
</section>
<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">
<i className="bi bi-tools display-3 text-primary mb-3"></i>
<h4>Tools</h4>
<p className="text-muted">
Power tools, hand tools, and equipment for your DIY projects
</p> </p>
<Link to="/items?tags=tools" className="btn btn-sm btn-outline-primary"> <div className="d-flex gap-3 justify-content-center flex-wrap">
Browse Tools <Link to="/items" className="btn btn-light btn-lg">
Browse Rentals
</Link> </Link>
</div> {user ? (
</div> <Link to="/create-item" className="btn btn-outline-light btn-lg">
</div> List an Item
<div className="col-md-4">
<div className="card h-100 shadow-sm">
<div className="card-body text-center">
<i className="bi bi-heart-pulse display-3 text-primary mb-3"></i>
<h4>Gym Equipment</h4>
<p className="text-muted">
Weights, machines, and fitness gear for your workout needs
</p>
<Link to="/items?tags=gym" className="btn btn-sm btn-outline-primary">
Browse Gym Equipment
</Link> </Link>
</div> ) : (
</div> <Link to="/register" className="btn btn-outline-light btn-lg">
</div> Sign Up Free
<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> </Link>
)}
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</section>
<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> </div>
<h5 className="mt-3">Search</h5> <div className="col-md-3">
<p className="text-muted">Find the equipment you need in your area</p> <h2 className="text-primary">500+</h2>
<p className="text-muted">Happy Renters</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">2</span> <p className="text-muted">Earned by Owners</p>
</div>
<div className="col-md-3">
<h2 className="text-primary">4.8</h2>
<p className="text-muted">Average Rating</p>
</div> </div>
<h5 className="mt-3">Book</h5>
<p className="text-muted">Reserve items for the dates you need</p>
</div>
<div className="col-md-3 text-center">
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
<span className="fs-3 fw-bold">3</span>
</div>
<h5 className="mt-3">Pick Up</h5>
<p className="text-muted">Collect items or have them delivered</p>
</div>
<div className="col-md-3 text-center">
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
<span className="fs-3 fw-bold">4</span>
</div>
<h5 className="mt-3">Return</h5>
<p className="text-muted">Return items when you're done</p>
</div> </div>
</div> </div>
</div> </div>
</section>
</div> </div>
); );
}; };

View File

@@ -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}>

View 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;

View 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;

View File

@@ -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>
); );
}; };

View 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;

View File

@@ -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;

View File

@@ -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;