From 1dbe821e7046c7e029983eff9f5c1612672080a9 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Thu, 17 Jul 2025 00:16:01 -0400 Subject: [PATCH] messages and reviews --- backend/models/Message.js | 50 +++ backend/models/index.js | 11 +- backend/routes/messages.js | 169 +++++++++ backend/routes/users.js | 21 +- backend/server.js | 2 + frontend/src/App.tsx | 20 ++ frontend/src/components/ChatWindow.tsx | 252 ++++++++++++++ frontend/src/components/ConfirmationModal.tsx | 75 ++++ frontend/src/components/ItemReviews.tsx | 131 +++++++ frontend/src/components/LocationMap.tsx | 83 +++++ frontend/src/components/MessageModal.tsx | 121 +++++++ frontend/src/components/Navbar.tsx | 5 + frontend/src/components/ReviewModal.tsx | 146 ++++++++ frontend/src/pages/Home.tsx | 323 +++++++++++++----- frontend/src/pages/ItemDetail.tsx | 35 ++ frontend/src/pages/MessageDetail.tsx | 215 ++++++++++++ frontend/src/pages/Messages.tsx | 166 +++++++++ frontend/src/pages/MyRentals.tsx | 73 +++- frontend/src/pages/PublicProfile.tsx | 160 +++++++++ frontend/src/services/api.ts | 10 + frontend/src/types/index.ts | 15 + 21 files changed, 1981 insertions(+), 102 deletions(-) create mode 100644 backend/models/Message.js create mode 100644 backend/routes/messages.js create mode 100644 frontend/src/components/ChatWindow.tsx create mode 100644 frontend/src/components/ConfirmationModal.tsx create mode 100644 frontend/src/components/ItemReviews.tsx create mode 100644 frontend/src/components/LocationMap.tsx create mode 100644 frontend/src/components/MessageModal.tsx create mode 100644 frontend/src/components/ReviewModal.tsx create mode 100644 frontend/src/pages/MessageDetail.tsx create mode 100644 frontend/src/pages/Messages.tsx create mode 100644 frontend/src/pages/PublicProfile.tsx diff --git a/backend/models/Message.js b/backend/models/Message.js new file mode 100644 index 0000000..056c600 --- /dev/null +++ b/backend/models/Message.js @@ -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; \ No newline at end of file diff --git a/backend/models/index.js b/backend/models/index.js index 3e68378..704739b 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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 }; \ No newline at end of file diff --git a/backend/routes/messages.js b/backend/routes/messages.js new file mode 100644 index 0000000..e15ac50 --- /dev/null +++ b/backend/routes/messages.js @@ -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; \ No newline at end of file diff --git a/backend/routes/users.js b/backend/routes/users.js index 32f52c4..9e2ebf8 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -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, { diff --git a/backend/server.js b/backend/server.js index cbe7adc..b37ed58 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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!' }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4551a61..b684077 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> + + + + } + /> + + + + } + /> diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx new file mode 100644 index 0000000..b573a76 --- /dev/null +++ b/frontend/src/components/ChatWindow.tsx @@ -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 = ({ show, onClose, recipient }) => { + const { user: currentUser } = useAuth(); + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [sending, setSending] = useState(false); + const [loading, setLoading] = useState(true); + const messagesEndRef = useRef(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 ( +
+ {/* Header */} +
+
+ {recipient.profileImage ? ( + {`${recipient.firstName} + ) : ( +
+ +
+ )} +
+
{recipient.firstName} {recipient.lastName}
+ @{recipient.username} +
+
+ +
+ + {/* Messages Area */} +
+ {loading ? ( +
+
+ Loading... +
+
+ ) : messages.length === 0 ? ( +
+ +

Start a conversation with {recipient.firstName}

+
+ ) : ( + <> + {messages.map((message, index) => { + const isCurrentUser = message.senderId === currentUser?.id; + const showDate = index === 0 || + formatDate(message.createdAt) !== formatDate(messages[index - 1].createdAt); + + return ( +
+ {showDate && ( +
+ + {formatDate(message.createdAt)} + +
+ )} +
+
+

+ {message.content} +

+ + {formatTime(message.createdAt)} + +
+
+
+ ); + })} +
+ + )} +
+ + {/* Input Area */} +
+
+ setNewMessage(e.target.value)} + disabled={sending} + /> + +
+
+
+ ); +}; + +export default ChatWindow; \ No newline at end of file diff --git a/frontend/src/components/ConfirmationModal.tsx b/frontend/src/components/ConfirmationModal.tsx new file mode 100644 index 0000000..ecb6b34 --- /dev/null +++ b/frontend/src/components/ConfirmationModal.tsx @@ -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 = ({ + show, + onClose, + onConfirm, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + confirmButtonClass = 'btn-danger', + loading = false +}) => { + if (!show) return null; + + return ( +
+
+
+
+
{title}
+ +
+
+

{message}

+
+
+ + +
+
+
+
+ ); +}; + +export default ConfirmationModal; \ No newline at end of file diff --git a/frontend/src/components/ItemReviews.tsx b/frontend/src/components/ItemReviews.tsx new file mode 100644 index 0000000..2a716ff --- /dev/null +++ b/frontend/src/components/ItemReviews.tsx @@ -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 = ({ itemId }) => { + const [reviews, setReviews] = useState([]); + 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 ( + + {[1, 2, 3, 4, 5].map((star) => ( + + ))} + + ); + }; + + if (loading) { + return ( +
+
Reviews
+
+
+ Loading reviews... +
+
+
+ ); + } + + return ( +
+
Reviews
+ + {reviews.length === 0 ? ( +

No reviews yet. Be the first to rent and review this item!

+ ) : ( + <> +
+
+ {renderStars(Math.round(averageRating))} + {averageRating.toFixed(1)} + ({reviews.length} {reviews.length === 1 ? 'review' : 'reviews'}) +
+
+ +
+ {reviews.map((rental) => ( +
+
+
+
+ {rental.renter?.profileImage ? ( + {`${rental.renter.firstName} + ) : ( +
+ +
+ )} +
+ {rental.renter?.firstName} {rental.renter?.lastName} +
+ {renderStars(rental.rating || 0)} +
+
+
+
+ + {new Date(rental.updatedAt).toLocaleDateString()} + +
+

{rental.review}

+
+ ))} +
+ + )} +
+ ); +}; + +export default ItemReviews; \ No newline at end of file diff --git a/frontend/src/components/LocationMap.tsx b/frontend/src/components/LocationMap.tsx new file mode 100644 index 0000000..287d0be --- /dev/null +++ b/frontend/src/components/LocationMap.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useRef } from 'react'; + +interface LocationMapProps { + latitude?: number; + longitude?: number; + location: string; + itemName: string; +} + +const LocationMap: React.FC = ({ latitude, longitude, location, itemName }) => { + const mapRef = useRef(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 = ` + + `; + } 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 = ` +
+ +

Location: ${location}

+ + View on Map + +
+ `; + } + }, [latitude, longitude, location]); + + return ( +
+
Location
+
+
+
+ Loading map... +
+
+
+ {(latitude && longitude) && ( +

+ + Exact location shown on map +

+ )} +
+ ); +}; + +export default LocationMap; \ No newline at end of file diff --git a/frontend/src/components/MessageModal.tsx b/frontend/src/components/MessageModal.tsx new file mode 100644 index 0000000..7462898 --- /dev/null +++ b/frontend/src/components/MessageModal.tsx @@ -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 = ({ show, onClose, recipient, onSuccess }) => { + const [subject, setSubject] = useState(''); + const [content, setContent] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+
+
Send Message to {recipient.firstName} {recipient.lastName}
+ +
+
+
+ {error && ( +
+ {error} +
+ )} + +
+ + setSubject(e.target.value)} + required + disabled={sending} + /> +
+ +
+ + +
+
+
+ + +
+
+
+
+
+ ); +}; + +export default MessageModal; \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 5752d1d..ba207e6 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -75,6 +75,11 @@ const Navbar: React.FC = () => { My Listings +
  • + + Messages + +

  • diff --git a/frontend/src/components/ReviewModal.tsx b/frontend/src/components/ReviewModal.tsx new file mode 100644 index 0000000..0fa67ee --- /dev/null +++ b/frontend/src/components/ReviewModal.tsx @@ -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 = ({ show, onClose, rental, onSuccess }) => { + const [rating, setRating] = useState(5); + const [review, setReview] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
    +
    +
    +
    +
    Review Your Rental
    + +
    +
    +
    + {rental.item && ( +
    +
    {rental.item.name}
    + + Rented from {new Date(rental.startDate).toLocaleDateString()} to {new Date(rental.endDate).toLocaleDateString()} + +
    + )} + + {error && ( +
    + {error} +
    + )} + +
    + +
    + {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
    +
    + + {rating === 1 && 'Poor'} + {rating === 2 && 'Fair'} + {rating === 3 && 'Good'} + {rating === 4 && 'Very Good'} + {rating === 5 && 'Excellent'} + +
    +
    + +
    + + + + Tell others about the item condition, owner communication, and overall experience + +
    +
    +
    + + +
    +
    +
    +
    +
    + ); +}; + +export default ReviewModal; \ No newline at end of file diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index bd0bd4c..c05f762 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -7,129 +7,264 @@ const Home: React.FC = () => { return (
    -
    + {/* Hero Section */} +
    -
    +

    - Rent Equipment from Your Neighbors + Rent Anything, From Anyone, Anywhere

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

    -
    - - Browse Items +
    + + Start Renting {user ? ( - - List Your Item + + List Your Items ) : ( - - Start Renting + + Start Earning )}
    +
    + +
    +
    +
    +
    + + {/* How It Works - For Renters */} +
    +
    +

    For Renters: Get What You Need, When You Need It

    +
    +
    +
    + +
    +

    1. Find What You Need

    +

    + Browse our marketplace for tools, equipment, electronics, and more. + Filter by location, price, and availability. +

    +
    +
    +
    + +
    +

    2. Book Your Rental

    +

    + Select your rental dates, choose delivery or pickup, and pay securely. + Your payment is held until the owner confirms. +

    +
    +
    +
    + +
    +

    3. Enjoy & Return

    +

    + Use the item for your project or event, then return it as agreed. + Rate your experience to help the community. +

    +
    +
    +
    +
    + + {/* How It Works - For Owners */} +
    +
    +

    For Owners: Turn Your Idle Items Into Income

    +
    +
    +
    + +
    +

    1. List Your Items

    +

    + Take photos, set your price, and choose when your items are available. + List anything from power tools to party supplies. +

    +
    +
    +
    + +
    +

    2. Accept Requests

    +

    + Review rental requests and accept the ones that work for you. + Set your own rules and requirements. +

    +
    +
    +
    + +
    +

    3. Get Paid

    +

    + Earn money from items sitting in your garage. + Payments are processed securely after each rental. +

    +
    +
    +
    +
    + + {/* Popular Categories */} +
    +
    +

    Popular Rental Categories

    +
    +
    +
    +
    + +
    Tools
    +
    +
    +
    +
    +
    +
    + +
    Electronics
    +
    +
    +
    +
    +
    +
    + +
    Sports
    +
    +
    +
    +
    +
    +
    + +
    Music
    +
    +
    +
    +
    +
    +
    + +
    Party
    +
    +
    +
    +
    +
    +
    + +
    Outdoor
    +
    +
    +
    +
    +
    +
    + + {/* Benefits Section */} +
    +
    +
    - Equipment rental +

    Why Choose Rentall?

    +
    + +
    +
    Save Money
    +

    Why buy when you can rent? Access expensive items for a fraction of the purchase price.

    +
    +
    +
    + +
    +
    Earn Extra Income
    +

    Turn your unused items into a revenue stream. Your garage could be a goldmine.

    +
    +
    +
    + +
    +
    Build Community
    +

    Connect with neighbors and help each other. Sharing builds stronger communities.

    +
    +
    +
    + +
    +
    Reduce Waste
    +

    Share instead of everyone buying. It's better for your wallet and the planet.

    +
    +
    +
    +
    +
    -
    +
    -
    -
    -

    Popular Categories

    -
    -
    -
    -
    - -

    Tools

    -

    - Power tools, hand tools, and equipment for your DIY projects -

    - - Browse Tools - -
    -
    -
    -
    -
    -
    - -

    Gym Equipment

    -

    - Weights, machines, and fitness gear for your workout needs -

    - - Browse Gym Equipment - -
    -
    -
    -
    -
    -
    - -

    Musical Instruments

    -

    - Guitars, keyboards, drums, and more for musicians -

    - - Browse Instruments - -
    -
    -
    + {/* CTA Section */} +
    +
    +

    Ready to Get Started?

    +

    + Join thousands of people sharing and renting in your community +

    +
    + + Browse Rentals + + {user ? ( + + List an Item + + ) : ( + + Sign Up Free + + )}
    -
    +
    -
    + {/* Stats Section */} +
    -

    How It Works

    -
    -
    -
    - 1 -
    -
    Search
    -

    Find the equipment you need in your area

    +
    +
    +

    1000+

    +

    Active Items

    -
    -
    - 2 -
    -
    Book
    -

    Reserve items for the dates you need

    +
    +

    500+

    +

    Happy Renters

    -
    -
    - 3 -
    -
    Pick Up
    -

    Collect items or have them delivered

    +
    +

    $50k+

    +

    Earned by Owners

    -
    -
    - 4 -
    -
    Return
    -

    Return items when you're done

    +
    +

    4.8★

    +

    Average Rating

    -
    + ); }; diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx index 7f86dda..eec160c 100644 --- a/frontend/src/pages/ItemDetail.tsx +++ b/frontend/src/pages/ItemDetail.tsx @@ -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 = () => {

    {item.name}

    {item.location}

    + {item.owner && ( +
    navigate(`/users/${item.ownerId}`)} + style={{ cursor: 'pointer' }} + > + {item.owner.profileImage ? ( + {`${item.owner.firstName} + ) : ( +
    + +
    + )} + {item.owner.firstName} {item.owner.lastName} +
    + )}
    {item.tags.map((tag, index) => ( @@ -165,6 +191,13 @@ const ItemDetail: React.FC = () => { )}
    + + {item.rules && (
    Rules
    @@ -172,6 +205,8 @@ const ItemDetail: React.FC = () => {
    )} + +
    {isOwner ? ( + +
    +
    +
    + {otherUser?.profileImage ? ( + {`${otherUser.firstName} + ) : ( +
    + +
    + )} +
    +
    {message.subject}
    + + {isReceiver ? 'From' : 'To'}: {otherUser?.firstName} {otherUser?.lastName} • {formatDateTime(message.createdAt)} + +
    +
    +
    +
    +

    {message.content}

    +
    +
    + + {message.replies && message.replies.length > 0 && ( +
    +
    Replies
    + {message.replies.map((reply) => ( +
    +
    +
    + {reply.sender?.profileImage ? ( + {`${reply.sender.firstName} + ) : ( +
    + +
    + )} +
    + {reply.sender?.firstName} {reply.sender?.lastName} + {formatDateTime(reply.createdAt)} +
    +
    +

    {reply.content}

    +
    +
    + ))} +
    + )} + +
    +
    +
    Send Reply
    + {error && ( +
    + {error} +
    + )} +
    +
    + +
    + +
    +
    +
    +
    +
    + + ); +}; + +export default MessageDetail; \ No newline at end of file diff --git a/frontend/src/pages/Messages.tsx b/frontend/src/pages/Messages.tsx new file mode 100644 index 0000000..dadc868 --- /dev/null +++ b/frontend/src/pages/Messages.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedRecipient, setSelectedRecipient] = useState(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 ( +
    +
    +
    + Loading... +
    +
    +
    + ); + } + + + return ( +
    +
    +
    +

    Messages

    + + {error && ( +
    + {error} +
    + )} + + {messages.length === 0 ? ( +
    + +

    No messages in your inbox

    +
    + ) : ( +
    + {messages.map((message) => ( +
    handleMessageClick(message)} + style={{ + cursor: 'pointer', + backgroundColor: !message.isRead ? '#f0f7ff' : 'white' + }} + > +
    +
    + {message.sender?.profileImage ? ( + {`${message.sender.firstName} + ) : ( +
    + +
    + )} +
    +
    +
    + {message.sender?.firstName} {message.sender?.lastName} +
    + {!message.isRead && ( + New + )} +
    +

    + {message.subject} +

    + + {message.content} + +
    +
    + {formatDate(message.createdAt)} +
    +
    + ))} +
    + )} +
    +
    + + {selectedRecipient && ( + { + setShowChat(false); + setSelectedRecipient(null); + }} + recipient={selectedRecipient} + /> + )} +
    + ); +}; + +export default Messages; \ No newline at end of file diff --git a/frontend/src/pages/MyRentals.tsx b/frontend/src/pages/MyRentals.tsx index df07b99..4bbe7f4 100644 --- a/frontend/src/pages/MyRentals.tsx +++ b/frontend/src/pages/MyRentals.tsx @@ -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(null); const [activeTab, setActiveTab] = useState<'active' | 'past'>('active'); + const [showReviewModal, setShowReviewModal] = useState(false); + const [selectedRental, setSelectedRental] = useState(null); + const [showCancelModal, setShowCancelModal] = useState(false); + const [rentalToCancel, setRentalToCancel] = useState(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' && ( )} {rental.status === 'completed' && !rental.rating && ( - )} + {rental.status === 'completed' && rental.rating && ( +
    + + Reviewed ({rental.rating}/5) +
    + )} @@ -193,6 +229,33 @@ const MyRentals: React.FC = () => { ))} )} + + {selectedRental && ( + { + setShowReviewModal(false); + setSelectedRental(null); + }} + rental={selectedRental} + onSuccess={handleReviewSuccess} + /> + )} + + { + 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} + /> ); }; diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx new file mode 100644 index 0000000..9ae1cde --- /dev/null +++ b/frontend/src/pages/PublicProfile.tsx @@ -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(null); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
    +
    +
    + Loading... +
    +
    +
    + ); + } + + if (error || !user) { + return ( +
    +
    + {error || 'User not found'} +
    +
    + ); + } + + return ( +
    +
    +
    +
    +
    +
    + {user.profileImage ? ( + {`${user.firstName} + ) : ( +
    + +
    + )} +

    {user.firstName} {user.lastName}

    +

    @{user.username}

    + {user.isVerified && ( + + Verified User + + )} + {currentUser && currentUser.id !== user.id && ( + + )} +
    +
    +
    + +
    +
    +
    Items Listed ({items.length})
    + {items.length > 0 ? ( +
    + {items.map((item) => ( +
    +
    navigate(`/items/${item.id}`)} + style={{ cursor: 'pointer' }} + > + {item.images.length > 0 ? ( + {item.name} + ) : ( +
    + No image +
    + )} +
    +
    {item.name}
    +

    {item.location}

    +
    + {item.pricePerDay && ( + ${item.pricePerDay}/day + )} +
    +
    +
    +
    + ))} +
    + ) : ( +

    No items listed yet.

    + )} +
    +
    + +
    + +
    +
    +
    +
    + ); +}; + +export default PublicProfile; \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6f5c463..e7d9efb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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; \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 63d6a9c..a8a0d3f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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;