messages and reviews
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
215
frontend/src/pages/MessageDetail.tsx
Normal file
215
frontend/src/pages/MessageDetail.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Message } from '../types';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const MessageDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessage();
|
||||
}, [id]);
|
||||
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const response = await messageAPI.getMessage(id!);
|
||||
setMessage(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch message');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message) return;
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
|
||||
await messageAPI.sendMessage({
|
||||
receiverId: recipientId,
|
||||
subject: `Re: ${message.subject}`,
|
||||
content: replyContent,
|
||||
parentMessageId: message.id
|
||||
});
|
||||
|
||||
setReplyContent('');
|
||||
fetchMessage(); // Refresh to show the new reply
|
||||
alert('Reply sent successfully!');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to send reply');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
Message not found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isReceiver = message.receiverId === user?.id;
|
||||
const otherUser = isReceiver ? message.sender : message.receiver;
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<button
|
||||
className="btn btn-link text-decoration-none mb-3"
|
||||
onClick={() => navigate('/messages')}
|
||||
>
|
||||
<i className="bi bi-arrow-left"></i> Back to Messages
|
||||
</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex align-items-center">
|
||||
{otherUser?.profileImage ? (
|
||||
<img
|
||||
src={otherUser.profileImage}
|
||||
alt={`${otherUser.firstName} ${otherUser.lastName}`}
|
||||
className="rounded-circle me-3"
|
||||
style={{ width: '50px', height: '50px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
|
||||
style={{ width: '50px', height: '50px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white"></i>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h5 className="mb-0">{message.subject}</h5>
|
||||
<small className="text-muted">
|
||||
{isReceiver ? 'From' : 'To'}: {otherUser?.firstName} {otherUser?.lastName} • {formatDateTime(message.createdAt)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p style={{ whiteSpace: 'pre-wrap' }}>{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.replies && message.replies.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h6>Replies</h6>
|
||||
{message.replies.map((reply) => (
|
||||
<div key={reply.id} className="card mb-2">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
{reply.sender?.profileImage ? (
|
||||
<img
|
||||
src={reply.sender.profileImage}
|
||||
alt={`${reply.sender.firstName} ${reply.sender.lastName}`}
|
||||
className="rounded-circle me-2"
|
||||
style={{ width: '30px', height: '30px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white" style={{ fontSize: '0.8rem' }}></i>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>{reply.sender?.firstName} {reply.sender?.lastName}</strong>
|
||||
<small className="text-muted ms-2">{formatDateTime(reply.createdAt)}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-0" style={{ whiteSpace: 'pre-wrap' }}>{reply.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-4">
|
||||
<div className="card-body">
|
||||
<h6>Send Reply</h6>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleReply}>
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={4}
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder="Type your reply..."
|
||||
required
|
||||
disabled={sending}
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={sending || !replyContent.trim()}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-send-fill me-2"></i>Send Reply
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageDetail;
|
||||
166
frontend/src/pages/Messages.tsx
Normal file
166
frontend/src/pages/Messages.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Message, User } from '../types';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ChatWindow from '../components/ChatWindow';
|
||||
|
||||
const Messages: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<User | null>(null);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, []);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const response = await messageAPI.getMessages();
|
||||
setMessages(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch messages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (diffInHours < 24) {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
} else if (diffInHours < 48) {
|
||||
return 'Yesterday';
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessageClick = async (message: Message) => {
|
||||
// Mark as read if unread
|
||||
if (!message.isRead) {
|
||||
try {
|
||||
await messageAPI.markAsRead(message.id);
|
||||
// Update local state
|
||||
setMessages(messages.map(m =>
|
||||
m.id === message.id ? { ...m, isRead: true } : m
|
||||
));
|
||||
} catch (err) {
|
||||
console.error('Failed to mark message as read:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Open chat with sender
|
||||
if (message.sender) {
|
||||
setSelectedRecipient(message.sender);
|
||||
setShowChat(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<h1 className="mb-4">Messages</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<i className="bi bi-envelope" style={{ fontSize: '3rem', color: '#ccc' }}></i>
|
||||
<p className="text-muted mt-2">No messages in your inbox</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="list-group">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`list-group-item list-group-item-action ${!message.isRead ? 'border-start border-primary border-4' : ''}`}
|
||||
onClick={() => handleMessageClick(message)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: !message.isRead ? '#f0f7ff' : 'white'
|
||||
}}
|
||||
>
|
||||
<div className="d-flex w-100 justify-content-between">
|
||||
<div className="d-flex align-items-center">
|
||||
{message.sender?.profileImage ? (
|
||||
<img
|
||||
src={message.sender.profileImage}
|
||||
alt={`${message.sender.firstName} ${message.sender.lastName}`}
|
||||
className="rounded-circle me-3"
|
||||
style={{ width: '40px', height: '40px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
|
||||
style={{ width: '40px', height: '40px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white"></i>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="d-flex align-items-center">
|
||||
<h6 className={`mb-1 ${!message.isRead ? 'fw-bold' : ''}`}>
|
||||
{message.sender?.firstName} {message.sender?.lastName}
|
||||
</h6>
|
||||
{!message.isRead && (
|
||||
<span className="badge bg-primary ms-2">New</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`mb-1 text-truncate ${!message.isRead ? 'fw-semibold' : ''}`} style={{ maxWidth: '400px' }}>
|
||||
{message.subject}
|
||||
</p>
|
||||
<small className="text-muted text-truncate d-block" style={{ maxWidth: '400px' }}>
|
||||
{message.content}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<small className="text-muted">{formatDate(message.createdAt)}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRecipient && (
|
||||
<ChatWindow
|
||||
show={showChat}
|
||||
onClose={() => {
|
||||
setShowChat(false);
|
||||
setSelectedRecipient(null);
|
||||
}}
|
||||
recipient={selectedRecipient}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Messages;
|
||||
@@ -3,6 +3,8 @@ import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
||||
160
frontend/src/pages/PublicProfile.tsx
Normal file
160
frontend/src/pages/PublicProfile.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { User, Item } from '../types';
|
||||
import { userAPI, itemAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const PublicProfile: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user: currentUser } = useAuth();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile();
|
||||
fetchUserItems();
|
||||
}, [id]);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
const response = await userAPI.getPublicProfile(id!);
|
||||
setUser(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch user profile');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserItems = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItems();
|
||||
const allItems = response.data.items || response.data || [];
|
||||
const userItems = allItems.filter((item: Item) => item.ownerId === id);
|
||||
setItems(userItems);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user items:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'User not found'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="text-center mb-4">
|
||||
{user.profileImage ? (
|
||||
<img
|
||||
src={user.profileImage}
|
||||
alt={`${user.firstName} ${user.lastName}`}
|
||||
className="rounded-circle mb-3"
|
||||
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center mb-3 mx-auto"
|
||||
style={{ width: '150px', height: '150px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white" style={{ fontSize: '3rem' }}></i>
|
||||
</div>
|
||||
)}
|
||||
<h3>{user.firstName} {user.lastName}</h3>
|
||||
<p className="text-muted">@{user.username}</p>
|
||||
{user.isVerified && (
|
||||
<span className="badge bg-success">
|
||||
<i className="bi bi-check-circle-fill"></i> Verified User
|
||||
</span>
|
||||
)}
|
||||
{currentUser && currentUser.id !== user.id && (
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => navigate('/messages')}
|
||||
>
|
||||
<i className="bi bi-chat-dots-fill me-2"></i>Message
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Items Listed ({items.length})</h5>
|
||||
{items.length > 0 ? (
|
||||
<div className="row">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="col-md-6 mb-3">
|
||||
<div
|
||||
className="card h-100 cursor-pointer"
|
||||
onClick={() => navigate(`/items/${item.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{item.images.length > 0 ? (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{ height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-light d-flex align-items-center justify-content-center" style={{ height: '200px' }}>
|
||||
<span className="text-muted">No image</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h6 className="card-title">{item.name}</h6>
|
||||
<p className="card-text text-muted small">{item.location}</p>
|
||||
<div>
|
||||
{item.pricePerDay && (
|
||||
<span className="badge bg-primary">${item.pricePerDay}/day</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted">No items listed yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicProfile;
|
||||
Reference in New Issue
Block a user