BusyBee3333 4e6467ffb0 Add CRESync CRM application with Setup page
- Build complete Next.js CRM for commercial real estate
- Add authentication with JWT sessions and role-based access
- Add GoHighLevel API integration for contacts, conversations, opportunities
- Add AI-powered Control Center with tool calling
- Add Setup page with onboarding checklist (/setup)
- Add sidebar navigation with Setup menu item
- Fix type errors in onboarding API, GHL services, and control center tools
- Add Prisma schema with SQLite for local development
- Add UI components with clay morphism design system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:30:55 -05:00

966 lines
35 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { api } from '@/lib/api/client';
import {
Search,
Send,
Mail,
MessageSquare,
Star,
Plus,
ArrowLeft,
Phone,
MoreVertical,
Paperclip,
Smile,
Check,
CheckCheck,
Clock,
X,
Inbox,
Users
} from 'lucide-react';
// Types
interface Contact {
id: string;
name: string;
email: string;
phone: string;
avatar?: string;
}
interface Message {
id: string;
conversationId: string;
content: string;
timestamp: Date;
direction: 'inbound' | 'outbound';
status: 'sent' | 'delivered' | 'read' | 'pending';
channel: 'sms' | 'email';
}
interface Conversation {
id: string;
contact: Contact;
lastMessage: string;
lastMessageTime: Date;
unreadCount: number;
isStarred: boolean;
channel: 'sms' | 'email';
messages: Message[];
}
type FilterTab = 'all' | 'unread' | 'starred';
// Mock data
const mockConversations: Conversation[] = [
{
id: '1',
contact: { id: 'c1', name: 'John Smith', email: 'john.smith@example.com', phone: '+1 (555) 123-4567' },
lastMessage: 'I am very interested in the property on Main Street. When can we schedule a viewing?',
lastMessageTime: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago
unreadCount: 2,
isStarred: true,
channel: 'sms',
messages: [
{ id: 'm1', conversationId: '1', content: 'Hi, I saw your listing for the commercial property on Main Street.', timestamp: new Date(Date.now() - 1000 * 60 * 60), direction: 'inbound', status: 'read', channel: 'sms' },
{ id: 'm2', conversationId: '1', content: 'Hello John! Yes, it is still available. Would you like to schedule a viewing?', timestamp: new Date(Date.now() - 1000 * 60 * 45), direction: 'outbound', status: 'read', channel: 'sms' },
{ id: 'm3', conversationId: '1', content: 'That would be great. What times work for you?', timestamp: new Date(Date.now() - 1000 * 60 * 30), direction: 'inbound', status: 'read', channel: 'sms' },
{ id: 'm4', conversationId: '1', content: 'I have availability tomorrow at 2 PM or Thursday at 10 AM.', timestamp: new Date(Date.now() - 1000 * 60 * 20), direction: 'outbound', status: 'delivered', channel: 'sms' },
{ id: 'm5', conversationId: '1', content: 'I am very interested in the property on Main Street. When can we schedule a viewing?', timestamp: new Date(Date.now() - 1000 * 60 * 5), direction: 'inbound', status: 'delivered', channel: 'sms' },
]
},
{
id: '2',
contact: { id: 'c2', name: 'Sarah Johnson', email: 'sarah.j@realty.com', phone: '+1 (555) 234-5678' },
lastMessage: 'Thank you for the documents. I will review and get back to you.',
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
unreadCount: 0,
isStarred: false,
channel: 'email',
messages: [
{ id: 'm6', conversationId: '2', content: 'Hi Sarah, please find attached the property documents.', timestamp: new Date(Date.now() - 1000 * 60 * 60 * 3), direction: 'outbound', status: 'read', channel: 'email' },
{ id: 'm7', conversationId: '2', content: 'Thank you for the documents. I will review and get back to you.', timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2), direction: 'inbound', status: 'read', channel: 'email' },
]
},
{
id: '3',
contact: { id: 'c3', name: 'Michael Chen', email: 'mchen@business.com', phone: '+1 (555) 345-6789' },
lastMessage: 'Perfect, I will bring the signed lease agreement.',
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 24), // 1 day ago
unreadCount: 1,
isStarred: true,
channel: 'sms',
messages: [
{ id: 'm8', conversationId: '3', content: 'Michael, just confirming our meeting tomorrow at 3 PM.', timestamp: new Date(Date.now() - 1000 * 60 * 60 * 25), direction: 'outbound', status: 'read', channel: 'sms' },
{ id: 'm9', conversationId: '3', content: 'Perfect, I will bring the signed lease agreement.', timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24), direction: 'inbound', status: 'delivered', channel: 'sms' },
]
},
{
id: '4',
contact: { id: 'c4', name: 'Emily Davis', email: 'emily.davis@corp.com', phone: '+1 (555) 456-7890' },
lastMessage: 'Looking forward to discussing the investment opportunity.',
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 48), // 2 days ago
unreadCount: 0,
isStarred: false,
channel: 'email',
messages: [
{ id: 'm10', conversationId: '4', content: 'Looking forward to discussing the investment opportunity.', timestamp: new Date(Date.now() - 1000 * 60 * 60 * 48), direction: 'inbound', status: 'read', channel: 'email' },
]
},
{
id: '5',
contact: { id: 'c5', name: 'Robert Wilson', email: 'rwilson@investors.com', phone: '+1 (555) 567-8901' },
lastMessage: 'Can you send me the updated financials?',
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 72), // 3 days ago
unreadCount: 0,
isStarred: false,
channel: 'sms',
messages: [
{ id: 'm11', conversationId: '5', content: 'Can you send me the updated financials?', timestamp: new Date(Date.now() - 1000 * 60 * 60 * 72), direction: 'inbound', status: 'read', channel: 'sms' },
]
},
];
// Avatar color palette based on name
const avatarColors = [
'bg-blue-500',
'bg-emerald-500',
'bg-violet-500',
'bg-amber-500',
'bg-rose-500',
'bg-cyan-500',
'bg-indigo-500',
'bg-pink-500',
];
function getAvatarColor(name: string): string {
const charSum = name.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0);
return avatarColors[charSum % avatarColors.length];
}
function getInitials(name: string): string {
const parts = name.trim().split(' ').filter(Boolean);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
// Helper functions
function formatMessageTime(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) return 'Now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days === 1) return 'Yesterday';
if (days < 7) return date.toLocaleDateString([], { weekday: 'short' });
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function formatFullTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Components
// Avatar Component
function Avatar({
name,
size = 'md',
showOnlineIndicator = false
}: {
name: string;
size?: 'sm' | 'md' | 'lg';
showOnlineIndicator?: boolean;
}) {
const sizeClasses = {
sm: 'w-8 h-8 text-xs',
md: 'w-11 h-11 text-sm',
lg: 'w-14 h-14 text-lg'
};
return (
<div className="relative flex-shrink-0">
<div
className={`
${sizeClasses[size]} ${getAvatarColor(name)}
rounded-full flex items-center justify-center font-semibold text-white
shadow-md
`}
>
{getInitials(name)}
</div>
{showOnlineIndicator && (
<div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-emerald-500 border-2 border-white rounded-full" />
)}
</div>
);
}
// Conversation List Item
const ConversationItem = React.memo(function ConversationItem({
conversation,
isSelected,
onClick
}: {
conversation: Conversation;
isSelected: boolean;
onClick: () => void;
}) {
const isUnread = conversation.unreadCount > 0;
return (
<div
onClick={onClick}
className={`
group mx-2 mb-2 p-4 cursor-pointer transition-all duration-200 rounded-xl
border border-transparent
${isSelected
? 'bg-primary/15 border-primary/30 shadow-sm'
: 'hover:bg-muted/70 hover:border-border/50'
}
`}
>
<div className="flex items-center gap-4">
{/* Avatar with channel indicator */}
<div className="relative">
<Avatar name={conversation.contact.name} />
<div
className={`
absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
${conversation.channel === 'sms' ? 'bg-emerald-500' : 'bg-blue-500'}
shadow-sm ring-2 ring-background
`}
>
{conversation.channel === 'sms' ? (
<MessageSquare className="w-2.5 h-2.5 text-white" />
) : (
<Mail className="w-2.5 h-2.5 text-white" />
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`
truncate max-w-[140px]
${isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'}
`}
>
{conversation.contact.name}
</span>
{conversation.isStarred && (
<Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500 flex-shrink-0" />
)}
</div>
<span
className={`
text-xs flex-shrink-0 ml-4
${isUnread ? 'text-primary font-medium' : 'text-slate-500'}
`}
>
{formatMessageTime(conversation.lastMessageTime)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<p
className={`
text-sm line-clamp-1
${isUnread ? 'text-foreground/90' : 'text-slate-500'}
`}
style={{
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{conversation.lastMessage}
</p>
{isUnread && (
<span className="flex-shrink-0 min-w-[20px] h-5 px-1.5 bg-primary text-primary-foreground text-xs font-semibold rounded-full flex items-center justify-center">
{conversation.unreadCount}
</span>
)}
</div>
</div>
</div>
</div>
);
});
// Conversation List (Left Panel)
function ConversationList({
conversations,
selectedId,
onSelect,
searchQuery,
onSearchChange,
activeFilter,
onFilterChange,
isLoading,
onNewMessage
}: {
conversations: Conversation[];
selectedId: string | null;
onSelect: (id: string) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
activeFilter: FilterTab;
onFilterChange: (filter: FilterTab) => void;
isLoading: boolean;
onNewMessage: () => void;
}) {
const filters: { id: FilterTab; label: string; count?: number }[] = [
{ id: 'all', label: 'All' },
{ id: 'unread', label: 'Unread', count: conversations.filter(c => c.unreadCount > 0).length },
{ id: 'starred', label: 'Starred', count: conversations.filter(c => c.isStarred).length },
];
return (
<div className="flex flex-col h-full bg-background rounded-2xl border border-border/50 shadow-sm overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-border/50">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground">Messages</h2>
<button
onClick={onNewMessage}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-xl hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={16} />
New
</button>
</div>
{/* Search */}
<div className="relative mb-5">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-500 pointer-events-none" size={18} />
<input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className={`w-full pl-12 ${searchQuery ? 'pr-12' : 'pr-4'} py-3 bg-muted/50 border border-border/50 rounded-xl text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all`}
/>
{searchQuery && (
<button
onClick={() => onSearchChange('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X size={14} />
</button>
)}
</div>
{/* Filter Tabs */}
<div className="flex gap-1 p-1 bg-muted/50 rounded-xl">
{filters.map((filter) => (
<button
key={filter.id}
onClick={() => onFilterChange(filter.id)}
className={`
flex-1 py-2.5 px-4 text-sm font-medium rounded-lg transition-all duration-200
flex items-center justify-center gap-2
${activeFilter === filter.id
? 'bg-background text-primary shadow-sm'
: 'text-slate-500 hover:text-foreground hover:bg-background/50'
}
`}
>
{filter.label}
{filter.count !== undefined && filter.count > 0 && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
activeFilter === filter.id
? 'bg-primary/10 text-primary'
: 'bg-slate-200 text-slate-600'
}`}>
{filter.count}
</span>
)}
</button>
))}
</div>
</div>
{/* Conversation List */}
<div className="flex-1 overflow-y-auto pt-3 pb-2">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
<p className="text-sm text-slate-500">Loading conversations...</p>
</div>
</div>
) : conversations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center mb-5">
{searchQuery ? (
<Search className="text-slate-500" size={28} />
) : activeFilter === 'unread' ? (
<Inbox className="text-slate-500" size={28} />
) : activeFilter === 'starred' ? (
<Star className="text-slate-500" size={28} />
) : (
<MessageSquare className="text-slate-500" size={28} />
)}
</div>
<p className="text-foreground font-medium mb-2">
{searchQuery
? 'No results found'
: activeFilter === 'unread'
? 'All caught up!'
: activeFilter === 'starred'
? 'No starred conversations'
: 'No conversations yet'
}
</p>
<p className="text-slate-500 text-sm mb-6">
{searchQuery
? 'Try a different search term'
: activeFilter === 'unread'
? 'You have no unread messages'
: activeFilter === 'starred'
? 'Star important conversations to find them here'
: 'Start a conversation to get going'
}
</p>
{!searchQuery && activeFilter === 'all' && (
<button
onClick={onNewMessage}
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
New Message
</button>
)}
</div>
) : (
conversations.map((conversation) => (
<ConversationItem
key={conversation.id}
conversation={conversation}
isSelected={selectedId === conversation.id}
onClick={() => onSelect(conversation.id)}
/>
))
)}
</div>
</div>
);
}
// Message Bubble
function MessageBubble({ message }: { message: Message }) {
const isOutbound = message.direction === 'outbound';
const StatusIcon = () => {
switch (message.status) {
case 'pending':
return <Clock className="w-3.5 h-3.5 text-white/60" />;
case 'sent':
return <Check className="w-3.5 h-3.5 text-white/60" />;
case 'delivered':
return <CheckCheck className="w-3.5 h-3.5 text-white/60" />;
case 'read':
return <CheckCheck className="w-3.5 h-3.5 text-emerald-300" />;
default:
return null;
}
};
return (
<div className={`flex ${isOutbound ? 'justify-end' : 'justify-start'} mb-3`}>
<div
className={`
max-w-[80%] px-4 py-3 rounded-2xl
${isOutbound
? 'bg-primary text-primary-foreground rounded-br-lg'
: 'bg-muted text-foreground rounded-bl-lg'
}
`}
>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
<div className={`flex items-center gap-1.5 mt-1.5 ${isOutbound ? 'justify-end' : 'justify-start'}`}>
<span className={`text-xs ${isOutbound ? 'text-white/70' : 'text-slate-400'}`}>
{formatFullTime(message.timestamp)}
</span>
{isOutbound && <StatusIcon />}
</div>
</div>
</div>
);
}
// Message Composer
function MessageComposer({
channel,
onChannelChange,
message,
onMessageChange,
onSend,
isSending
}: {
channel: 'sms' | 'email';
onChannelChange: (channel: 'sms' | 'email') => void;
message: string;
onMessageChange: (message: string) => void;
onSend: () => void;
isSending: boolean;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (message.trim()) {
onSend();
}
}
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 150) + 'px';
}
}, [message]);
return (
<div className="p-5 border-t border-border/50 bg-background">
{/* Channel Toggle */}
<div className="flex items-center gap-3 mb-4">
<span className="text-sm text-slate-500">Send via:</span>
<div className="flex gap-1 p-1 bg-muted/50 rounded-xl">
<button
onClick={() => onChannelChange('sms')}
className={`
flex items-center gap-2 py-2 px-4 text-sm font-medium rounded-lg transition-all
${channel === 'sms'
? 'bg-background text-emerald-600 shadow-sm'
: 'text-slate-500 hover:text-foreground hover:bg-background/50'
}
`}
>
<MessageSquare size={16} />
SMS
</button>
<button
onClick={() => onChannelChange('email')}
className={`
flex items-center gap-2 py-2 px-4 text-sm font-medium rounded-lg transition-all
${channel === 'email'
? 'bg-background text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-foreground hover:bg-background/50'
}
`}
>
<Mail size={16} />
Email
</button>
</div>
</div>
{/* Composer */}
<div className="flex items-end gap-3">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => onMessageChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={channel === 'sms' ? 'Type a message...' : 'Compose your email...'}
rows={1}
className="w-full pr-24 py-3.5 px-4 bg-muted/50 border border-border/50 rounded-2xl text-sm resize-none placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all"
/>
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors">
<Paperclip size={18} />
</button>
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors">
<Smile size={18} />
</button>
</div>
</div>
<button
onClick={onSend}
disabled={!message.trim() || isSending}
className="p-3.5 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 disabled:opacity-40 disabled:bg-primary/70 disabled:cursor-not-allowed disabled:shadow-none transition-all shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2"
>
{isSending ? (
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
) : (
<Send size={20} />
)}
</button>
</div>
<p className="text-xs text-slate-400 mt-3">
Press <kbd className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-500 font-mono text-[11px]">Enter</kbd> to send, <kbd className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-500 font-mono text-[11px]">Shift + Enter</kbd> for new line
</p>
</div>
);
}
// Empty State Illustration
function EmptyStateIllustration() {
return (
<div className="relative w-48 h-48 mx-auto mb-8">
{/* Background circles */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-48 h-48 rounded-full bg-primary/5 animate-pulse" />
</div>
<div className="absolute inset-4 flex items-center justify-center">
<div className="w-40 h-40 rounded-full bg-primary/10" />
</div>
{/* Message bubbles illustration */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative">
{/* Main message icon */}
<div className="w-20 h-20 bg-primary/20 rounded-2xl flex items-center justify-center shadow-lg">
<MessageSquare className="w-10 h-10 text-primary" />
</div>
{/* Floating elements */}
<div className="absolute -top-3 -right-3 w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center shadow-md animate-bounce">
<MessageSquare className="w-4 h-4 text-white" />
</div>
<div className="absolute -bottom-2 -left-4 w-7 h-7 bg-blue-500 rounded-full flex items-center justify-center shadow-md" style={{ animationDelay: '0.2s' }}>
<Mail className="w-3.5 h-3.5 text-white" />
</div>
<div className="absolute top-1/2 -right-6 w-6 h-6 bg-amber-500 rounded-full flex items-center justify-center shadow-md">
<Star className="w-3 h-3 text-white" />
</div>
</div>
</div>
</div>
);
}
// Message Thread (Right Panel)
function MessageThread({
conversation,
onBack,
showBackButton,
onNewMessage
}: {
conversation: Conversation | null;
onBack: () => void;
showBackButton: boolean;
onNewMessage: () => void;
}) {
const [message, setMessage] = useState('');
const [channel, setChannel] = useState<'sms' | 'email'>('sms');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (conversation) {
setChannel(conversation.channel);
}
}, [conversation]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [conversation?.messages]);
const handleSend = async () => {
if (!message.trim() || !conversation) return;
setIsSending(true);
try {
// Send message via GHL API
if (conversation.channel === 'sms') {
await api.conversations.sendSMS(conversation.contact.id, message);
} else {
await api.conversations.sendEmail(conversation.contact.id, 'Reply', message);
}
setMessage('');
// Refresh messages would happen here via realtime or refetch
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setIsSending(false);
}
};
if (!conversation) {
return (
<div className="flex flex-col h-full bg-background rounded-2xl border border-border/50 shadow-sm overflow-hidden">
<div className="flex items-center justify-center h-full">
<div className="text-center p-10 max-w-md">
<EmptyStateIllustration />
<h3 className="text-2xl font-semibold text-foreground mb-4">Your Messages</h3>
<p className="text-slate-500 mb-8 leading-relaxed">
Select a conversation from the list to view your messages, or start a new conversation to connect with clients and leads.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={onNewMessage}
className="flex items-center justify-center gap-2 px-6 py-3 bg-primary text-primary-foreground font-medium rounded-xl hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={18} />
New Message
</button>
<button className="flex items-center justify-center gap-2 px-6 py-3 bg-muted text-foreground font-medium rounded-xl hover:bg-muted/80 transition-colors">
<Users size={18} />
View Contacts
</button>
</div>
{/* Quick tips */}
<div className="mt-10 pt-8 border-t border-border/50">
<p className="text-xs text-slate-500 mb-4">Quick tips</p>
<div className="flex flex-wrap justify-center gap-3">
<span className="px-3 py-1.5 bg-muted/50 text-slate-500 text-xs rounded-full">
Press Enter to send
</span>
<span className="px-3 py-1.5 bg-muted/50 text-slate-500 text-xs rounded-full">
Star important chats
</span>
<span className="px-3 py-1.5 bg-muted/50 text-slate-500 text-xs rounded-full">
Search to filter
</span>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-background rounded-2xl border border-border/50 shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border/50">
{showBackButton && (
<button
onClick={onBack}
className="p-2 -ml-2 text-slate-500 hover:text-foreground hover:bg-muted rounded-lg transition-colors lg:hidden"
>
<ArrowLeft size={20} />
</button>
)}
{/* Contact Avatar */}
<Avatar name={conversation.contact.name} />
{/* Contact Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground truncate">{conversation.contact.name}</h3>
{conversation.isStarred && (
<Star className="w-4 h-4 text-amber-500 fill-amber-500 flex-shrink-0" />
)}
</div>
<p className="text-sm text-slate-500 truncate">
{conversation.channel === 'sms' ? conversation.contact.phone : conversation.contact.email}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<button className="p-2 text-slate-500 hover:text-foreground hover:bg-muted rounded-lg transition-colors">
<Phone size={18} />
</button>
<button className="p-2 text-slate-500 hover:text-foreground hover:bg-muted rounded-lg transition-colors">
<Star size={18} className={conversation.isStarred ? 'text-amber-500 fill-amber-500' : ''} />
</button>
<button className="p-2 text-slate-500 hover:text-foreground hover:bg-muted rounded-lg transition-colors">
<MoreVertical size={18} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{/* Date separator example */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-border/50" />
<span className="text-xs text-slate-500 px-2">Today</span>
<div className="flex-1 h-px bg-border/50" />
</div>
{conversation.messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
{/* Composer */}
<MessageComposer
channel={channel}
onChannelChange={setChannel}
message={message}
onMessageChange={setMessage}
onSend={handleSend}
isSending={isSending}
/>
</div>
);
}
// Main Page Component
export default function ConversationsPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeFilter, setActiveFilter] = useState<FilterTab>('all');
const [isLoading, setIsLoading] = useState(true);
const [isMobileThreadOpen, setIsMobileThreadOpen] = useState(false);
// Load conversations from GHL
useEffect(() => {
let isMounted = true;
const loadConversations = async () => {
setIsLoading(true);
try {
const response = await api.conversations.getAll({ limit: 50 });
if (isMounted && response.data) {
// Map GHL conversations to our format
const ghlConversations: Conversation[] = response.data.map((conv: any) => ({
id: conv.id,
contact: {
id: conv.contactId || conv.contact?.id || '',
name: conv.contactName || conv.contact?.name || conv.contact?.firstName + ' ' + (conv.contact?.lastName || '') || 'Unknown',
email: conv.contact?.email || '',
phone: conv.contact?.phone || '',
},
lastMessage: conv.lastMessageBody || conv.snippet || '',
lastMessageTime: new Date(conv.lastMessageDate || conv.dateUpdated || Date.now()),
unreadCount: conv.unreadCount || 0,
isStarred: conv.starred || false,
channel: conv.type === 'SMS' ? 'sms' : 'email',
messages: [], // Messages loaded separately when conversation is selected
}));
setConversations(ghlConversations);
}
} catch (err: any) {
console.error('Failed to fetch conversations:', err);
// Fall back to empty state - don't use mock data in production
if (isMounted) {
setConversations([]);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadConversations();
return () => {
isMounted = false;
};
}, []);
// Filter conversations with memoization to prevent unnecessary recalculations
const filteredConversations = useMemo(() => {
return conversations.filter((conv) => {
// Search filter
const searchLower = searchQuery.toLowerCase();
const matchesSearch = searchQuery === '' ||
conv.contact.name.toLowerCase().includes(searchLower) ||
conv.contact.email.toLowerCase().includes(searchLower) ||
conv.lastMessage.toLowerCase().includes(searchLower);
// Tab filter
let matchesFilter = true;
if (activeFilter === 'unread') {
matchesFilter = conv.unreadCount > 0;
} else if (activeFilter === 'starred') {
matchesFilter = conv.isStarred;
}
return matchesSearch && matchesFilter;
});
}, [conversations, searchQuery, activeFilter]);
const selectedConversation = useMemo(() => {
return conversations.find(c => c.id === selectedConversationId) || null;
}, [conversations, selectedConversationId]);
const handleSelectConversation = useCallback((id: string) => {
setSelectedConversationId(id);
setIsMobileThreadOpen(true);
}, []);
const handleBackToList = useCallback(() => {
setIsMobileThreadOpen(false);
}, []);
const handleNewMessage = useCallback(() => {
// Placeholder for new message functionality
console.log('New message clicked');
}, []);
return (
<div className="h-[calc(100vh-8rem)]">
{/* Desktop Layout */}
<div className="hidden lg:grid lg:grid-cols-[380px_1fr] gap-4 h-full">
{/* Left Panel - Conversation List */}
<ConversationList
conversations={filteredConversations}
selectedId={selectedConversationId}
onSelect={handleSelectConversation}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
isLoading={isLoading}
onNewMessage={handleNewMessage}
/>
{/* Right Panel - Message Thread */}
<MessageThread
conversation={selectedConversation}
onBack={handleBackToList}
showBackButton={false}
onNewMessage={handleNewMessage}
/>
</div>
{/* Mobile Layout */}
<div className="lg:hidden h-full">
{/* List View */}
<div className={`h-full ${isMobileThreadOpen ? 'hidden' : 'block'}`}>
<ConversationList
conversations={filteredConversations}
selectedId={selectedConversationId}
onSelect={handleSelectConversation}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
isLoading={isLoading}
onNewMessage={handleNewMessage}
/>
</div>
{/* Thread View (Full Screen) */}
<div className={`h-full ${isMobileThreadOpen ? 'block' : 'hidden'}`}>
<MessageThread
conversation={selectedConversation}
onBack={handleBackToList}
showBackButton={true}
onNewMessage={handleNewMessage}
/>
</div>
</div>
</div>
);
}