- 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>
966 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|