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

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;