feat: Full petmaster CRM with NestJS API and React frontend
- Client management (CRUD with JSONB custom fields) - Pet profiles - Service catalog with per-client pricing - Booking calendar with recurring appointments - Invoicing with Stripe integration - Real-time messaging (WebSocket) - Self-booking portal - Staff assignment - Docker multi-stage builds - Prisma schema + seed
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Send, User, Bot } from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Message {
|
||||
id: string; clientId: string; content: string; isFromStaff: boolean;
|
||||
createdAt: string; readAt: string | null;
|
||||
}
|
||||
|
||||
export default function Messages() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const staffId = 'staff-1'; // In production, this would come from auth
|
||||
|
||||
useEffect(() => {
|
||||
if (clientId) fetchMessages();
|
||||
}, [clientId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/messages/conversation/${clientId}?staffId=${staffId}`);
|
||||
const data = await res.json();
|
||||
setMessages(data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!newMessage.trim() || !clientId) return;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/ws/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: newMessage,
|
||||
clientId,
|
||||
staffId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewMessage('');
|
||||
fetchMessages();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const clients = [
|
||||
{ id: 'client-1', name: 'Alice Johnson' },
|
||||
{ id: 'client-2', name: 'Bob Smith' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 h-[calc(100vh-8rem)]">
|
||||
<div className="w-72 bg-white rounded-xl border border-slate-200 p-4 overflow-y-auto">
|
||||
<h2 className="text-sm font-semibold text-slate-600 mb-4 uppercase tracking-wide">Conversations</h2>
|
||||
<div className="space-y-1">
|
||||
{clients.map(client => (
|
||||
<button key={client.id} onClick={() => { setClientId(client.id); }}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
clientId === client.id ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-slate-600 hover:bg-slate-50'
|
||||
}`}>
|
||||
{client.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-xl border border-slate-200 flex flex-col">
|
||||
<div className="p-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-800">
|
||||
{clients.find(c => c.id === clientId)?.name || 'Select a conversation'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{loading ? <div className="text-center text-slate-400">Loading...</div> : (
|
||||
messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.isFromStaff ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`flex items-start gap-2 max-w-[70%] ${msg.isFromStaff ? 'flex-row-reverse' : ''}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
msg.isFromStaff ? 'bg-indigo-100' : 'bg-slate-100'
|
||||
}`}>
|
||||
{msg.isFromStaff ? <Bot className="w-4 h-4 text-indigo-600" /> : <User className="w-4 h-4 text-slate-600" />}
|
||||
</div>
|
||||
<div className={`rounded-xl px-4 py-2 ${
|
||||
msg.isFromStaff
|
||||
? 'bg-indigo-600 text-white rounded-br-sm'
|
||||
: 'bg-slate-100 text-slate-800 rounded-bl-sm'
|
||||
}`}>
|
||||
<p className="text-sm">{msg.content}</p>
|
||||
<p className={`text-xs mt-1 ${msg.isFromStaff ? 'text-indigo-200' : 'text-slate-400'}`}>
|
||||
{new Date(msg.createdAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<div className="flex gap-2">
|
||||
<input value={newMessage} onChange={e => setNewMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 border rounded-lg px-4 py-2"
|
||||
disabled={!clientId} />
|
||||
<button onClick={handleSend} disabled={!newMessage.trim() || !clientId}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user