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 Item = require('./Item');
const Rental = require('./Rental');
const Message = require('./Message');
User.hasMany(Item, { as: 'ownedItems', 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: '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 = {
sequelize,
User,
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) => {
try {
const { firstName, lastName, phone, address } = req.body;
const { firstName, lastName, phone, address, profileImage } = req.body;
await req.user.update({
firstName,
lastName,
phone,
address
address,
profileImage
});
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 itemRoutes = require('./routes/items');
const rentalRoutes = require('./routes/rentals');
const messageRoutes = require('./routes/messages');
const app = express();
@@ -19,6 +20,7 @@ app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/items', itemRoutes);
app.use('/api/rentals', rentalRoutes);
app.use('/api/messages', messageRoutes);
app.get('/', (req, res) => {
res.json({ message: 'Rentall API is running!' });

View File

@@ -13,6 +13,9 @@ import CreateItem from './pages/CreateItem';
import MyRentals from './pages/MyRentals';
import MyListings from './pages/MyListings';
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 './App.css';
@@ -27,6 +30,7 @@ function App() {
<Route path="/register" element={<Register />} />
<Route path="/items" element={<ItemList />} />
<Route path="/items/:id" element={<ItemDetail />} />
<Route path="/users/:id" element={<PublicProfile />} />
<Route
path="/items/:id/edit"
element={
@@ -75,6 +79,22 @@ function App() {
</PrivateRoute>
}
/>
<Route
path="/messages"
element={
<PrivateRoute>
<Messages />
</PrivateRoute>
}
/>
<Route
path="/messages/:id"
element={
<PrivateRoute>
<MessageDetail />
</PrivateRoute>
}
/>
</Routes>
</Router>
</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
</Link>
</li>
<li>
<Link className="dropdown-item" to="/messages">
<i className="bi bi-envelope me-2"></i>Messages
</Link>
</li>
<li>
<hr className="dropdown-divider" />
</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 (
<div>
<section className="py-5 bg-light">
{/* Hero Section */}
<div className="bg-primary text-white py-5">
<div className="container">
<div className="row align-items-center">
<div className="row align-items-center min-vh-50">
<div className="col-lg-6">
<h1 className="display-4 fw-bold mb-4">
Rent Equipment from Your Neighbors
Rent Anything, From Anyone, Anywhere
</h1>
<p className="lead mb-4">
Why buy when you can rent? Find gym equipment, tools, and musical instruments
available for rent in your area. Save money and space while getting access to
everything you need.
Join the sharing economy. Rent items you need for a fraction of the cost,
or earn money from things you already own.
</p>
<div className="d-flex gap-3">
<Link to="/items" className="btn btn-primary btn-lg">
Browse Items
<div className="d-flex gap-3 flex-wrap">
<Link to="/items" className="btn btn-light btn-lg">
Start Renting
</Link>
{user ? (
<Link to="/create-item" className="btn btn-outline-primary btn-lg">
List Your Item
<Link to="/create-item" className="btn btn-outline-light btn-lg">
List Your Items
</Link>
) : (
<Link to="/register" className="btn btn-outline-primary btn-lg">
Start Renting
<Link to="/register" className="btn btn-outline-light btn-lg">
Start Earning
</Link>
)}
</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">
<img
src="https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600"
alt="Equipment rental"
className="img-fluid rounded shadow"
/>
<h2 className="mb-4">Why Choose Rentall?</h2>
<div className="d-flex mb-3">
<i className="bi bi-check-circle-fill text-success me-3" style={{ fontSize: '1.5rem' }}></i>
<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>
</section>
</div>
<section className="py-5">
<div className="container">
<h2 className="text-center mb-5">Popular Categories</h2>
<div className="row g-4">
<div className="col-md-4">
<div className="card h-100 shadow-sm">
<div className="card-body text-center">
<i className="bi bi-tools display-3 text-primary mb-3"></i>
<h4>Tools</h4>
<p className="text-muted">
Power tools, hand tools, and equipment for your DIY projects
</p>
<Link to="/items?tags=tools" className="btn btn-sm btn-outline-primary">
Browse Tools
</Link>
</div>
</div>
</div>
<div className="col-md-4">
<div className="card h-100 shadow-sm">
<div className="card-body text-center">
<i className="bi bi-heart-pulse display-3 text-primary mb-3"></i>
<h4>Gym Equipment</h4>
<p className="text-muted">
Weights, machines, and fitness gear for your workout needs
</p>
<Link to="/items?tags=gym" className="btn btn-sm btn-outline-primary">
Browse Gym Equipment
</Link>
</div>
</div>
</div>
<div className="col-md-4">
<div className="card h-100 shadow-sm">
<div className="card-body text-center">
<i className="bi bi-music-note-beamed display-3 text-primary mb-3"></i>
<h4>Musical Instruments</h4>
<p className="text-muted">
Guitars, keyboards, drums, and more for musicians
</p>
<Link to="/items?tags=music" className="btn btn-sm btn-outline-primary">
Browse Instruments
</Link>
</div>
</div>
</div>
{/* CTA Section */}
<div className="bg-primary text-white py-5">
<div className="container text-center">
<h2 className="mb-4">Ready to Get Started?</h2>
<p className="lead mb-4">
Join thousands of people sharing and renting in your community
</p>
<div className="d-flex gap-3 justify-content-center flex-wrap">
<Link to="/items" className="btn btn-light btn-lg">
Browse Rentals
</Link>
{user ? (
<Link to="/create-item" className="btn btn-outline-light btn-lg">
List an Item
</Link>
) : (
<Link to="/register" className="btn btn-outline-light btn-lg">
Sign Up Free
</Link>
)}
</div>
</div>
</section>
</div>
<section className="py-5 bg-light">
{/* Stats Section */}
<div className="py-5">
<div className="container">
<h2 className="text-center mb-5">How It Works</h2>
<div className="row g-4">
<div className="col-md-3 text-center">
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
<span className="fs-3 fw-bold">1</span>
</div>
<h5 className="mt-3">Search</h5>
<p className="text-muted">Find the equipment you need in your area</p>
<div className="row text-center">
<div className="col-md-3">
<h2 className="text-primary">1000+</h2>
<p className="text-muted">Active Items</p>
</div>
<div className="col-md-3 text-center">
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
<span className="fs-3 fw-bold">2</span>
</div>
<h5 className="mt-3">Book</h5>
<p className="text-muted">Reserve items for the dates you need</p>
<div className="col-md-3">
<h2 className="text-primary">500+</h2>
<p className="text-muted">Happy Renters</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 className="col-md-3">
<h2 className="text-primary">$50k+</h2>
<p className="text-muted">Earned by Owners</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 className="col-md-3">
<h2 className="text-primary">4.8</h2>
<p className="text-muted">Average Rating</p>
</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 { useAuth } from '../contexts/AuthContext';
import { itemAPI, rentalAPI } from '../services/api';
import LocationMap from '../components/LocationMap';
import ItemReviews from '../components/ItemReviews';
const ItemDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -116,6 +118,30 @@ const ItemDetail: React.FC = () => {
<div className="col-md-8">
<h1>{item.name}</h1>
<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">
{item.tags.map((tag, index) => (
@@ -165,6 +191,13 @@ const ItemDetail: React.FC = () => {
)}
</div>
<LocationMap
latitude={item.latitude}
longitude={item.longitude}
location={item.location}
itemName={item.name}
/>
{item.rules && (
<div className="mb-4">
<h5>Rules</h5>
@@ -172,6 +205,8 @@ const ItemDetail: React.FC = () => {
</div>
)}
<ItemReviews itemId={item.id} />
<div className="d-flex gap-2">
{isOwner ? (
<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 { rentalAPI } from '../services/api';
import { Rental } from '../types';
import ReviewModal from '../components/ReviewModal';
import ConfirmationModal from '../components/ConfirmationModal';
const MyRentals: React.FC = () => {
const { user } = useAuth();
@@ -10,6 +12,11 @@ const MyRentals: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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(() => {
fetchRentals();
@@ -26,17 +33,37 @@ const MyRentals: React.FC = () => {
}
};
const cancelRental = async (rentalId: string) => {
if (!window.confirm('Are you sure you want to cancel this rental?')) return;
const handleCancelClick = (rentalId: string) => {
setRentalToCancel(rentalId);
setShowCancelModal(true);
};
const confirmCancelRental = async () => {
if (!rentalToCancel) return;
setCancelling(true);
try {
await rentalAPI.updateRentalStatus(rentalId, 'cancelled');
await rentalAPI.updateRentalStatus(rentalToCancel, 'cancelled');
fetchRentals(); // Refresh the list
setShowCancelModal(false);
setRentalToCancel(null);
} catch (err: any) {
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
const activeRentals = rentals.filter(r =>
['pending', 'confirmed', 'active'].includes(r.status)
@@ -175,16 +202,25 @@ const MyRentals: React.FC = () => {
{rental.status === 'pending' && (
<button
className="btn btn-sm btn-danger"
onClick={() => cancelRental(rental.id)}
onClick={() => handleCancelClick(rental.id)}
>
Cancel
</button>
)}
{rental.status === 'completed' && !rental.rating && (
<button className="btn btn-sm btn-primary">
<button
className="btn btn-sm btn-primary"
onClick={() => handleReviewClick(rental)}
>
Leave Review
</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>
@@ -193,6 +229,33 @@ const MyRentals: React.FC = () => {
))}
</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>
);
};

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 = {
getProfile: () => api.get('/users/profile'),
updateProfile: (data: any) => api.put('/users/profile', data),
getPublicProfile: (userId: string) => api.get(`/users/${userId}`),
};
export const itemAPI = {
@@ -57,4 +58,13 @@ export const rentalAPI = {
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;

View File

@@ -10,6 +10,21 @@ export interface User {
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 {
id: string;
name: string;