- 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>
298 lines
9.3 KiB
TypeScript
298 lines
9.3 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { Menu, X, Loader2, AlertCircle } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useIsMobile } from '@/hooks/use-mobile';
|
|
import {
|
|
ChatProvider,
|
|
useChatContext,
|
|
ChatInterface,
|
|
ConversationSidebar,
|
|
StatusIndicator,
|
|
} from '@/components/control-center';
|
|
import type { ControlCenterConversation } from '@/types/control-center';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface MCPStatus {
|
|
connected: boolean;
|
|
toolCount: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface ToolsResponse {
|
|
tools: unknown[];
|
|
mcpStatus: MCPStatus;
|
|
appToolCount: number;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Inner Component (uses ChatContext)
|
|
// =============================================================================
|
|
|
|
function ControlCenterContent() {
|
|
const isMobile = useIsMobile();
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [mcpStatus, setMcpStatus] = useState<MCPStatus>({
|
|
connected: false,
|
|
toolCount: 0,
|
|
});
|
|
const [initialLoading, setInitialLoading] = useState(true);
|
|
const [toolsError, setToolsError] = useState<string | null>(null);
|
|
|
|
const {
|
|
conversations,
|
|
currentConversation,
|
|
loadConversations,
|
|
selectConversation,
|
|
newConversation,
|
|
isLoading,
|
|
isStreaming,
|
|
error,
|
|
} = useChatContext();
|
|
|
|
// Determine connection status for StatusIndicator
|
|
const getConnectionStatus = (): 'connected' | 'connecting' | 'error' | 'idle' => {
|
|
if (error || toolsError) return 'error';
|
|
if (isLoading || isStreaming) return 'connecting';
|
|
if (mcpStatus.connected) return 'connected';
|
|
return 'idle';
|
|
};
|
|
|
|
// Fetch available tools on mount to check MCP status
|
|
const fetchTools = useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/v1/control-center/tools');
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch tools');
|
|
}
|
|
const data: ToolsResponse = await response.json();
|
|
setMcpStatus(data.mcpStatus);
|
|
setToolsError(null);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to check MCP status';
|
|
setToolsError(message);
|
|
setMcpStatus({ connected: false, toolCount: 0, error: message });
|
|
}
|
|
}, []);
|
|
|
|
// Load initial data on mount
|
|
useEffect(() => {
|
|
const initializeData = async () => {
|
|
setInitialLoading(true);
|
|
try {
|
|
await Promise.all([loadConversations(), fetchTools()]);
|
|
} finally {
|
|
setInitialLoading(false);
|
|
}
|
|
};
|
|
|
|
initializeData();
|
|
}, [loadConversations, fetchTools]);
|
|
|
|
// Close sidebar when selecting a conversation on mobile
|
|
const handleSelectConversation = useCallback(
|
|
async (id: string) => {
|
|
await selectConversation(id);
|
|
if (isMobile) {
|
|
setSidebarOpen(false);
|
|
}
|
|
},
|
|
[selectConversation, isMobile]
|
|
);
|
|
|
|
// Handle new conversation
|
|
const handleNewConversation = useCallback(() => {
|
|
newConversation();
|
|
if (isMobile) {
|
|
setSidebarOpen(false);
|
|
}
|
|
}, [newConversation, isMobile]);
|
|
|
|
// Convert ConversationSummary[] to ControlCenterConversation[] for sidebar
|
|
// The sidebar expects ControlCenterConversation but we only have summaries
|
|
const conversationsForSidebar: ControlCenterConversation[] = conversations.map((conv) => ({
|
|
id: conv.id,
|
|
title: conv.title,
|
|
messages: [],
|
|
createdAt: conv.createdAt,
|
|
updatedAt: conv.updatedAt,
|
|
}));
|
|
|
|
// Show loading state while initial data is being fetched
|
|
if (initialLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div
|
|
className={cn(
|
|
'w-16 h-16 rounded-2xl flex items-center justify-center',
|
|
'bg-[#F0F4F8]',
|
|
'shadow-[6px_6px_12px_#bfc3cc,-6px_-6px_12px_#ffffff]'
|
|
)}
|
|
>
|
|
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin" />
|
|
</div>
|
|
<p className="text-gray-500 font-medium">Loading Control Center...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-[calc(100vh-10rem)] flex flex-col">
|
|
{/* Mobile Header with Sidebar Toggle */}
|
|
<div className="lg:hidden flex items-center justify-between mb-4 px-1 flex-shrink-0">
|
|
<button
|
|
onClick={() => setSidebarOpen(true)}
|
|
className={cn(
|
|
'flex items-center gap-2 px-4 py-2.5',
|
|
'bg-[#F0F4F8]',
|
|
'rounded-xl',
|
|
'shadow-[4px_4px_8px_#bfc3cc,-4px_-4px_8px_#ffffff]',
|
|
'hover:shadow-[2px_2px_4px_#bfc3cc,-2px_-2px_4px_#ffffff]',
|
|
'active:shadow-[inset_2px_2px_4px_rgba(0,0,0,0.05)]',
|
|
'transition-all duration-200'
|
|
)}
|
|
aria-label="Open conversation history"
|
|
>
|
|
<Menu className="w-5 h-5 text-gray-600" />
|
|
<span className="text-sm font-medium text-gray-700">History</span>
|
|
</button>
|
|
|
|
<StatusIndicator
|
|
status={getConnectionStatus()}
|
|
mcpConnected={mcpStatus.connected}
|
|
/>
|
|
</div>
|
|
|
|
{/* Desktop Layout */}
|
|
<div className="hidden lg:grid lg:grid-cols-[280px_1fr] gap-6 flex-1 min-h-0 overflow-hidden">
|
|
{/* Left Sidebar */}
|
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
|
{/* Status Indicator */}
|
|
<div className="mb-4 flex justify-center">
|
|
<StatusIndicator
|
|
status={getConnectionStatus()}
|
|
mcpConnected={mcpStatus.connected}
|
|
/>
|
|
</div>
|
|
|
|
{/* Conversation Sidebar */}
|
|
<div className="flex-1 min-h-0">
|
|
<ConversationSidebar
|
|
conversations={conversationsForSidebar}
|
|
currentId={currentConversation?.id}
|
|
onSelect={handleSelectConversation}
|
|
onNew={handleNewConversation}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Chat Area */}
|
|
<div className="min-h-0 overflow-hidden">
|
|
<ChatInterface className="h-full" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Layout */}
|
|
<div className="lg:hidden flex-1 min-h-0 overflow-hidden">
|
|
<ChatInterface className="h-full" />
|
|
</div>
|
|
|
|
{/* Mobile Sidebar Overlay */}
|
|
{sidebarOpen && (
|
|
<div
|
|
className="lg:hidden fixed inset-0 z-50"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Conversation history"
|
|
>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
|
onClick={() => setSidebarOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Sidebar Panel */}
|
|
<div
|
|
className={cn(
|
|
'absolute left-0 top-0 h-full w-[280px]',
|
|
'bg-[#F0F4F8]',
|
|
'shadow-[8px_0_24px_rgba(0,0,0,0.15)]',
|
|
'animate-slide-in-from-left'
|
|
)}
|
|
>
|
|
{/* Close button */}
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200/50">
|
|
<h2 className="font-semibold text-gray-800">Conversation History</h2>
|
|
<button
|
|
onClick={() => setSidebarOpen(false)}
|
|
className={cn(
|
|
'p-2 rounded-lg',
|
|
'text-gray-500 hover:text-gray-700',
|
|
'hover:bg-gray-100',
|
|
'transition-colors'
|
|
)}
|
|
aria-label="Close sidebar"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sidebar content */}
|
|
<div className="h-[calc(100%-65px)]">
|
|
<ConversationSidebar
|
|
conversations={conversationsForSidebar}
|
|
currentId={currentConversation?.id}
|
|
onSelect={handleSelectConversation}
|
|
onNew={handleNewConversation}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MCP Warning Banner (shown at bottom on desktop if MCP not connected) */}
|
|
{!mcpStatus.connected && mcpStatus.error && !initialLoading && (
|
|
<div className="hidden lg:block fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-3 px-4 py-3',
|
|
'bg-amber-50 border border-amber-200',
|
|
'rounded-xl shadow-lg',
|
|
'max-w-md'
|
|
)}
|
|
>
|
|
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-medium text-amber-800">
|
|
Limited Functionality
|
|
</p>
|
|
<p className="text-xs text-amber-600">
|
|
{mcpStatus.error || 'MCP server not connected. Some tools may be unavailable.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main Page Component
|
|
// =============================================================================
|
|
|
|
export default function ControlCenterPage() {
|
|
return (
|
|
<ChatProvider>
|
|
<ControlCenterContent />
|
|
</ChatProvider>
|
|
);
|
|
}
|