Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Register from './pages/Register';
import CreateListing from './pages/CreateListing';
import ListingDetail from './pages/ListingDetail';
import Profile from './pages/Profile';
import Messages from './pages/Messages';

function App() {
return (
Expand All @@ -22,6 +23,7 @@ function App() {
<Route path="/create-listing" element={<CreateListing />} />
<Route path="/listing/:id" element={<ListingDetail />} />
<Route path="/profile" element={<Profile />} />
<Route path="/messages" element={<Messages />} />
</Routes>
</div>
</Router>
Expand Down
261 changes: 261 additions & 0 deletions frontend/src/pages/Messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { messagesAPI } from '../services/api';
import { useAuth } from '../context/AuthContext';

const Messages = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [conversations, setConversations] = useState([]);
const [selectedConversation, setSelectedConversation] = useState(null);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(true);
const [sendingMessage, setSendingMessage] = useState(false);

useEffect(() => {
if (!user) {
navigate('/login');
return;
}
loadConversations();
}, [user, navigate]);

const loadConversations = async () => {
setLoading(true);
try {
const response = await messagesAPI.getConversations();
setConversations(response.data.conversations);
} catch (error) {
console.error('Failed to load conversations:', error);
} finally {
setLoading(false);
}
};

const loadMessages = async (conversation) => {
setSelectedConversation(conversation);
try {
const response = await messagesAPI.getMessages(
conversation.listing_id,
conversation.other_user_id
);
setMessages(response.data.messages);
} catch (error) {
console.error('Failed to load messages:', error);
}
};

const handleSendMessage = async (e) => {
e.preventDefault();
if (!newMessage.trim() || !selectedConversation) return;

setSendingMessage(true);
try {
await messagesAPI.send({
listing_id: selectedConversation.listing_id,
receiver_id: selectedConversation.other_user_id,
message: newMessage,
});
setNewMessage('');
// Reload messages
await loadMessages(selectedConversation);
} catch (error) {
alert('Failed to send message');
} finally {
setSendingMessage(false);
}
};

const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);

if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};

if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 py-8 text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading messages...</p>
</div>
);
}

return (
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Messages</h1>

<div className="card p-0 overflow-hidden">
<div className="flex h-[600px]">
{/* Conversations List */}
<div className="w-1/3 border-r border-gray-200 overflow-y-auto">
{conversations.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<p className="mb-4">No messages yet</p>
<p className="text-sm">Start a conversation by contacting a seller on a listing!</p>
</div>
) : (
conversations.map((conv) => (
<div
key={`${conv.listing_id}-${conv.other_user_id}`}
onClick={() => loadMessages(conv)}
className={`p-4 border-b border-gray-200 cursor-pointer hover:bg-gray-50 transition-colors ${
selectedConversation?.listing_id === conv.listing_id &&
selectedConversation?.other_user_id === conv.other_user_id
? 'bg-primary-50'
: ''
}`}
>
<div className="flex items-start">
<div className="w-12 h-12 bg-primary-600 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0">
{conv.other_user_name?.charAt(0).toUpperCase()}
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="font-semibold text-gray-900 truncate">
{conv.other_user_name}
</p>
<span className="text-xs text-gray-500 ml-2 flex-shrink-0">
{formatDate(conv.created_at)}
</span>
</div>
<p className="text-sm text-gray-600 truncate mt-1">
{conv.listing_title}
</p>
<p className="text-sm text-gray-500 truncate mt-1">
{conv.message}
</p>
{!conv.is_read && (
<span className="inline-block mt-1 w-2 h-2 bg-primary-600 rounded-full"></span>
)}
</div>
</div>
</div>
))
)}
</div>

{/* Messages Area */}
<div className="w-2/3 flex flex-col">
{selectedConversation ? (
<>
{/* Conversation Header */}
<div className="p-4 border-b border-gray-200 bg-white">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-lg text-gray-900">
{selectedConversation.other_user_name}
</h2>
<p className="text-sm text-gray-600">
Re: {selectedConversation.listing_title}
</p>
</div>
<button
onClick={() => navigate(`/listing/${selectedConversation.listing_id}`)}
className="btn btn-outline text-sm"
>
View Listing
</button>
</div>
</div>

{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
{messages.length === 0 ? (
<div className="text-center text-gray-500 mt-8">
<p>No messages yet. Start the conversation!</p>
</div>
) : (
<div className="space-y-4">
{messages.map((msg) => {
const isSender = msg.sender_id === user?.id;
return (
<div
key={msg.id}
className={`flex ${isSender ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-lg p-3 ${
isSender
? 'bg-primary-600 text-white'
: 'bg-white text-gray-900 border border-gray-200'
}`}
>
<p className="text-sm font-medium mb-1">
{isSender ? 'You' : msg.sender_name}
</p>
<p className="whitespace-pre-wrap">{msg.message}</p>
<p
className={`text-xs mt-2 ${
isSender ? 'text-primary-100' : 'text-gray-500'
}`}
>
{formatDate(msg.created_at)}
</p>
</div>
</div>
);
})}
</div>
)}
</div>

{/* Message Input */}
<form onSubmit={handleSendMessage} className="p-4 bg-white border-t border-gray-200">
<div className="flex gap-2">
<textarea
className="input flex-1"
rows="2"
placeholder="Type your message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
disabled={sendingMessage}
/>
<button
type="submit"
className="btn btn-primary self-end"
disabled={sendingMessage || !newMessage.trim()}
>
{sendingMessage ? 'Sending...' : 'Send'}
</button>
</div>
</form>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
<div className="text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p>Select a conversation to view messages</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};

export default Messages;
Loading