- 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>
193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { Plus, MessageSquare } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import type { ControlCenterConversation } from '@/types/control-center';
|
|
|
|
interface ConversationSidebarProps {
|
|
/** List of conversations to display */
|
|
conversations: ControlCenterConversation[];
|
|
/** ID of the currently selected conversation */
|
|
currentId?: string;
|
|
/** Callback when a conversation is selected */
|
|
onSelect: (id: string) => void;
|
|
/** Callback when the user wants to start a new conversation */
|
|
onNew: () => void;
|
|
}
|
|
|
|
/**
|
|
* Format a date string to relative time (e.g., "2 hours ago", "Yesterday")
|
|
*/
|
|
function formatRelativeTime(dateString: string): string {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
|
|
if (diffInSeconds < 60) {
|
|
return 'Just now';
|
|
}
|
|
|
|
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
|
if (diffInMinutes < 60) {
|
|
return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
if (diffInHours < 24) {
|
|
return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
const diffInDays = Math.floor(diffInHours / 24);
|
|
if (diffInDays === 1) {
|
|
return 'Yesterday';
|
|
}
|
|
if (diffInDays < 7) {
|
|
return `${diffInDays} days ago`;
|
|
}
|
|
|
|
const diffInWeeks = Math.floor(diffInDays / 7);
|
|
if (diffInWeeks < 4) {
|
|
return `${diffInWeeks} week${diffInWeeks !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
const diffInMonths = Math.floor(diffInDays / 30);
|
|
if (diffInMonths < 12) {
|
|
return `${diffInMonths} month${diffInMonths !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
const diffInYears = Math.floor(diffInDays / 365);
|
|
return `${diffInYears} year${diffInYears !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
/**
|
|
* ConversationSidebar - Displays conversation history for the Control Center
|
|
*
|
|
* Shows a scrollable list of past conversations with relative timestamps,
|
|
* plus a button to start new conversations.
|
|
*/
|
|
export const ConversationSidebar: React.FC<ConversationSidebarProps> = ({
|
|
conversations,
|
|
currentId,
|
|
onSelect,
|
|
onNew,
|
|
}) => {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col',
|
|
'h-full',
|
|
'bg-[#F0F4F8]',
|
|
'rounded-2xl',
|
|
'shadow-[6px_6px_12px_#bfc3cc,-6px_-6px_12px_#ffffff]',
|
|
'overflow-hidden'
|
|
)}
|
|
>
|
|
{/* Header with New Chat button */}
|
|
<div className="p-4 border-b border-gray-200/50">
|
|
<button
|
|
onClick={onNew}
|
|
className={cn(
|
|
'w-full',
|
|
'flex items-center justify-center gap-2',
|
|
'px-4 py-3',
|
|
'bg-indigo-500',
|
|
'text-white',
|
|
'rounded-xl',
|
|
'font-medium',
|
|
'shadow-[4px_4px_8px_#bfc3cc,-4px_-4px_8px_#ffffff]',
|
|
'hover:bg-indigo-600',
|
|
'hover:shadow-[2px_2px_4px_#bfc3cc,-2px_-2px_4px_#ffffff]',
|
|
'active:shadow-[inset_2px_2px_4px_rgba(0,0,0,0.1)]',
|
|
'transition-all duration-200'
|
|
)}
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
<span>New Chat</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Conversation list */}
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-3">
|
|
{conversations.length === 0 ? (
|
|
// Empty state
|
|
<div className="flex flex-col items-center justify-center py-12 px-4">
|
|
<div
|
|
className={cn(
|
|
'w-16 h-16',
|
|
'flex items-center justify-center',
|
|
'rounded-2xl',
|
|
'bg-[#F0F4F8]',
|
|
'shadow-[inset_3px_3px_6px_rgba(0,0,0,0.06),inset_-3px_-3px_6px_rgba(255,255,255,0.9)]',
|
|
'mb-4'
|
|
)}
|
|
>
|
|
<MessageSquare className="w-8 h-8 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-sm font-medium text-gray-600 mb-1">
|
|
No conversations yet
|
|
</h3>
|
|
<p className="text-xs text-gray-400 text-center">
|
|
Start a new chat to begin exploring your GHL account
|
|
</p>
|
|
</div>
|
|
) : (
|
|
// Conversation items
|
|
<div className="flex flex-col gap-2">
|
|
{conversations.map((conversation) => {
|
|
const isSelected = conversation.id === currentId;
|
|
const displayTitle = conversation.title || 'New conversation';
|
|
|
|
return (
|
|
<button
|
|
key={conversation.id}
|
|
onClick={() => onSelect(conversation.id)}
|
|
className={cn(
|
|
'w-full',
|
|
'text-left',
|
|
'px-4 py-3',
|
|
'rounded-xl',
|
|
'transition-all duration-200',
|
|
isSelected
|
|
? [
|
|
'bg-[#F0F4F8]',
|
|
'shadow-[inset_4px_4px_8px_rgba(0,0,0,0.05),inset_-4px_-4px_8px_rgba(255,255,255,0.8)]',
|
|
'border-2 border-indigo-500',
|
|
]
|
|
: [
|
|
'bg-[#F0F4F8]',
|
|
'shadow-[3px_3px_6px_#c5c9d1,-3px_-3px_6px_#ffffff]',
|
|
'border-2 border-transparent',
|
|
'hover:shadow-[2px_2px_4px_#c5c9d1,-2px_-2px_4px_#ffffff]',
|
|
'hover:border-gray-300',
|
|
]
|
|
)}
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
<span
|
|
className={cn(
|
|
'text-sm font-medium truncate',
|
|
isSelected ? 'text-indigo-700' : 'text-gray-700'
|
|
)}
|
|
>
|
|
{displayTitle}
|
|
</span>
|
|
<span className="text-xs text-gray-400">
|
|
{formatRelativeTime(conversation.createdAt)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ConversationSidebar;
|