diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1318cf8
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cresync?schema=public"
diff --git a/.gitignore b/.gitignore
index 5ef6a52..3fb63ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
@@ -39,3 +40,15 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+/src/generated/prisma
+
+# local database
+dev.db
+dev.db-journal
+
+# claude code
+.claude/
+
+# playwright mcp
+.playwright-mcp/
diff --git a/__tests__/api/control-center/conversations.test.ts b/__tests__/api/control-center/conversations.test.ts
new file mode 100644
index 0000000..e368c21
--- /dev/null
+++ b/__tests__/api/control-center/conversations.test.ts
@@ -0,0 +1,318 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { NextRequest } from 'next/server';
+import { GET } from '@/app/api/v1/control-center/conversations/route';
+import { prisma } from '@/lib/db';
+import * as auth from '@/lib/auth';
+
+// Get the mocked prisma instance
+const mockPrisma = vi.mocked(prisma);
+
+// Mock the auth module
+vi.mock('@/lib/auth', () => ({
+ getSession: vi.fn()
+}));
+
+// Mock the conversationService through the control-center module
+vi.mock('@/lib/control-center', () => ({
+ conversationService: {
+ listByUser: vi.fn()
+ }
+}));
+
+// Import the mocked conversation service
+import { conversationService } from '@/lib/control-center';
+const mockConversationService = vi.mocked(conversationService);
+
+describe('Conversations API', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ describe('GET /api/v1/control-center/conversations', () => {
+ it('should return 401 when not authenticated', async () => {
+ vi.mocked(auth.getSession).mockResolvedValue(null);
+
+ const request = new NextRequest('http://localhost:3000/api/v1/control-center/conversations');
+ const response = await GET(request);
+
+ expect(response.status).toBe(401);
+ const json = await response.json();
+ expect(json.error).toBe('Unauthorized');
+ });
+
+ it('should return conversations for authenticated user', async () => {
+ const mockUser = {
+ id: 'user-123',
+ email: 'test@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ const mockConversations = [
+ {
+ id: 'conv-1',
+ userId: 'user-123',
+ title: 'First conversation',
+ createdAt: new Date('2024-01-01'),
+ updatedAt: new Date('2024-01-01'),
+ _count: { messages: 5 }
+ },
+ {
+ id: 'conv-2',
+ userId: 'user-123',
+ title: 'Second conversation',
+ createdAt: new Date('2024-01-02'),
+ updatedAt: new Date('2024-01-02'),
+ _count: { messages: 3 }
+ }
+ ];
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue(mockConversations);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(2);
+
+ const request = new NextRequest('http://localhost:3000/api/v1/control-center/conversations');
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ const json = await response.json();
+
+ expect(json.conversations).toHaveLength(2);
+ expect(json.total).toBe(2);
+ expect(json.limit).toBe(20);
+ expect(json.offset).toBe(0);
+ expect(mockConversationService.listByUser).toHaveBeenCalledWith('user-123', 20, 0);
+ });
+
+ it('should handle pagination parameters correctly', async () => {
+ const mockUser = {
+ id: 'user-456',
+ email: 'test@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue([]);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(50);
+
+ const request = new NextRequest(
+ 'http://localhost:3000/api/v1/control-center/conversations?limit=10&offset=20'
+ );
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ const json = await response.json();
+
+ expect(json.limit).toBe(10);
+ expect(json.offset).toBe(20);
+ expect(mockConversationService.listByUser).toHaveBeenCalledWith('user-456', 10, 20);
+ });
+
+ it('should use default pagination when parameters are invalid', async () => {
+ const mockUser = {
+ id: 'user-789',
+ email: 'test@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue([]);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(0);
+
+ // Invalid parameters should parse as NaN, then parseInt will return NaN
+ const request = new NextRequest(
+ 'http://localhost:3000/api/v1/control-center/conversations?limit=invalid&offset=bad'
+ );
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ const json = await response.json();
+
+ // parseInt('invalid') returns NaN, which is falsy, but the code uses || not ??
+ // So NaN will use the default value
+ expect(mockConversationService.listByUser).toHaveBeenCalledWith(
+ 'user-789',
+ expect.any(Number),
+ expect.any(Number)
+ );
+ });
+
+ it('should handle empty conversation list', async () => {
+ const mockUser = {
+ id: 'new-user',
+ email: 'new@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue([]);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(0);
+
+ const request = new NextRequest('http://localhost:3000/api/v1/control-center/conversations');
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ const json = await response.json();
+
+ expect(json.conversations).toEqual([]);
+ expect(json.total).toBe(0);
+ });
+
+ it('should return 500 on database error', async () => {
+ const mockUser = {
+ id: 'user-error',
+ email: 'error@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockRejectedValue(new Error('Database connection failed'));
+
+ // Suppress console.error for this test
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const request = new NextRequest('http://localhost:3000/api/v1/control-center/conversations');
+ const response = await GET(request);
+
+ expect(response.status).toBe(500);
+ const json = await response.json();
+ expect(json.error).toBe('Failed to fetch conversations');
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should count conversations correctly for the authenticated user only', async () => {
+ const mockUser = {
+ id: 'specific-user',
+ email: 'specific@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue([]);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(25);
+
+ const request = new NextRequest('http://localhost:3000/api/v1/control-center/conversations');
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ const json = await response.json();
+
+ expect(json.total).toBe(25);
+ expect(mockPrisma.controlCenterConversation.count).toHaveBeenCalledWith({
+ where: { userId: 'specific-user' }
+ });
+ });
+
+ it('should return conversations with message count', async () => {
+ const mockUser = {
+ id: 'user-with-convos',
+ email: 'convos@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ const mockConversations = [
+ {
+ id: 'conv-with-messages',
+ userId: 'user-with-convos',
+ title: 'Conversation with messages',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ _count: { messages: 15 }
+ }
+ ];
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue(mockConversations);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(1);
+
+ const request = new NextRequest('http://localhost:3000/api/v1/control-center/conversations');
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ const json = await response.json();
+
+ expect(json.conversations[0]._count.messages).toBe(15);
+ });
+ });
+
+ describe('Pagination edge cases', () => {
+ it('should handle large offset beyond total count', async () => {
+ const mockUser = {
+ id: 'user-large-offset',
+ email: 'large@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue([]);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(5);
+
+ const request = new NextRequest(
+ 'http://localhost:3000/api/v1/control-center/conversations?offset=100'
+ );
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ const json = await response.json();
+
+ expect(json.conversations).toEqual([]);
+ expect(json.total).toBe(5);
+ expect(json.offset).toBe(100);
+ });
+
+ it('should handle zero limit', async () => {
+ const mockUser = {
+ id: 'user-zero-limit',
+ email: 'zero@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue([]);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(10);
+
+ const request = new NextRequest(
+ 'http://localhost:3000/api/v1/control-center/conversations?limit=0'
+ );
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ // Note: limit=0 with parseInt returns 0, which is falsy but a valid number
+ // The API should handle this, returning 0 items
+ });
+
+ it('should handle negative pagination values gracefully', async () => {
+ const mockUser = {
+ id: 'user-negative',
+ email: 'negative@example.com',
+ role: 'user' as const,
+ createdAt: new Date()
+ };
+
+ vi.mocked(auth.getSession).mockResolvedValue({ user: mockUser });
+ mockConversationService.listByUser.mockResolvedValue([]);
+ mockPrisma.controlCenterConversation.count.mockResolvedValue(10);
+
+ const request = new NextRequest(
+ 'http://localhost:3000/api/v1/control-center/conversations?limit=-5&offset=-10'
+ );
+ const response = await GET(request);
+
+ // The API should still return a response (behavior depends on Prisma handling)
+ expect(response.status).toBe(200);
+ });
+ });
+});
diff --git a/__tests__/lib/control-center/conversation-service.test.ts b/__tests__/lib/control-center/conversation-service.test.ts
new file mode 100644
index 0000000..7e79904
--- /dev/null
+++ b/__tests__/lib/control-center/conversation-service.test.ts
@@ -0,0 +1,296 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ConversationService } from '@/lib/control-center/conversation-service';
+import { prisma } from '@/lib/db';
+
+// Get the mocked prisma instance
+const mockPrisma = vi.mocked(prisma);
+
+describe('ConversationService', () => {
+ let conversationService: ConversationService;
+
+ beforeEach(() => {
+ conversationService = new ConversationService();
+ vi.clearAllMocks();
+ });
+
+ describe('create', () => {
+ it('should create a conversation with userId and title', async () => {
+ const mockConversation = {
+ id: 'conv-123',
+ userId: 'user-456',
+ title: 'Test Conversation',
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ mockPrisma.controlCenterConversation.create.mockResolvedValue(mockConversation);
+
+ const result = await conversationService.create('user-456', 'Test Conversation');
+
+ expect(mockPrisma.controlCenterConversation.create).toHaveBeenCalledWith({
+ data: { userId: 'user-456', title: 'Test Conversation' }
+ });
+ expect(result).toEqual(mockConversation);
+ });
+
+ it('should create a conversation without title', async () => {
+ const mockConversation = {
+ id: 'conv-123',
+ userId: 'user-456',
+ title: null,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ mockPrisma.controlCenterConversation.create.mockResolvedValue(mockConversation);
+
+ const result = await conversationService.create('user-456');
+
+ expect(mockPrisma.controlCenterConversation.create).toHaveBeenCalledWith({
+ data: { userId: 'user-456', title: undefined }
+ });
+ expect(result).toEqual(mockConversation);
+ });
+ });
+
+ describe('getById', () => {
+ it('should return conversation with messages ordered by createdAt', async () => {
+ const mockConversation = {
+ id: 'conv-123',
+ userId: 'user-456',
+ title: 'Test Conversation',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ messages: [
+ {
+ id: 'msg-1',
+ conversationId: 'conv-123',
+ role: 'user',
+ content: 'Hello',
+ createdAt: new Date('2024-01-01')
+ },
+ {
+ id: 'msg-2',
+ conversationId: 'conv-123',
+ role: 'assistant',
+ content: 'Hi there!',
+ createdAt: new Date('2024-01-02')
+ }
+ ]
+ };
+
+ mockPrisma.controlCenterConversation.findUnique.mockResolvedValue(mockConversation);
+
+ const result = await conversationService.getById('conv-123');
+
+ expect(mockPrisma.controlCenterConversation.findUnique).toHaveBeenCalledWith({
+ where: { id: 'conv-123' },
+ include: { messages: { orderBy: { createdAt: 'asc' } } }
+ });
+ expect(result).toEqual(mockConversation);
+ expect(result?.messages).toHaveLength(2);
+ });
+
+ it('should return null for non-existent conversation', async () => {
+ mockPrisma.controlCenterConversation.findUnique.mockResolvedValue(null);
+
+ const result = await conversationService.getById('non-existent');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('listByUser', () => {
+ it('should return paginated conversations for a user', async () => {
+ const mockConversations = [
+ {
+ id: 'conv-1',
+ userId: 'user-456',
+ title: 'Conversation 1',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ _count: { messages: 5 }
+ },
+ {
+ id: 'conv-2',
+ userId: 'user-456',
+ title: 'Conversation 2',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ _count: { messages: 3 }
+ }
+ ];
+
+ mockPrisma.controlCenterConversation.findMany.mockResolvedValue(mockConversations);
+
+ const result = await conversationService.listByUser('user-456', 20, 0);
+
+ expect(mockPrisma.controlCenterConversation.findMany).toHaveBeenCalledWith({
+ where: { userId: 'user-456' },
+ orderBy: { updatedAt: 'desc' },
+ take: 20,
+ skip: 0,
+ include: { _count: { select: { messages: true } } }
+ });
+ expect(result).toEqual(mockConversations);
+ expect(result).toHaveLength(2);
+ });
+
+ it('should use default pagination values', async () => {
+ mockPrisma.controlCenterConversation.findMany.mockResolvedValue([]);
+
+ await conversationService.listByUser('user-456');
+
+ expect(mockPrisma.controlCenterConversation.findMany).toHaveBeenCalledWith({
+ where: { userId: 'user-456' },
+ orderBy: { updatedAt: 'desc' },
+ take: 20,
+ skip: 0,
+ include: { _count: { select: { messages: true } } }
+ });
+ });
+
+ it('should apply custom pagination parameters', async () => {
+ mockPrisma.controlCenterConversation.findMany.mockResolvedValue([]);
+
+ await conversationService.listByUser('user-456', 10, 5);
+
+ expect(mockPrisma.controlCenterConversation.findMany).toHaveBeenCalledWith({
+ where: { userId: 'user-456' },
+ orderBy: { updatedAt: 'desc' },
+ take: 10,
+ skip: 5,
+ include: { _count: { select: { messages: true } } }
+ });
+ });
+ });
+
+ describe('addMessage', () => {
+ it('should add a message and update conversation timestamp', async () => {
+ const mockMessage = {
+ id: 'msg-123',
+ conversationId: 'conv-456',
+ role: 'user',
+ content: 'Hello!',
+ toolCalls: null,
+ toolResults: null,
+ model: null,
+ inputTokens: null,
+ outputTokens: null,
+ createdAt: new Date()
+ };
+
+ mockPrisma.controlCenterConversation.update.mockResolvedValue({} as any);
+ mockPrisma.controlCenterMessage.create.mockResolvedValue(mockMessage);
+
+ const messageData = {
+ role: 'user' as const,
+ content: 'Hello!'
+ };
+
+ const result = await conversationService.addMessage('conv-456', messageData);
+
+ expect(mockPrisma.controlCenterConversation.update).toHaveBeenCalledWith({
+ where: { id: 'conv-456' },
+ data: { updatedAt: expect.any(Date) }
+ });
+ expect(mockPrisma.controlCenterMessage.create).toHaveBeenCalledWith({
+ data: { conversationId: 'conv-456', ...messageData }
+ });
+ expect(result).toEqual(mockMessage);
+ });
+
+ it('should add assistant message with tool calls and usage data', async () => {
+ const toolCalls = [{ id: 'tc-1', name: 'get_contacts', input: {} }];
+ const toolResults = [{ toolCallId: 'tc-1', success: true, result: [] }];
+
+ const mockMessage = {
+ id: 'msg-123',
+ conversationId: 'conv-456',
+ role: 'assistant',
+ content: 'Here are your contacts',
+ toolCalls,
+ toolResults,
+ model: 'claude-sonnet-4-20250514',
+ inputTokens: 100,
+ outputTokens: 50,
+ createdAt: new Date()
+ };
+
+ mockPrisma.controlCenterConversation.update.mockResolvedValue({} as any);
+ mockPrisma.controlCenterMessage.create.mockResolvedValue(mockMessage);
+
+ const messageData = {
+ role: 'assistant' as const,
+ content: 'Here are your contacts',
+ toolCalls,
+ toolResults,
+ model: 'claude-sonnet-4-20250514',
+ inputTokens: 100,
+ outputTokens: 50
+ };
+
+ const result = await conversationService.addMessage('conv-456', messageData);
+
+ expect(mockPrisma.controlCenterMessage.create).toHaveBeenCalledWith({
+ data: { conversationId: 'conv-456', ...messageData }
+ });
+ expect(result.model).toBe('claude-sonnet-4-20250514');
+ expect(result.inputTokens).toBe(100);
+ expect(result.outputTokens).toBe(50);
+ });
+ });
+
+ describe('updateTitle', () => {
+ it('should update conversation title', async () => {
+ const mockConversation = {
+ id: 'conv-123',
+ userId: 'user-456',
+ title: 'New Title',
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ mockPrisma.controlCenterConversation.update.mockResolvedValue(mockConversation);
+
+ const result = await conversationService.updateTitle('conv-123', 'New Title');
+
+ expect(mockPrisma.controlCenterConversation.update).toHaveBeenCalledWith({
+ where: { id: 'conv-123' },
+ data: { title: 'New Title' }
+ });
+ expect(result.title).toBe('New Title');
+ });
+ });
+
+ describe('delete', () => {
+ it('should delete a conversation', async () => {
+ const mockConversation = {
+ id: 'conv-123',
+ userId: 'user-456',
+ title: 'Deleted',
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ mockPrisma.controlCenterConversation.delete.mockResolvedValue(mockConversation);
+
+ const result = await conversationService.delete('conv-123');
+
+ expect(mockPrisma.controlCenterConversation.delete).toHaveBeenCalledWith({
+ where: { id: 'conv-123' }
+ });
+ expect(result).toEqual(mockConversation);
+ });
+
+ it('should throw error for non-existent conversation', async () => {
+ mockPrisma.controlCenterConversation.delete.mockRejectedValue(
+ new Error('Record not found')
+ );
+
+ await expect(conversationService.delete('non-existent')).rejects.toThrow(
+ 'Record not found'
+ );
+ });
+ });
+});
diff --git a/__tests__/lib/control-center/tool-router.test.ts b/__tests__/lib/control-center/tool-router.test.ts
new file mode 100644
index 0000000..b70d2a1
--- /dev/null
+++ b/__tests__/lib/control-center/tool-router.test.ts
@@ -0,0 +1,341 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ToolRouter } from '@/lib/control-center/tool-router';
+import { MCPClient } from '@/lib/control-center/mcp-client';
+import { appToolsRegistry, appToolDefinitions } from '@/lib/control-center/app-tools';
+import { ToolCall, ToolContext, ToolDefinition } from '@/lib/control-center/types';
+
+// Mock the MCP client
+vi.mock('@/lib/control-center/mcp-client');
+
+// Mock the app tools registry
+vi.mock('@/lib/control-center/app-tools', () => ({
+ appToolsRegistry: {
+ getDefinitions: vi.fn(),
+ has: vi.fn(),
+ execute: vi.fn()
+ },
+ appToolDefinitions: [
+ {
+ name: 'get_dashboard_stats',
+ description: 'Get dashboard stats',
+ inputSchema: { type: 'object', properties: {} }
+ },
+ {
+ name: 'list_recent_contacts',
+ description: 'List contacts',
+ inputSchema: { type: 'object', properties: {} }
+ }
+ ]
+}));
+
+describe('ToolRouter', () => {
+ let mockMcpClient: MCPClient;
+ let mockContext: ToolContext;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Create mock MCP client
+ mockMcpClient = {
+ getTools: vi.fn(),
+ executeTool: vi.fn(),
+ healthCheck: vi.fn(),
+ clearCache: vi.fn()
+ } as unknown as MCPClient;
+
+ // Default context for tests
+ mockContext = {
+ userId: 'user-123',
+ ghlClient: null
+ };
+
+ // Setup default app tools mock
+ vi.mocked(appToolsRegistry.getDefinitions).mockReturnValue(appToolDefinitions);
+ vi.mocked(appToolsRegistry.has).mockReturnValue(false);
+ });
+
+ describe('getAllTools', () => {
+ it('should return combined MCP and app tools', async () => {
+ const mcpTools: ToolDefinition[] = [
+ {
+ name: 'ghl_get_contacts',
+ description: 'Get GHL contacts',
+ inputSchema: { type: 'object', properties: {} }
+ },
+ {
+ name: 'ghl_send_message',
+ description: 'Send GHL message',
+ inputSchema: { type: 'object', properties: {} }
+ }
+ ];
+
+ vi.mocked(mockMcpClient.getTools).mockResolvedValue(mcpTools);
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const tools = await toolRouter.getAllTools();
+
+ expect(appToolsRegistry.getDefinitions).toHaveBeenCalled();
+ expect(mockMcpClient.getTools).toHaveBeenCalled();
+ expect(tools).toHaveLength(4); // 2 app tools + 2 MCP tools
+ expect(tools.map(t => t.name)).toContain('get_dashboard_stats');
+ expect(tools.map(t => t.name)).toContain('ghl_get_contacts');
+ });
+
+ it('should return only app tools when MCP client is null', async () => {
+ const toolRouter = new ToolRouter(null);
+ const tools = await toolRouter.getAllTools();
+
+ expect(appToolsRegistry.getDefinitions).toHaveBeenCalled();
+ expect(tools).toEqual(appToolDefinitions);
+ expect(tools).toHaveLength(2);
+ });
+
+ it('should return only app tools when MCP getTools fails', async () => {
+ vi.mocked(mockMcpClient.getTools).mockRejectedValue(new Error('MCP connection failed'));
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const tools = await toolRouter.getAllTools();
+
+ expect(tools).toEqual(appToolDefinitions);
+ expect(tools).toHaveLength(2);
+ });
+
+ it('should handle MCP returning empty tools array', async () => {
+ vi.mocked(mockMcpClient.getTools).mockResolvedValue([]);
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const tools = await toolRouter.getAllTools();
+
+ expect(tools).toEqual(appToolDefinitions);
+ });
+ });
+
+ describe('execute', () => {
+ it('should route app tool calls to appToolsRegistry', async () => {
+ const toolCall: ToolCall = {
+ id: 'tc-123',
+ name: 'get_dashboard_stats',
+ input: {}
+ };
+
+ vi.mocked(appToolsRegistry.has).mockReturnValue(true);
+ vi.mocked(appToolsRegistry.execute).mockResolvedValue({
+ toolCallId: '',
+ success: true,
+ result: { totalContacts: 100, totalConversations: 50 }
+ });
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const result = await toolRouter.execute(toolCall, mockContext);
+
+ expect(appToolsRegistry.has).toHaveBeenCalledWith('get_dashboard_stats');
+ expect(appToolsRegistry.execute).toHaveBeenCalledWith(
+ 'get_dashboard_stats',
+ {},
+ mockContext
+ );
+ expect(result.toolCallId).toBe('tc-123');
+ expect(result.success).toBe(true);
+ expect(result.result).toEqual({ totalContacts: 100, totalConversations: 50 });
+ });
+
+ it('should route MCP tool calls to MCPClient', async () => {
+ const toolCall: ToolCall = {
+ id: 'tc-456',
+ name: 'ghl_get_contacts',
+ input: { limit: 10 }
+ };
+
+ vi.mocked(appToolsRegistry.has).mockReturnValue(false);
+ vi.mocked(mockMcpClient.executeTool).mockResolvedValue({
+ toolCallId: '',
+ success: true,
+ result: [{ id: 'contact-1', name: 'John Doe' }]
+ });
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const result = await toolRouter.execute(toolCall, mockContext);
+
+ expect(appToolsRegistry.has).toHaveBeenCalledWith('ghl_get_contacts');
+ expect(mockMcpClient.executeTool).toHaveBeenCalledWith('ghl_get_contacts', { limit: 10 });
+ expect(result.toolCallId).toBe('tc-456');
+ expect(result.success).toBe(true);
+ });
+
+ it('should return error when MCP client is not configured for MCP tools', async () => {
+ const toolCall: ToolCall = {
+ id: 'tc-789',
+ name: 'ghl_get_contacts',
+ input: {}
+ };
+
+ vi.mocked(appToolsRegistry.has).mockReturnValue(false);
+
+ const toolRouter = new ToolRouter(null);
+ const result = await toolRouter.execute(toolCall, mockContext);
+
+ expect(result.toolCallId).toBe('tc-789');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('MCP client not configured');
+ });
+
+ it('should handle app tool execution errors', async () => {
+ const toolCall: ToolCall = {
+ id: 'tc-error',
+ name: 'list_recent_contacts',
+ input: {}
+ };
+
+ vi.mocked(appToolsRegistry.has).mockReturnValue(true);
+ vi.mocked(appToolsRegistry.execute).mockResolvedValue({
+ toolCallId: '',
+ success: false,
+ error: 'GHL not configured'
+ });
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const result = await toolRouter.execute(toolCall, mockContext);
+
+ expect(result.toolCallId).toBe('tc-error');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('GHL not configured');
+ });
+
+ it('should handle MCP tool execution errors', async () => {
+ const toolCall: ToolCall = {
+ id: 'tc-mcp-error',
+ name: 'ghl_send_message',
+ input: { contactId: '123', message: 'Hello' }
+ };
+
+ vi.mocked(appToolsRegistry.has).mockReturnValue(false);
+ vi.mocked(mockMcpClient.executeTool).mockResolvedValue({
+ toolCallId: '',
+ success: false,
+ error: 'Tool execution failed: 500 - Internal server error'
+ });
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const result = await toolRouter.execute(toolCall, mockContext);
+
+ expect(result.toolCallId).toBe('tc-mcp-error');
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('Tool execution failed');
+ });
+ });
+
+ describe('executeAll', () => {
+ it('should execute multiple tool calls in parallel', async () => {
+ const toolCalls: ToolCall[] = [
+ { id: 'tc-1', name: 'get_dashboard_stats', input: {} },
+ { id: 'tc-2', name: 'ghl_get_contacts', input: { limit: 5 } }
+ ];
+
+ vi.mocked(appToolsRegistry.has).mockImplementation((name) =>
+ name === 'get_dashboard_stats'
+ );
+
+ vi.mocked(appToolsRegistry.execute).mockResolvedValue({
+ toolCallId: '',
+ success: true,
+ result: { totalContacts: 100 }
+ });
+
+ vi.mocked(mockMcpClient.executeTool).mockResolvedValue({
+ toolCallId: '',
+ success: true,
+ result: []
+ });
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const results = await toolRouter.executeAll(toolCalls, mockContext);
+
+ expect(results).toHaveLength(2);
+ expect(results[0].toolCallId).toBe('tc-1');
+ expect(results[1].toolCallId).toBe('tc-2');
+ });
+
+ it('should handle partial failures in executeAll', async () => {
+ const toolCalls: ToolCall[] = [
+ { id: 'tc-1', name: 'get_dashboard_stats', input: {} },
+ { id: 'tc-2', name: 'failing_tool', input: {} }
+ ];
+
+ vi.mocked(appToolsRegistry.has).mockImplementation((name) =>
+ name === 'get_dashboard_stats' || name === 'failing_tool'
+ );
+
+ vi.mocked(appToolsRegistry.execute)
+ .mockResolvedValueOnce({
+ toolCallId: '',
+ success: true,
+ result: { totalContacts: 100 }
+ })
+ .mockResolvedValueOnce({
+ toolCallId: '',
+ success: false,
+ error: 'Tool failed'
+ });
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const results = await toolRouter.executeAll(toolCalls, mockContext);
+
+ expect(results).toHaveLength(2);
+ expect(results[0].success).toBe(true);
+ expect(results[1].success).toBe(false);
+ });
+
+ it('should return empty array for empty tool calls', async () => {
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const results = await toolRouter.executeAll([], mockContext);
+
+ expect(results).toEqual([]);
+ });
+ });
+
+ describe('handles MCP not configured gracefully', () => {
+ it('should work with null MCP client for app-only workflows', async () => {
+ const toolCall: ToolCall = {
+ id: 'tc-app',
+ name: 'get_dashboard_stats',
+ input: {}
+ };
+
+ vi.mocked(appToolsRegistry.has).mockReturnValue(true);
+ vi.mocked(appToolsRegistry.execute).mockResolvedValue({
+ toolCallId: '',
+ success: true,
+ result: { totalContacts: 50 }
+ });
+
+ const toolRouter = new ToolRouter(null);
+
+ // Get tools should work
+ const tools = await toolRouter.getAllTools();
+ expect(tools).toEqual(appToolDefinitions);
+
+ // Execute app tools should work
+ const result = await toolRouter.execute(toolCall, mockContext);
+ expect(result.success).toBe(true);
+ });
+
+ it('should log error but return app tools when MCP fails', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ vi.mocked(mockMcpClient.getTools).mockRejectedValue(
+ new Error('Connection refused')
+ );
+
+ const toolRouter = new ToolRouter(mockMcpClient);
+ const tools = await toolRouter.getAllTools();
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to get MCP tools:',
+ expect.any(Error)
+ );
+ expect(tools).toEqual(appToolDefinitions);
+
+ consoleSpy.mockRestore();
+ });
+ });
+});
diff --git a/__tests__/setup.ts b/__tests__/setup.ts
new file mode 100644
index 0000000..2c7c2c5
--- /dev/null
+++ b/__tests__/setup.ts
@@ -0,0 +1,46 @@
+import { vi } from 'vitest';
+
+// Mock Prisma client
+vi.mock('@/lib/db', () => ({
+ prisma: {
+ controlCenterConversation: {
+ create: vi.fn(),
+ findUnique: vi.fn(),
+ findMany: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ count: vi.fn()
+ },
+ controlCenterMessage: {
+ create: vi.fn(),
+ findMany: vi.fn()
+ },
+ user: {
+ findUnique: vi.fn()
+ }
+ }
+}));
+
+// Mock Next.js cookies
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(() => ({
+ get: vi.fn(),
+ set: vi.fn(),
+ delete: vi.fn()
+ }))
+}));
+
+// Mock GHL helpers
+vi.mock('@/lib/ghl/helpers', () => ({
+ getGHLClientForUser: vi.fn()
+}));
+
+// Mock settings service
+vi.mock('@/lib/settings/settings-service', () => ({
+ settingsService: {
+ get: vi.fn()
+ }
+}));
+
+// Global fetch mock
+global.fetch = vi.fn();
diff --git a/app/(app)/admin/layout.tsx b/app/(app)/admin/layout.tsx
new file mode 100644
index 0000000..76001e9
--- /dev/null
+++ b/app/(app)/admin/layout.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
+
+export default function AdminLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ Checking permissions...
+
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx
new file mode 100644
index 0000000..071001e
--- /dev/null
+++ b/app/(app)/admin/page.tsx
@@ -0,0 +1,1127 @@
+'use client';
+
+import React, { useState, useEffect, useMemo } from 'react';
+import { useRouter } from 'next/navigation';
+import {
+ Shield,
+ Users,
+ Settings,
+ BarChart2,
+ DollarSign,
+ AlertTriangle,
+ Clock,
+ Search,
+ ChevronUp,
+ ChevronDown,
+ CheckCircle,
+ XCircle,
+ Bell,
+ Eye,
+ EyeOff,
+ Check,
+ X,
+ Loader2,
+ Save,
+ RefreshCw,
+ UserPlus,
+} from 'lucide-react';
+import { api } from '@/lib/api/client';
+import { Role, AdminDashboardStats, AdminUserView, SystemSettings } from '@/types';
+import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
+import { PERMISSIONS } from '@/lib/auth/roles';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+type TabType = 'overview' | 'users' | 'settings';
+type SortField = 'name' | 'email' | 'brokerage' | 'gci' | 'createdAt' | 'setupCompletion';
+type SortDirection = 'asc' | 'desc';
+type FilterType = 'all' | 'highGCI' | 'incompleteSetup' | 'completeSetup' | 'pendingDFY';
+
+interface RecentSignup {
+ id: string;
+ firstName: string;
+ lastName: string;
+ email: string;
+ createdAt: Date;
+ gciRange?: string;
+}
+
+// =============================================================================
+// Helper Functions
+// =============================================================================
+
+const parseGCI = (gciString?: string): number => {
+ if (!gciString) return 0;
+ const cleaned = gciString.replace(/[$,]/g, '');
+ return parseFloat(cleaned) || 0;
+};
+
+const isHighGCI = (gciRange?: string): boolean => {
+ return parseGCI(gciRange) >= 100000;
+};
+
+const getSetupCompletionCount = (status?: AdminUserView['setupStatus']): number => {
+ if (!status) return 0;
+ return [
+ status.smsConfigured,
+ status.emailConfigured,
+ status.contactsImported,
+ status.campaignsSetup
+ ].filter(Boolean).length;
+};
+
+const hasIncompleteSetup = (status?: AdminUserView['setupStatus']): boolean => {
+ return getSetupCompletionCount(status) < 4;
+};
+
+// =============================================================================
+// Tab Navigation Component
+// =============================================================================
+
+function TabNavigation({
+ activeTab,
+ onTabChange
+}: {
+ activeTab: TabType;
+ onTabChange: (tab: TabType) => void;
+}) {
+ const tabs = [
+ { id: 'overview' as TabType, label: 'Overview', icon: BarChart2 },
+ { id: 'users' as TabType, label: 'Users', icon: Users },
+ { id: 'settings' as TabType, label: 'Settings', icon: Settings },
+ ];
+
+ return (
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ const isActive = activeTab === tab.id;
+ return (
+ onTabChange(tab.id)}
+ className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all ${
+ isActive
+ ? 'bg-primary-600 text-white shadow-[4px_4px_8px_rgba(0,0,0,0.15),-2px_-2px_6px_rgba(255,255,255,0.8)]'
+ : 'bg-white text-slate-600 hover:bg-slate-100 shadow-[4px_4px_8px_rgba(0,0,0,0.1),-2px_-2px_6px_rgba(255,255,255,0.9)]'
+ }`}
+ >
+
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
+
+// =============================================================================
+// Stats Card Component
+// =============================================================================
+
+function StatCard({
+ icon,
+ label,
+ value,
+ iconBgClass = 'bg-primary-100',
+ iconColorClass = 'text-primary-600',
+ isLoading = false
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string | number;
+ iconBgClass?: string;
+ iconColorClass?: string;
+ isLoading?: boolean;
+}) {
+ return (
+
+
+
+ {icon}
+
+
+
{label}
+ {isLoading ? (
+
+ ) : (
+
{value}
+ )}
+
+
+
+ );
+}
+
+// =============================================================================
+// Overview Tab Component
+// =============================================================================
+
+function OverviewTab({
+ stats,
+ recentSignups,
+ isLoading
+}: {
+ stats: AdminDashboardStats | null;
+ recentSignups: RecentSignup[];
+ isLoading: boolean;
+}) {
+ return (
+
+ {/* Stats Grid */}
+
+ }
+ label="Total Users"
+ value={stats?.totalUsers ?? 0}
+ iconBgClass="bg-primary-100"
+ iconColorClass="text-primary-600"
+ isLoading={isLoading}
+ />
+ }
+ label="High GCI Users ($100K+)"
+ value={stats?.highGCIUsers ?? 0}
+ iconBgClass="bg-yellow-100"
+ iconColorClass="text-yellow-600"
+ isLoading={isLoading}
+ />
+ }
+ label="Incomplete Setup"
+ value={stats?.incompleteSetup ?? 0}
+ iconBgClass="bg-red-100"
+ iconColorClass="text-red-600"
+ isLoading={isLoading}
+ />
+ }
+ label="Pending DFY"
+ value={stats?.dfyRequestsPending ?? 0}
+ iconBgClass="bg-purple-100"
+ iconColorClass="text-purple-600"
+ isLoading={isLoading}
+ />
+
+
+ {/* Recent Signups */}
+
+
+
Recent Signups
+ Last 7 days
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : recentSignups.length === 0 ? (
+
+ ) : (
+
+ {recentSignups.map((user) => (
+
+
+
+ {user.firstName?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
+
+
+
+ {user.firstName} {user.lastName}
+
+
{user.email}
+
+
+
+
+ {new Date(user.createdAt).toLocaleDateString()}
+
+ {user.gciRange && isHighGCI(user.gciRange) && (
+
+ High GCI
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// =============================================================================
+// Users Tab Component
+// =============================================================================
+
+function UsersTab({
+ users,
+ isLoading,
+ onRefresh
+}: {
+ users: AdminUserView[];
+ isLoading: boolean;
+ onRefresh: () => void;
+}) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [sortField, setSortField] = useState('createdAt');
+ const [sortDirection, setSortDirection] = useState('desc');
+ const [filterType, setFilterType] = useState('all');
+
+ const handleSort = (field: SortField) => {
+ if (sortField === field) {
+ setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortField(field);
+ setSortDirection('asc');
+ }
+ };
+
+ const filteredAndSortedUsers = useMemo(() => {
+ let result = [...users];
+
+ // Apply search filter
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ result = result.filter(
+ (user) =>
+ user.firstName?.toLowerCase().includes(query) ||
+ user.lastName?.toLowerCase().includes(query) ||
+ user.email.toLowerCase().includes(query) ||
+ user.brokerage?.toLowerCase().includes(query)
+ );
+ }
+
+ // Apply type filter
+ switch (filterType) {
+ case 'highGCI':
+ result = result.filter((user) => isHighGCI(user.gciRange));
+ break;
+ case 'incompleteSetup':
+ result = result.filter((user) => hasIncompleteSetup(user.setupStatus));
+ break;
+ case 'completeSetup':
+ result = result.filter((user) => !hasIncompleteSetup(user.setupStatus));
+ break;
+ }
+
+ // Apply sorting
+ result.sort((a, b) => {
+ let comparison = 0;
+
+ switch (sortField) {
+ case 'name':
+ comparison = `${a.firstName} ${a.lastName}`.localeCompare(
+ `${b.firstName} ${b.lastName}`
+ );
+ break;
+ case 'email':
+ comparison = a.email.localeCompare(b.email);
+ break;
+ case 'brokerage':
+ comparison = (a.brokerage || '').localeCompare(b.brokerage || '');
+ break;
+ case 'gci':
+ comparison = parseGCI(a.gciRange) - parseGCI(b.gciRange);
+ break;
+ case 'createdAt':
+ comparison =
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
+ break;
+ case 'setupCompletion':
+ comparison =
+ getSetupCompletionCount(a.setupStatus) -
+ getSetupCompletionCount(b.setupStatus);
+ break;
+ }
+
+ return sortDirection === 'asc' ? comparison : -comparison;
+ });
+
+ return result;
+ }, [users, searchQuery, sortField, sortDirection, filterType]);
+
+ const SortIcon = ({ field }: { field: SortField }) => {
+ if (sortField !== field) return null;
+ return sortDirection === 'asc' ? (
+
+ ) : (
+
+ );
+ };
+
+ const StatusBadge = ({
+ configured,
+ label
+ }: {
+ configured: boolean;
+ label: string;
+ }) => (
+
+ {configured ? : }
+ {label}
+
+ );
+
+ return (
+
+ {/* Filters and Search */}
+
+
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-10 pr-4 py-3 rounded-xl bg-slate-50 border-none shadow-inner focus:ring-2 focus:ring-primary-500 outline-none"
+ />
+
+
+ {/* Filter Buttons */}
+
+
setFilterType('all')}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
+ filterType === 'all'
+ ? 'bg-primary-600 text-white'
+ : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
+ }`}
+ >
+ All
+
+
setFilterType('highGCI')}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
+ filterType === 'highGCI'
+ ? 'bg-yellow-500 text-white'
+ : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
+ }`}
+ >
+ High GCI
+
+
setFilterType('incompleteSetup')}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
+ filterType === 'incompleteSetup'
+ ? 'bg-red-500 text-white'
+ : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
+ }`}
+ >
+ Incomplete
+
+
setFilterType('completeSetup')}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
+ filterType === 'completeSetup'
+ ? 'bg-green-500 text-white'
+ : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
+ }`}
+ >
+ Complete
+
+
+ Refresh
+
+
+
+
+
+ {/* Users Table */}
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+ handleSort('name')}
+ >
+ Name
+
+ handleSort('email')}
+ >
+ Email
+
+ handleSort('brokerage')}
+ >
+ Brokerage
+
+ handleSort('gci')}
+ >
+ GCI Range
+
+ handleSort('setupCompletion')}
+ >
+ Setup Status
+
+ handleSort('createdAt')}
+ >
+ Joined
+
+
+
+
+ {filteredAndSortedUsers.map((user) => {
+ const userIsHighGCI = isHighGCI(user.gciRange);
+ const userHasIncompleteSetup = hasIncompleteSetup(user.setupStatus);
+
+ return (
+
+
+
+
+ {user.firstName?.[0]?.toUpperCase() ||
+ user.email[0].toUpperCase()}
+
+
+ {user.firstName} {user.lastName}
+
+ {userIsHighGCI && (
+
+ High GCI
+
+ )}
+
+
+ {user.email}
+
+ {user.brokerage || (
+ Not set
+ )}
+
+
+
+ {user.gciRange || 'N/A'}
+
+
+
+
+
+
+
+
+
+
+
+ {new Date(user.createdAt).toLocaleDateString()}
+
+
+ );
+ })}
+
+
+
+ {filteredAndSortedUsers.length === 0 && (
+
+
+
No users found
+
+ Try adjusting your search or filter criteria
+
+
+ )}
+
+ )}
+
+
+ {/* Results Summary */}
+
+ Showing {filteredAndSortedUsers.length} of {users.length} users
+
+
+ );
+}
+
+// =============================================================================
+// Settings Tab Component
+// =============================================================================
+
+function SettingsTab({
+ initialSettings,
+ onSave,
+ onTestGHL,
+ onTestStripe,
+ isLoading
+}: {
+ initialSettings: Record;
+ onSave: (settings: Record) => Promise;
+ onTestGHL: () => Promise<{ success: boolean; error?: string }>;
+ onTestStripe: () => Promise<{ success: boolean; error?: string }>;
+ isLoading: boolean;
+}) {
+ const [settings, setSettings] = useState(initialSettings);
+ const [showSecrets, setShowSecrets] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const [testingGHL, setTestingGHL] = useState(false);
+ const [testingStripe, setTestingStripe] = useState(false);
+ const [ghlStatus, setGhlStatus] = useState<'idle' | 'success' | 'error'>('idle');
+ const [stripeStatus, setStripeStatus] = useState<'idle' | 'success' | 'error'>('idle');
+
+ useEffect(() => {
+ setSettings(initialSettings);
+ }, [initialSettings]);
+
+ const handleChange = (key: string, value: string) => {
+ setSettings((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await onSave(settings);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleTestGHL = async () => {
+ setTestingGHL(true);
+ setGhlStatus('idle');
+ try {
+ const result = await onTestGHL();
+ setGhlStatus(result.success ? 'success' : 'error');
+ } finally {
+ setTestingGHL(false);
+ }
+ };
+
+ const handleTestStripe = async () => {
+ setTestingStripe(true);
+ setStripeStatus('idle');
+ try {
+ const result = await onTestStripe();
+ setStripeStatus(result.success ? 'success' : 'error');
+ } finally {
+ setTestingStripe(false);
+ }
+ };
+
+ const toggleSecret = (key: string) => {
+ setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ const renderSecretInput = (key: string, label: string, placeholder: string) => (
+
+
{label}
+
+ handleChange(key, e.target.value)}
+ placeholder={placeholder}
+ className="w-full p-3 pr-10 rounded-xl bg-slate-50 border-none shadow-inner focus:ring-2 focus:ring-primary-500 outline-none"
+ />
+ toggleSecret(key)}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {showSecrets[key] ? : }
+
+
+
+ );
+
+ const renderTextInput = (key: string, label: string, placeholder: string) => (
+
+ {label}
+ handleChange(key, e.target.value)}
+ placeholder={placeholder}
+ className="w-full p-3 rounded-xl bg-slate-50 border-none shadow-inner focus:ring-2 focus:ring-primary-500 outline-none"
+ />
+
+ );
+
+ if (isLoading) {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {/* GHL Configuration */}
+
+
+
+ GoHighLevel Configuration
+
+
+ {testingGHL ? (
+
+ ) : ghlStatus === 'success' ? (
+
+ ) : ghlStatus === 'error' ? (
+
+ ) : null}
+ Test Connection
+
+
+
+ Configure your GHL Private Integration OAuth credentials. Create a Private Integration in GHL under Settings → Integrations → Private Integrations.
+
+
+ {renderTextInput(
+ 'ghlClientId',
+ 'Client ID',
+ 'OAuth Client ID from Private Integration'
+ )}
+ {renderSecretInput(
+ 'ghlClientSecret',
+ 'Client Secret',
+ 'OAuth Client Secret from Private Integration'
+ )}
+ {renderSecretInput(
+ 'ghlAccessToken',
+ 'Access Token',
+ 'OAuth Access Token (from authorization)'
+ )}
+ {renderSecretInput(
+ 'ghlRefreshToken',
+ 'Refresh Token',
+ 'OAuth Refresh Token (for auto-renewal)'
+ )}
+ {renderTextInput(
+ 'ghlLocationId',
+ 'Location ID',
+ 'Your GHL Location/Sub-account ID'
+ )}
+ {renderSecretInput(
+ 'ghlWebhookSecret',
+ 'Webhook Secret',
+ 'Secret for validating webhooks (optional)'
+ )}
+
+
+ {/* Legacy/Agency Settings (collapsible or secondary) */}
+
+
+ Agency Settings (for user provisioning)
+
+
+ {renderSecretInput(
+ 'ghlAgencyApiKey',
+ 'Agency API Key',
+ 'For creating sub-accounts'
+ )}
+ {renderTextInput('ghlAgencyId', 'Agency ID', 'Your GHL Agency/Company ID')}
+
+
+
+
+ {/* Tag Configuration */}
+
+
Tag Configuration
+
+ These tags will be applied to contacts in the owner's GHL account to
+ trigger automations.
+
+
+ {renderTextInput('tagHighGCI', 'High GCI Tag', 'e.g., high-gci-lead')}
+ {renderTextInput(
+ 'tagOnboardingComplete',
+ 'Onboarding Complete Tag',
+ 'e.g., onboarding-done'
+ )}
+ {renderTextInput(
+ 'tagDFYRequested',
+ 'DFY Requested Tag',
+ 'e.g., dfy-requested'
+ )}
+
+
+
+ {/* Stripe Configuration */}
+
+
+
Stripe Configuration
+
+ {testingStripe ? (
+
+ ) : stripeStatus === 'success' ? (
+
+ ) : stripeStatus === 'error' ? (
+
+ ) : null}
+ Test Connection
+
+
+
+ {renderSecretInput(
+ 'stripeSecretKey',
+ 'Stripe Secret Key',
+ 'sk_live_... or sk_test_...'
+ )}
+ {renderSecretInput(
+ 'stripeWebhookSecret',
+ 'Stripe Webhook Secret',
+ 'whsec_...'
+ )}
+
+
+
+ {/* DFY Pricing Configuration */}
+
+
+ DFY Pricing (in cents)
+
+
+ {renderTextInput(
+ 'dfyPriceFullSetup',
+ 'Full Setup Price',
+ 'e.g., 29700 for $297'
+ )}
+ {renderTextInput(
+ 'dfyPriceSmsSetup',
+ 'SMS Setup Price',
+ 'e.g., 9900 for $99'
+ )}
+ {renderTextInput(
+ 'dfyPriceEmailSetup',
+ 'Email Setup Price',
+ 'e.g., 9900 for $99'
+ )}
+
+
+
+ {/* Calendly Links */}
+
+
Calendly Links
+
+ {renderTextInput(
+ 'calendlyCoachingLink',
+ 'Coaching Calendly Link',
+ 'https://calendly.com/...'
+ )}
+ {renderTextInput(
+ 'calendlyTeamLink',
+ 'Join Team Calendly Link',
+ 'https://calendly.com/...'
+ )}
+
+
+
+ {/* Notifications */}
+
+
Notifications
+
+ {renderTextInput(
+ 'notificationEmail',
+ 'Notification Email',
+ 'Email for high-GCI alerts'
+ )}
+ {renderTextInput(
+ 'slackWebhookUrl',
+ 'Slack Webhook URL',
+ 'Optional: Slack notifications'
+ )}
+
+
+
+ {/* ClickUp Integration */}
+
+
+ ClickUp Integration (for DFY tasks)
+
+
+ {renderSecretInput(
+ 'clickupApiKey',
+ 'ClickUp API Key',
+ 'Enter your ClickUp API key'
+ )}
+ {renderTextInput('clickupListId', 'ClickUp List ID', 'List ID for DFY tasks')}
+
+
+
+ {/* AI Configuration */}
+
+
+ AI Configuration
+
+
+ Configure AI service API keys for intelligent features and automations.
+
+
+ {renderSecretInput(
+ 'claudeApiKey',
+ 'Claude API Key',
+ 'sk-ant-... (Anthropic API key)'
+ )}
+ {renderSecretInput(
+ 'openaiApiKey',
+ 'OpenAI API Key',
+ 'sk-... (OpenAI API key)'
+ )}
+ {renderTextInput(
+ 'mcpServerUrl',
+ 'MCP Server URL',
+ 'https://your-mcp-server.com'
+ )}
+
+
+
+ {/* Save Button */}
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+ Save Settings
+
+
+
+ );
+}
+
+// =============================================================================
+// Main Admin Page Component
+// =============================================================================
+
+export default function AdminPage() {
+ const router = useRouter();
+ const [activeTab, setActiveTab] = useState('overview');
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Data states
+ const [stats, setStats] = useState(null);
+ const [users, setUsers] = useState([]);
+ const [recentSignups, setRecentSignups] = useState([]);
+ const [settings, setSettings] = useState>({});
+
+ // Fetch data
+ useEffect(() => {
+ const fetchData = async () => {
+ setIsLoading(true);
+ try {
+ const [statsResponse, usersResponse, settingsResponse] = await Promise.all([
+ api.admin.getStats(),
+ api.admin.getUsers({ limit: 100 }),
+ api.admin.getSettings(),
+ ]);
+
+ if (statsResponse?.stats) {
+ setStats(statsResponse.stats);
+ }
+
+ if (usersResponse?.users) {
+ setUsers(usersResponse.users);
+ // Extract recent signups (last 7 days)
+ const sevenDaysAgo = new Date();
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
+ const recent = usersResponse.users
+ .filter((u: AdminUserView) => new Date(u.createdAt) >= sevenDaysAgo)
+ .sort((a: AdminUserView, b: AdminUserView) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ )
+ .slice(0, 5)
+ .map((u: AdminUserView) => ({
+ id: u.id,
+ firstName: u.firstName || '',
+ lastName: u.lastName || '',
+ email: u.email,
+ createdAt: u.createdAt,
+ gciRange: u.gciRange,
+ }));
+ setRecentSignups(recent);
+ }
+
+ if (settingsResponse?.settings) {
+ // Convert settings to string record for form
+ const settingsRecord: Record = {};
+ Object.entries(settingsResponse.settings).forEach(([key, value]) => {
+ settingsRecord[key] = value?.toString() || '';
+ });
+ setSettings(settingsRecord);
+ }
+ } catch (error) {
+ console.error('Error fetching admin data:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ const handleSaveSettings = async (newSettings: Record) => {
+ await api.admin.updateSettings(newSettings);
+ setSettings(newSettings);
+ };
+
+ const handleTestGHL = async () => {
+ return api.admin.testConnection('ghl');
+ };
+
+ const handleTestStripe = async () => {
+ return api.admin.testConnection('stripe');
+ };
+
+ const handleRefreshUsers = async () => {
+ try {
+ const usersResponse = await api.admin.getUsers({ limit: 100 });
+ if (usersResponse?.users) {
+ setUsers(usersResponse.users);
+ }
+ } catch (error) {
+ console.error('Error refreshing users:', error);
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
Admin Settings
+
+ System configuration and user management
+
+
+
+
+ {/* Tab Navigation */}
+
+
+ {/* Tab Content */}
+ {activeTab === 'overview' && (
+
+ )}
+
+ {activeTab === 'users' && (
+
+ )}
+
+ {activeTab === 'settings' && (
+
+ )}
+
+
+ );
+}
diff --git a/app/(app)/automations/page.tsx b/app/(app)/automations/page.tsx
new file mode 100644
index 0000000..58ee17c
--- /dev/null
+++ b/app/(app)/automations/page.tsx
@@ -0,0 +1,151 @@
+'use client';
+
+import React, { useState } from 'react';
+import {
+ Zap,
+ Play,
+ Clock,
+ Mail,
+ MessageSquare,
+ GitBranch,
+ Target,
+ Bell,
+ CheckCircle2,
+ ArrowRight,
+ Sparkles
+} from 'lucide-react';
+
+export default function AutomationsPage() {
+ const [email, setEmail] = useState('');
+ const [submitted, setSubmitted] = useState(false);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (email) {
+ setSubmitted(true);
+ setEmail('');
+ }
+ };
+
+ const upcomingFeatures = [
+ { icon: Play, title: 'Trigger-Based Workflows', description: 'Start automations when deals change status, contacts engage, or time conditions are met' },
+ { icon: Mail, title: 'Automated Email Sequences', description: 'Send personalized follow-up emails automatically based on prospect behavior' },
+ { icon: MessageSquare, title: 'SMS & Notification Actions', description: 'Reach out via text or push notifications at the perfect moment' },
+ { icon: GitBranch, title: 'Conditional Logic', description: 'Build smart workflows with if/then branches and decision points' },
+ { icon: Target, title: 'Lead Scoring Automation', description: 'Automatically prioritize leads based on engagement and criteria' },
+ { icon: Clock, title: 'Scheduled Actions', description: 'Set delays, schedule sends, and time your outreach perfectly' },
+ ];
+
+ return (
+
+ {/* Hero Section */}
+
+ {/* Animated Icon Stack */}
+
+
+ {/* Badge */}
+
+
+ Coming Q2 2026
+
+
+
+ Workflow Automations
+
+
+ Put your CRE outreach on autopilot. Build powerful automations that nurture leads,
+ follow up at the perfect time, and keep your pipeline moving without lifting a finger.
+
+
+ From simple email sequences to complex multi-channel workflows with conditional logic,
+ automations will help you scale your business while maintaining a personal touch.
+
+
+ {/* Email Signup */}
+ {!submitted ? (
+
+ ) : (
+
+
+ You're on the list! We'll notify you when it's ready.
+
+ )}
+
+ Be the first to know when automations launch. No spam, just updates.
+
+
+
+ {/* Features Preview */}
+
+
+ What's Coming
+
+
+ {upcomingFeatures.map((feature, index) => (
+
+
+
+
+
{feature.title}
+
{feature.description}
+
+ ))}
+
+
+ {/* Workflow Preview (Disabled/Grayed) */}
+
+
+
+ Workflow Builder Preview
+ Coming Soon
+
+
+ {/* Trigger */}
+
+
+ Trigger
+
+
+ {/* Condition */}
+
+
+ Condition
+
+
+ {/* Action */}
+
+
+ Action
+
+
+
+
+
+ );
+}
diff --git a/app/(app)/contacts/page.tsx b/app/(app)/contacts/page.tsx
new file mode 100644
index 0000000..9e43107
--- /dev/null
+++ b/app/(app)/contacts/page.tsx
@@ -0,0 +1,1665 @@
+'use client';
+
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import { ClayCard } from '@/components/ClayCard';
+import { api } from '@/lib/api/client';
+import {
+ Search,
+ Plus,
+ MoreVertical,
+ Mail,
+ Phone,
+ Tag,
+ Trash2,
+ Edit,
+ User,
+ X,
+ Eye,
+ Calendar,
+ Loader2,
+ AlertCircle,
+ Users,
+ ChevronUp,
+ ChevronDown,
+ Filter,
+ MessageSquare,
+ Building,
+ Clock,
+ Activity
+} from 'lucide-react';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+interface Contact {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ name?: string;
+ email?: string;
+ phone?: string;
+ tags?: string[];
+ createdAt: string;
+ companyName?: string;
+}
+
+interface ContactFormData {
+ firstName: string;
+ lastName: string;
+ email: string;
+ phone: string;
+ tags: string[];
+ companyName: string;
+}
+
+type SortField = 'name' | 'email' | 'phone' | 'createdAt';
+type SortDirection = 'asc' | 'desc';
+
+interface SortConfig {
+ field: SortField;
+ direction: SortDirection;
+}
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const TAG_COLORS: Record = {
+ 'Investor': { bg: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200' },
+ 'Hot Lead': { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
+ 'Buyer': { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' },
+ 'Developer': { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200' },
+ 'VIP': { bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200' },
+ 'Tenant Rep': { bg: 'bg-cyan-50', text: 'text-cyan-700', border: 'border-cyan-200' },
+ 'Institutional': { bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-200' },
+ 'Seller': { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
+ 'Landlord': { bg: 'bg-teal-50', text: 'text-teal-700', border: 'border-teal-200' },
+ 'default': { bg: 'bg-slate-50', text: 'text-slate-700', border: 'border-slate-200' },
+};
+
+const FILTER_OPTIONS = [
+ { value: 'all', label: 'All Contacts' },
+ { value: 'recent', label: 'Added This Week' },
+ { value: 'investor', label: 'Investors' },
+ { value: 'buyer', label: 'Buyers' },
+ { value: 'hot-lead', label: 'Hot Leads' },
+ { value: 'vip', label: 'VIP Contacts' },
+];
+
+const FIELD_LIMITS = {
+ firstName: 50,
+ lastName: 50,
+ email: 100,
+ phone: 20,
+ companyName: 100,
+};
+
+// =============================================================================
+// Mock Data (replace with real API calls)
+// =============================================================================
+
+const mockContacts: Contact[] = [
+ {
+ id: '1',
+ firstName: 'John',
+ lastName: 'Smith',
+ email: 'john.smith@example.com',
+ phone: '(555) 123-4567',
+ tags: ['Investor', 'Hot Lead'],
+ createdAt: '2024-01-15T10:30:00Z',
+ companyName: 'Smith Holdings LLC',
+ },
+ {
+ id: '2',
+ firstName: 'Sarah',
+ lastName: 'Johnson',
+ email: 'sarah.j@realestate.com',
+ phone: '(555) 987-6543',
+ tags: ['Buyer'],
+ createdAt: '2024-01-10T14:20:00Z',
+ companyName: 'Johnson Properties',
+ },
+ {
+ id: '3',
+ firstName: 'Michael',
+ lastName: 'Chen',
+ email: 'mchen@capitalgroup.com',
+ phone: '(555) 456-7890',
+ tags: ['Developer', 'VIP'],
+ createdAt: '2024-01-08T09:15:00Z',
+ companyName: 'Capital Development Group',
+ },
+ {
+ id: '4',
+ firstName: 'Emily',
+ lastName: 'Rodriguez',
+ email: 'emily.r@commercialre.com',
+ phone: '(555) 321-0987',
+ tags: ['Tenant Rep'],
+ createdAt: '2024-01-05T16:45:00Z',
+ companyName: 'Commercial RE Solutions',
+ },
+ {
+ id: '5',
+ firstName: 'David',
+ lastName: 'Thompson',
+ email: 'dthompson@ventures.io',
+ phone: '(555) 654-3210',
+ tags: ['Investor', 'Institutional'],
+ createdAt: '2024-01-03T11:00:00Z',
+ companyName: 'Thompson Ventures',
+ },
+];
+
+// =============================================================================
+// Utility Functions
+// =============================================================================
+
+const formatDate = (dateString: string): string => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+};
+
+const getContactDisplayName = (contact: Contact): string => {
+ if (contact.name) return contact.name;
+ if (contact.firstName || contact.lastName) {
+ return `${contact.firstName || ''} ${contact.lastName || ''}`.trim();
+ }
+ return 'Unknown';
+};
+
+const getTagColor = (tag: string) => {
+ return TAG_COLORS[tag] || TAG_COLORS['default'];
+};
+
+const isWithinLastWeek = (dateString: string): boolean => {
+ const date = new Date(dateString);
+ const now = new Date();
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ return date >= weekAgo;
+};
+
+// =============================================================================
+// Tag Chip Component
+// =============================================================================
+
+interface TagChipProps {
+ tag: string;
+ onRemove?: () => void;
+ size?: 'sm' | 'md';
+}
+
+const TagChip: React.FC = ({ tag, onRemove, size = 'sm' }) => {
+ const colors = getTagColor(tag);
+ const sizeClasses = size === 'sm' ? 'text-xs px-3.5 py-1.5' : 'text-sm px-3.5 py-1.5';
+
+ return (
+
+ {tag}
+ {onRemove && (
+
+
+
+ )}
+
+ );
+};
+
+// =============================================================================
+// Loading Skeleton Component
+// =============================================================================
+
+const TableSkeleton: React.FC = () => (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+);
+
+// =============================================================================
+// Empty State Component
+// =============================================================================
+
+interface EmptyStateProps {
+ onAddContact: () => void;
+ hasSearchQuery?: boolean;
+ searchQuery?: string;
+ onClearSearch?: () => void;
+}
+
+const EmptyState: React.FC = ({
+ onAddContact,
+ hasSearchQuery = false,
+ searchQuery = '',
+ onClearSearch
+}) => (
+
+
+ {hasSearchQuery ? (
+
+ ) : (
+
+ )}
+
+ {hasSearchQuery ? (
+ <>
+
No contacts found
+
+ No contacts match your search for "{searchQuery}". Try adjusting your search terms or filters.
+
+
+
+ Clear Search
+
+ >
+ ) : (
+ <>
+
No contacts yet
+
+ Get started by adding your first contact or import contacts from a CSV file.
+
+
+
+ Add Your First Contact
+
+ >
+ )}
+
+);
+
+// =============================================================================
+// Error State Component
+// =============================================================================
+
+const ErrorState: React.FC<{ error: string; onRetry: () => void }> = ({ error, onRetry }) => (
+
+
+
Failed to load contacts
+
{error}
+
+ Try Again
+
+
+);
+
+// =============================================================================
+// Tag Input Component
+// =============================================================================
+
+interface TagInputProps {
+ tags: string[];
+ onChange: (tags: string[]) => void;
+ placeholder?: string;
+}
+
+const TagInput: React.FC = ({ tags, onChange, placeholder = 'Add tags...' }) => {
+ const [inputValue, setInputValue] = useState('');
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if ((e.key === 'Enter' || e.key === ',') && inputValue.trim()) {
+ e.preventDefault();
+ const newTag = inputValue.trim().replace(/,/g, '');
+ if (newTag && !tags.includes(newTag)) {
+ onChange([...tags, newTag]);
+ }
+ setInputValue('');
+ } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
+ onChange(tags.slice(0, -1));
+ }
+ };
+
+ const removeTag = (tagToRemove: string) => {
+ onChange(tags.filter(tag => tag !== tagToRemove));
+ };
+
+ return (
+
+ {tags.map((tag, index) => (
+ removeTag(tag)} size="md" />
+ ))}
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={tags.length === 0 ? placeholder : ''}
+ className="flex-1 min-w-[120px] bg-transparent outline-none text-slate-900 placeholder:text-slate-400"
+ />
+
+ );
+};
+
+// =============================================================================
+// Contact Modal Component (Add/Edit)
+// =============================================================================
+
+interface ContactModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ contact?: Contact | null;
+ onSave: (data: ContactFormData) => void;
+ isLoading?: boolean;
+}
+
+const ContactModal: React.FC = ({
+ isOpen,
+ onClose,
+ contact,
+ onSave,
+ isLoading = false,
+}) => {
+ const [formData, setFormData] = useState({
+ firstName: '',
+ lastName: '',
+ email: '',
+ phone: '',
+ tags: [],
+ companyName: '',
+ });
+
+ useEffect(() => {
+ if (contact) {
+ setFormData({
+ firstName: contact.firstName || '',
+ lastName: contact.lastName || '',
+ email: contact.email || '',
+ phone: contact.phone || '',
+ tags: contact.tags || [],
+ companyName: contact.companyName || '',
+ });
+ } else {
+ setFormData({
+ firstName: '',
+ lastName: '',
+ email: '',
+ phone: '',
+ tags: [],
+ companyName: '',
+ });
+ }
+ }, [contact, isOpen]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSave(formData);
+ };
+
+ const handleChange = (field: keyof Omit) => (
+ e: React.ChangeEvent
+ ) => {
+ const limit = FIELD_LIMITS[field as keyof typeof FIELD_LIMITS];
+ const value = limit ? e.target.value.slice(0, limit) : e.target.value;
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {/* Form */}
+
+
+
+ );
+};
+
+// =============================================================================
+// View Contact Modal Component
+// =============================================================================
+
+interface ViewContactModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ contact: Contact | null;
+ onEdit: () => void;
+}
+
+const ViewContactModal: React.FC = ({
+ isOpen,
+ onClose,
+ contact,
+ onEdit,
+}) => {
+ if (!isOpen || !contact) return null;
+
+ const handleCall = () => {
+ if (contact.phone) {
+ window.location.href = `tel:${contact.phone}`;
+ }
+ };
+
+ const handleEmail = () => {
+ if (contact.email) {
+ window.location.href = `mailto:${contact.email}`;
+ }
+ };
+
+ const handleMessage = () => {
+ if (contact.phone) {
+ window.location.href = `sms:${contact.phone}`;
+ }
+ };
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {/* Avatar and Name */}
+
+
+
+
+
+
+ {getContactDisplayName(contact)}
+
+ {contact.companyName && (
+
{contact.companyName}
+ )}
+
+
+
+ {/* Quick Action Buttons */}
+
+
+
+ Call
+
+
+
+ Email
+
+
+
+ Message
+
+
+
+ {/* Contact Info */}
+
+ {contact.email && (
+
+ )}
+
+ {contact.phone && (
+
+ )}
+
+ {contact.companyName && (
+
+
+
+
+
+
Company
+
{contact.companyName}
+
+
+ )}
+
+ {contact.tags && contact.tags.length > 0 && (
+
+
+
+
+
+
Tags
+
+ {contact.tags.map((tag, index) => (
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+
Added
+
{formatDate(contact.createdAt)}
+
+
+
+
+ {/* Activity Section */}
+
+
+
+
+
+
+
+
+
No recent activity
+
Activity will appear here once you start interacting with this contact
+
+
+
+
+
+ {/* Actions */}
+
+
+ Close
+
+
+
+ Edit Contact
+
+
+
+
+
+ );
+};
+
+// =============================================================================
+// Delete Confirmation Modal
+// =============================================================================
+
+interface DeleteModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ contactName: string;
+ isLoading?: boolean;
+}
+
+const DeleteModal: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ contactName,
+ isLoading = false,
+}) => {
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+
+
+
+
Delete Contact
+
+ Are you sure you want to delete {contactName} ?
+ This action cannot be undone.
+
+
+
+ Cancel
+
+
+ {isLoading ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ <>
+
+ Delete
+ >
+ )}
+
+
+
+
+
+ );
+};
+
+// =============================================================================
+// Action Menu Component
+// =============================================================================
+
+interface ActionMenuProps {
+ onView: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ contactName: string;
+}
+
+const ActionMenu: React.FC = ({ onView, onEdit, onDelete, contactName }) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
{
+ e.stopPropagation();
+ setIsOpen(!isOpen);
+ }}
+ className="clay-icon-btn p-2"
+ aria-label={`Actions for ${contactName}`}
+ aria-haspopup="menu"
+ aria-expanded={isOpen}
+ >
+
+
+
+ {isOpen && (
+ <>
+
setIsOpen(false)} aria-hidden="true" />
+
+ {
+ e.stopPropagation();
+ onView();
+ setIsOpen(false);
+ }}
+ className="w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
+ role="menuitem"
+ >
+
+ View
+
+ {
+ e.stopPropagation();
+ onEdit();
+ setIsOpen(false);
+ }}
+ className="w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
+ role="menuitem"
+ >
+
+ Edit
+
+ {
+ e.stopPropagation();
+ onDelete();
+ setIsOpen(false);
+ }}
+ className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
+ role="menuitem"
+ >
+
+ Delete
+
+
+ >
+ )}
+
+ );
+};
+
+// =============================================================================
+// Sortable Column Header Component
+// =============================================================================
+
+interface SortableHeaderProps {
+ field: SortField;
+ label: string;
+ currentSort: SortConfig;
+ onSort: (field: SortField) => void;
+ className?: string;
+}
+
+const SortableHeader: React.FC
= ({
+ field,
+ label,
+ currentSort,
+ onSort,
+ className = '',
+}) => {
+ const isActive = currentSort.field === field;
+ const isAsc = currentSort.direction === 'asc';
+
+ return (
+
+ onSort(field)}
+ className="flex items-center gap-1 text-sm font-semibold text-slate-600 hover:text-slate-900 transition-colors group"
+ aria-label={`Sort by ${label} ${isActive && isAsc ? 'descending' : 'ascending'}`}
+ >
+ {label}
+
+
+
+
+
+
+ );
+};
+
+// =============================================================================
+// Contact Card Component (Mobile)
+// =============================================================================
+
+interface ContactCardProps {
+ contact: Contact;
+ onView: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+const ContactCard: React.FC = ({ contact, onView, onEdit, onDelete }) => (
+
+
+
+
+
+
+
+
{getContactDisplayName(contact)}
+ {contact.companyName && (
+
{contact.companyName}
+ )}
+
+
+
+
+
+
+ {contact.email && (
+
+
+ {contact.email}
+
+ )}
+ {contact.phone && (
+
+ )}
+
+
+ {contact.tags && contact.tags.length > 0 && (
+
+ {contact.tags.map((tag, index) => (
+
+ ))}
+
+ )}
+
+
+
Added {formatDate(contact.createdAt)}
+
+
+);
+
+// =============================================================================
+// Contacts Table Component (Desktop)
+// =============================================================================
+
+interface ContactsTableProps {
+ contacts: Contact[];
+ onView: (contact: Contact) => void;
+ onEdit: (contact: Contact) => void;
+ onDelete: (contact: Contact) => void;
+ sortConfig: SortConfig;
+ onSort: (field: SortField) => void;
+}
+
+const ContactsTable: React.FC = ({
+ contacts,
+ onView,
+ onEdit,
+ onDelete,
+ sortConfig,
+ onSort,
+}) => (
+
+
+
+
+
+
+
+
+ Tags
+
+
+ Actions
+
+
+
+ {contacts.map((contact) => (
+ onView(contact)}
+ className="border-b border-slate-100 hover:bg-indigo-50/50 transition-colors cursor-pointer group"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onView(contact);
+ }
+ }}
+ role="button"
+ aria-label={`View details for ${getContactDisplayName(contact)}`}
+ >
+
+
+
+
+
+
+
+ {getContactDisplayName(contact)}
+
+ {contact.companyName && (
+
{contact.companyName}
+ )}
+
+
+
+
+ {contact.email || - }
+
+
+ {contact.phone || - }
+
+
+
+ {contact.tags?.slice(0, 2).map((tag, index) => (
+
+ ))}
+ {contact.tags && contact.tags.length > 2 && (
+
+ +{contact.tags.length - 2}
+
+ )}
+ {(!contact.tags || contact.tags.length === 0) && (
+ -
+ )}
+
+
+
+ {formatDate(contact.createdAt)}
+
+
+ onView(contact)}
+ onEdit={() => onEdit(contact)}
+ onDelete={() => onDelete(contact)}
+ contactName={getContactDisplayName(contact)}
+ />
+
+
+ ))}
+
+
+
+);
+
+// =============================================================================
+// Filter Dropdown Component
+// =============================================================================
+
+interface FilterDropdownProps {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+const FilterDropdown: React.FC = ({ value, onChange }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const selectedOption = FILTER_OPTIONS.find(opt => opt.value === value) || FILTER_OPTIONS[0];
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="clay-btn px-4 py-3 font-medium flex items-center gap-2 min-w-[160px] justify-between"
+ aria-label="Filter contacts"
+ aria-haspopup="listbox"
+ aria-expanded={isOpen}
+ >
+
+
+ {selectedOption.label}
+
+
+
+
+ {isOpen && (
+ <>
+
setIsOpen(false)} aria-hidden="true" />
+
+ {FILTER_OPTIONS.map((option) => (
+ {
+ onChange(option.value);
+ setIsOpen(false);
+ }}
+ className={`w-full px-4 py-2 text-left text-sm hover:bg-slate-50 flex items-center gap-2 ${
+ value === option.value ? 'text-indigo-600 bg-indigo-50 font-medium' : 'text-slate-700'
+ }`}
+ role="option"
+ aria-selected={value === option.value}
+ >
+ {option.label}
+
+ ))}
+
+ >
+ )}
+
+ );
+};
+
+// =============================================================================
+// Main Page Component
+// =============================================================================
+
+export default function ContactsPage() {
+ // State
+ const [contacts, setContacts] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [filterValue, setFilterValue] = useState('all');
+ const [sortConfig, setSortConfig] = useState({ field: 'name', direction: 'asc' });
+
+ // Modal states
+ const [isAddEditModalOpen, setIsAddEditModalOpen] = useState(false);
+ const [isViewModalOpen, setIsViewModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [selectedContact, setSelectedContact] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // Fetch contacts from GHL
+ const fetchContacts = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await api.contacts.getAll({ limit: 100 });
+ // Map GHL response to our Contact interface
+ const ghlContacts: Contact[] = (response.data || []).map((c: any) => ({
+ id: c.id,
+ firstName: c.firstName,
+ lastName: c.lastName,
+ name: c.name,
+ email: c.email,
+ phone: c.phone,
+ tags: c.tags || [],
+ createdAt: c.dateAdded || c.createdAt || new Date().toISOString(),
+ companyName: c.companyName,
+ }));
+ setContacts(ghlContacts);
+ } catch (err: any) {
+ console.error('Failed to fetch contacts:', err);
+ if (err.message?.includes('GHL not configured')) {
+ setError('GHL is not configured. Please set up your GHL credentials in Admin → Settings.');
+ } else {
+ setError('Unable to load contacts. Please try again.');
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchContacts();
+ }, []);
+
+ // Sort handler
+ const handleSort = useCallback((field: SortField) => {
+ setSortConfig((prev) => ({
+ field,
+ direction: prev.field === field && prev.direction === 'asc' ? 'desc' : 'asc',
+ }));
+ }, []);
+
+ // Filtered and sorted contacts
+ const filteredAndSortedContacts = useMemo(() => {
+ let result = [...contacts];
+
+ // Apply filter
+ if (filterValue !== 'all') {
+ result = result.filter((contact) => {
+ switch (filterValue) {
+ case 'recent':
+ return isWithinLastWeek(contact.createdAt);
+ case 'investor':
+ return contact.tags?.some(tag => tag.toLowerCase() === 'investor');
+ case 'buyer':
+ return contact.tags?.some(tag => tag.toLowerCase() === 'buyer');
+ case 'hot-lead':
+ return contact.tags?.some(tag => tag.toLowerCase() === 'hot lead');
+ case 'vip':
+ return contact.tags?.some(tag => tag.toLowerCase() === 'vip');
+ default:
+ return true;
+ }
+ });
+ }
+
+ // Apply search
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ result = result.filter((contact) => {
+ const name = getContactDisplayName(contact).toLowerCase();
+ const email = contact.email?.toLowerCase() || '';
+ const phone = contact.phone?.toLowerCase() || '';
+ const company = contact.companyName?.toLowerCase() || '';
+ const tags = contact.tags?.join(' ').toLowerCase() || '';
+
+ return (
+ name.includes(query) ||
+ email.includes(query) ||
+ phone.includes(query) ||
+ company.includes(query) ||
+ tags.includes(query)
+ );
+ });
+ }
+
+ // Apply sort
+ result.sort((a, b) => {
+ let aValue: string;
+ let bValue: string;
+
+ switch (sortConfig.field) {
+ case 'name':
+ aValue = getContactDisplayName(a).toLowerCase();
+ bValue = getContactDisplayName(b).toLowerCase();
+ break;
+ case 'email':
+ aValue = a.email?.toLowerCase() || '';
+ bValue = b.email?.toLowerCase() || '';
+ break;
+ case 'phone':
+ aValue = a.phone || '';
+ bValue = b.phone || '';
+ break;
+ case 'createdAt':
+ aValue = a.createdAt;
+ bValue = b.createdAt;
+ break;
+ default:
+ return 0;
+ }
+
+ if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
+ if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
+ return 0;
+ });
+
+ return result;
+ }, [contacts, searchQuery, filterValue, sortConfig]);
+
+ // Handlers
+ const handleAddContact = () => {
+ setSelectedContact(null);
+ setIsAddEditModalOpen(true);
+ };
+
+ const handleViewContact = (contact: Contact) => {
+ setSelectedContact(contact);
+ setIsViewModalOpen(true);
+ };
+
+ const handleEditContact = (contact: Contact) => {
+ setSelectedContact(contact);
+ setIsAddEditModalOpen(true);
+ setIsViewModalOpen(false);
+ };
+
+ const handleDeleteContact = (contact: Contact) => {
+ setSelectedContact(contact);
+ setIsDeleteModalOpen(true);
+ };
+
+ const handleClearSearch = () => {
+ setSearchQuery('');
+ setFilterValue('all');
+ };
+
+ const handleSaveContact = async (formData: ContactFormData) => {
+ setIsSaving(true);
+
+ try {
+ if (selectedContact) {
+ // Update existing contact in GHL
+ await api.contacts.update(selectedContact.id, {
+ firstName: formData.firstName,
+ lastName: formData.lastName,
+ email: formData.email || undefined,
+ phone: formData.phone || undefined,
+ tags: formData.tags,
+ });
+ // Refresh contacts list
+ await fetchContacts();
+ } else {
+ // Create new contact in GHL
+ await api.contacts.create({
+ firstName: formData.firstName,
+ lastName: formData.lastName,
+ email: formData.email || undefined,
+ phone: formData.phone || undefined,
+ tags: formData.tags,
+ });
+ // Refresh contacts list
+ await fetchContacts();
+ }
+
+ setIsAddEditModalOpen(false);
+ setSelectedContact(null);
+ } catch (err) {
+ console.error('Failed to save contact:', err);
+ setError('Failed to save contact. Please try again.');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleConfirmDelete = async () => {
+ if (!selectedContact) return;
+
+ setIsDeleting(true);
+
+ try {
+ // Delete contact in GHL
+ await api.contacts.delete(selectedContact.id);
+ // Refresh contacts list
+ await fetchContacts();
+ setIsDeleteModalOpen(false);
+ setSelectedContact(null);
+ } catch (err) {
+ console.error('Failed to delete contact:', err);
+ setError('Failed to delete contact. Please try again.');
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+
+ {/* Header - Level 1 (subtle) since it's a page header */}
+
+
+
+
Contacts
+
Manage your leads and clients
+
+
+
+ Add Contact
+
+
+
+
+ {/* Search and Filter Bar */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className={`clay-input clay-input-icon w-full h-14 ${searchQuery ? 'pr-12' : 'pr-4'} text-base border-2 border-slate-200 focus:border-indigo-400 transition-colors`}
+ aria-label="Search contacts"
+ />
+ {searchQuery && (
+ setSearchQuery('')}
+ 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"
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+
+
+
+ {/* Active filter indicator */}
+ {(filterValue !== 'all' || searchQuery) && (
+
+ Active filters:
+ {filterValue !== 'all' && (
+
+ {FILTER_OPTIONS.find(opt => opt.value === filterValue)?.label}
+ setFilterValue('all')}
+ className="ml-1 hover:text-indigo-900"
+ aria-label="Remove filter"
+ >
+
+
+
+ )}
+ {searchQuery && (
+
+ Search: "{searchQuery}"
+ setSearchQuery('')}
+ className="ml-1 hover:text-slate-900"
+ aria-label="Clear search"
+ >
+
+
+
+ )}
+
+ Clear all
+
+
+ )}
+
+ {/* Contacts List */}
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ ) : filteredAndSortedContacts.length === 0 ? (
+
opt.value === filterValue)?.label || ''}
+ onClearSearch={handleClearSearch}
+ />
+ ) : (
+ <>
+ {/* Desktop Table */}
+
+
+
+
+ {/* Mobile Cards */}
+
+ {filteredAndSortedContacts.map((contact) => (
+ handleViewContact(contact)}
+ onEdit={() => handleEditContact(contact)}
+ onDelete={() => handleDeleteContact(contact)}
+ />
+ ))}
+
+ >
+ )}
+
+
+ {/* Results count */}
+ {!isLoading && !error && filteredAndSortedContacts.length > 0 && (
+
+ Showing {filteredAndSortedContacts.length} of {contacts.length} contacts
+
+ )}
+
+ {/* Modals */}
+
{
+ setIsAddEditModalOpen(false);
+ setSelectedContact(null);
+ }}
+ contact={selectedContact}
+ onSave={handleSaveContact}
+ isLoading={isSaving}
+ />
+
+ {
+ setIsViewModalOpen(false);
+ setSelectedContact(null);
+ }}
+ contact={selectedContact}
+ onEdit={() => handleEditContact(selectedContact!)}
+ />
+
+ {
+ setIsDeleteModalOpen(false);
+ setSelectedContact(null);
+ }}
+ onConfirm={handleConfirmDelete}
+ contactName={selectedContact ? getContactDisplayName(selectedContact) : ''}
+ isLoading={isDeleting}
+ />
+
+ );
+}
diff --git a/app/(app)/control-center/page.tsx b/app/(app)/control-center/page.tsx
new file mode 100644
index 0000000..ef8856a
--- /dev/null
+++ b/app/(app)/control-center/page.tsx
@@ -0,0 +1,297 @@
+'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({
+ connected: false,
+ toolCount: 0,
+ });
+ const [initialLoading, setInitialLoading] = useState(true);
+ const [toolsError, setToolsError] = useState(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 (
+
+
+
+
+
+
Loading Control Center...
+
+
+ );
+ }
+
+ return (
+
+ {/* Mobile Header with Sidebar Toggle */}
+
+ 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"
+ >
+
+ History
+
+
+
+
+
+ {/* Desktop Layout */}
+
+ {/* Left Sidebar */}
+
+ {/* Status Indicator */}
+
+
+
+
+ {/* Conversation Sidebar */}
+
+
+
+
+
+ {/* Main Chat Area */}
+
+
+
+
+
+ {/* Mobile Layout */}
+
+
+
+
+ {/* Mobile Sidebar Overlay */}
+ {sidebarOpen && (
+
+ {/* Backdrop */}
+
setSidebarOpen(false)}
+ aria-hidden="true"
+ />
+
+ {/* Sidebar Panel */}
+
+ {/* Close button */}
+
+
Conversation History
+ 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"
+ >
+
+
+
+
+ {/* Sidebar content */}
+
+
+
+
+
+ )}
+
+ {/* MCP Warning Banner (shown at bottom on desktop if MCP not connected) */}
+ {!mcpStatus.connected && mcpStatus.error && !initialLoading && (
+
+
+
+
+
+ Limited Functionality
+
+
+ {mcpStatus.error || 'MCP server not connected. Some tools may be unavailable.'}
+
+
+
+
+ )}
+
+ );
+}
+
+// =============================================================================
+// Main Page Component
+// =============================================================================
+
+export default function ControlCenterPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/(app)/conversations/page.tsx b/app/(app)/conversations/page.tsx
new file mode 100644
index 0000000..c497817
--- /dev/null
+++ b/app/(app)/conversations/page.tsx
@@ -0,0 +1,965 @@
+'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 (
+
+
+ {getInitials(name)}
+
+ {showOnlineIndicator && (
+
+ )}
+
+ );
+}
+
+// Conversation List Item
+const ConversationItem = React.memo(function ConversationItem({
+ conversation,
+ isSelected,
+ onClick
+}: {
+ conversation: Conversation;
+ isSelected: boolean;
+ onClick: () => void;
+}) {
+ const isUnread = conversation.unreadCount > 0;
+
+ return (
+
+
+ {/* Avatar with channel indicator */}
+
+
+
+ {conversation.channel === 'sms' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Content */}
+
+
+
+
+ {conversation.contact.name}
+
+ {conversation.isStarred && (
+
+ )}
+
+
+ {formatMessageTime(conversation.lastMessageTime)}
+
+
+
+
+ {conversation.lastMessage}
+
+ {isUnread && (
+
+ {conversation.unreadCount}
+
+ )}
+
+
+
+
+ );
+});
+
+// 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 (
+
+ {/* Header */}
+
+
+
+ {/* Search */}
+
+
+ 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 && (
+ 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"
+ >
+
+
+ )}
+
+
+ {/* Filter Tabs */}
+
+ {filters.map((filter) => (
+ 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 && (
+
+ {filter.count}
+
+ )}
+
+ ))}
+
+
+
+ {/* Conversation List */}
+
+ {isLoading ? (
+
+
+
+
Loading conversations...
+
+
+ ) : conversations.length === 0 ? (
+
+
+ {searchQuery ? (
+
+ ) : activeFilter === 'unread' ? (
+
+ ) : activeFilter === 'starred' ? (
+
+ ) : (
+
+ )}
+
+
+ {searchQuery
+ ? 'No results found'
+ : activeFilter === 'unread'
+ ? 'All caught up!'
+ : activeFilter === 'starred'
+ ? 'No starred conversations'
+ : 'No conversations yet'
+ }
+
+
+ {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'
+ }
+
+ {!searchQuery && activeFilter === 'all' && (
+
+
+ New Message
+
+ )}
+
+ ) : (
+ conversations.map((conversation) => (
+
onSelect(conversation.id)}
+ />
+ ))
+ )}
+
+
+ );
+}
+
+// Message Bubble
+function MessageBubble({ message }: { message: Message }) {
+ const isOutbound = message.direction === 'outbound';
+
+ const StatusIcon = () => {
+ switch (message.status) {
+ case 'pending':
+ return
;
+ case 'sent':
+ return
;
+ case 'delivered':
+ return
;
+ case 'read':
+ return
;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
{message.content}
+
+
+ {formatFullTime(message.timestamp)}
+
+ {isOutbound && }
+
+
+
+ );
+}
+
+// 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
(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 (
+
+ {/* Channel Toggle */}
+
+
Send via:
+
+ 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'
+ }
+ `}
+ >
+
+ SMS
+
+ 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'
+ }
+ `}
+ >
+
+ Email
+
+
+
+
+ {/* Composer */}
+
+
+
+ {isSending ? (
+
+ ) : (
+
+ )}
+
+
+
+ Press Enter to send, Shift + Enter for new line
+
+
+ );
+}
+
+// Empty State Illustration
+function EmptyStateIllustration() {
+ return (
+
+ {/* Background circles */}
+
+
+
+ {/* Message bubbles illustration */}
+
+
+ {/* Main message icon */}
+
+
+
+
+ {/* Floating elements */}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// 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(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 (
+
+
+
+
+
Your Messages
+
+ Select a conversation from the list to view your messages, or start a new conversation to connect with clients and leads.
+
+
+
+
+ New Message
+
+
+
+ View Contacts
+
+
+
+ {/* Quick tips */}
+
+
Quick tips
+
+
+ Press Enter to send
+
+
+ Star important chats
+
+
+ Search to filter
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+ {showBackButton && (
+
+
+
+ )}
+
+ {/* Contact Avatar */}
+
+
+ {/* Contact Info */}
+
+
+
{conversation.contact.name}
+ {conversation.isStarred && (
+
+ )}
+
+
+ {conversation.channel === 'sms' ? conversation.contact.phone : conversation.contact.email}
+
+
+
+ {/* Actions */}
+
+
+
+ {/* Messages */}
+
+ {/* Date separator example */}
+
+
+ {conversation.messages.map((msg) => (
+
+ ))}
+
+
+
+ {/* Composer */}
+
+
+ );
+}
+
+// Main Page Component
+export default function ConversationsPage() {
+ const [conversations, setConversations] = useState([]);
+ const [selectedConversationId, setSelectedConversationId] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [activeFilter, setActiveFilter] = useState('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 (
+
+ {/* Desktop Layout */}
+
+ {/* Left Panel - Conversation List */}
+
+
+ {/* Right Panel - Message Thread */}
+
+
+
+ {/* Mobile Layout */}
+
+ {/* List View */}
+
+
+
+
+ {/* Thread View (Full Screen) */}
+
+
+
+
+
+ );
+}
diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
new file mode 100644
index 0000000..4d4b0a1
--- /dev/null
+++ b/app/(app)/dashboard/page.tsx
@@ -0,0 +1,618 @@
+'use client';
+
+import { useAuth } from '@/lib/hooks/useAuth';
+import { useRealtimeContext } from '@/components/realtime/RealtimeProvider';
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import {
+ Users, MessageSquare, TrendingUp, DollarSign, Target, UserPlus,
+ Mail, CheckCircle2, Circle, Zap, Building2, ArrowUpRight, Clock,
+ Rocket, Activity, Sparkles, Settings, BarChart3, Wifi, WifiOff,
+ AlertCircle, PhoneCall, Megaphone, UserCheck
+} from 'lucide-react';
+
+interface DashboardStats {
+ totalContacts: number;
+ activeConversations: number;
+ openOpportunities: number;
+ pipelineValue: number;
+}
+
+export default function DashboardPage() {
+ const { user } = useAuth();
+ const { isConnected } = useRealtimeContext();
+ const [stats, setStats] = useState({
+ totalContacts: 0,
+ activeConversations: 0,
+ openOpportunities: 0,
+ pipelineValue: 0,
+ });
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function fetchStats() {
+ try {
+ const response = await fetch('/api/v1/dashboard/stats');
+ if (response.ok) {
+ const data = await response.json();
+ setStats(data);
+ }
+ } catch (error) {
+ console.error('Failed to fetch stats:', error);
+ } finally {
+ setLoading(false);
+ }
+ }
+ fetchStats();
+ }, []);
+
+ const quickActions = [
+ { name: 'Add Contact', href: '/contacts', icon: UserPlus, description: 'Import or create new contacts' },
+ { name: 'Send Message', href: '/conversations', icon: Mail, description: 'Start a conversation' },
+ { name: 'View Pipeline', href: '/opportunities', icon: Target, description: 'Track your deals' },
+ { name: 'Get Leads', href: '/leads', icon: TrendingUp, description: 'Find new opportunities' },
+ ];
+
+ return (
+
+ {/* Header Section - Level 1 (subtle) since it's a page header, not main content */}
+
+
+
+
+ Welcome back{user?.firstName && , {user.firstName} }
+
+
Here's what's happening with your portfolio today
+
+
+ {isConnected ? (
+ <>
+
+
+ Connected
+ >
+ ) : (
+ <>
+
+
+ Connecting...
+ >
+ )}
+
+
+
+
+ {/* Stats Grid */}
+
+ {/* Section header - Level 1 (subtle) */}
+
+
+
+
+
+
+
Overview
+
Your key metrics at a glance
+
+
+
+
+ 0 ? "+12%" : undefined}
+ trendUp
+ emptyMessage="No contacts yet"
+ emptyAction={{ label: "Import contacts", href: "/contacts" }}
+ />
+ 0 ? "+5%" : undefined}
+ trendUp
+ emptyMessage="No active chats"
+ emptyAction={{ label: "Start a conversation", href: "/conversations" }}
+ />
+ 0 ? "+8%" : undefined}
+ trendUp
+ emptyMessage="No opportunities"
+ emptyAction={{ label: "Create opportunity", href: "/opportunities" }}
+ />
+ 0 ? "+23%" : undefined}
+ trendUp
+ highlight
+ isCurrency
+ emptyMessage="No pipeline value"
+ emptyAction={{ label: "Add a deal", href: "/opportunities" }}
+ />
+
+
+
+ {/* Quick Actions */}
+
+ {/* Section header - Level 1 (subtle) */}
+
+
+
+
+
+
+
Quick Actions
+
Jump right into common tasks
+
+
+
+
+ {quickActions.map((action) => {
+ const Icon = action.icon;
+ return (
+
+
+
+
+ {action.name}
+
+
{action.description}
+
+
+ );
+ })}
+
+
+
+ {/* Bottom Section */}
+
+
+
+
+
+ );
+}
+
+function StatCard({
+ title,
+ value,
+ icon: Icon,
+ loading,
+ trend,
+ trendUp,
+ highlight,
+ isCurrency,
+ emptyMessage,
+ emptyAction
+}: {
+ title: string;
+ value: string | number;
+ icon: React.ComponentType<{ className?: string }>;
+ loading: boolean;
+ trend?: string;
+ trendUp?: boolean;
+ highlight?: boolean;
+ isCurrency?: boolean;
+ emptyMessage?: string;
+ emptyAction?: { label: string; href: string };
+}) {
+ const numericValue = typeof value === 'number' ? value : parseFloat(value) || 0;
+ const isEmpty = numericValue === 0;
+ const displayValue = isCurrency ? `$${numericValue.toLocaleString()}` : value;
+
+ return (
+
+
+
+
+
+ {trend ? (
+
+ {trend}
+
+ ) : !loading && isEmpty ? (
+
+ --
+
+ ) : null}
+
+
+ {loading ? (
+
+ ) : isEmpty ? (
+
+
+ {isCurrency ? '$0' : '0'}
+
+ {emptyMessage && (
+
{emptyMessage}
+ )}
+ {emptyAction && (
+
+ {emptyAction.label}
+
+
+ )}
+
+ ) : (
+
+ {displayValue}
+
+ )}
+
{title}
+
+
+ );
+}
+
+function SetupProgress() {
+ const [progress, setProgress] = useState({
+ smsConfigured: false,
+ emailConfigured: false,
+ contactsImported: false,
+ campaignsSetup: false,
+ });
+
+ useEffect(() => {
+ fetch('/api/v1/onboarding/status')
+ .then(res => res.json())
+ .then(data => {
+ if (data.setupStatus) {
+ setProgress(data.setupStatus);
+ }
+ })
+ .catch(() => {});
+ }, []);
+
+ const steps = [
+ { key: 'smsConfigured', label: 'Configure SMS', description: 'Set up two-way texting', done: progress.smsConfigured, href: '/settings/sms', icon: MessageSquare },
+ { key: 'emailConfigured', label: 'Set up Email', description: 'Connect your email account', done: progress.emailConfigured, href: '/settings/email', icon: Mail },
+ { key: 'contactsImported', label: 'Import Contacts', description: 'Bring in your database', done: progress.contactsImported, href: '/contacts', icon: Users },
+ { key: 'campaignsSetup', label: 'Create Campaigns', description: 'Automate your follow-ups', done: progress.campaignsSetup, href: '/campaigns', icon: Zap },
+ ];
+
+ const completedCount = steps.filter(s => s.done).length;
+ const progressPercent = (completedCount / steps.length) * 100;
+ const isComplete = progressPercent === 100;
+
+ return (
+
+ {/* Header */}
+
+
+
+ {isComplete ? (
+
+ ) : (
+
+ )}
+
+
+
Setup Progress
+
+ {isComplete ? 'All set! You\'re ready to go' : 'Complete these steps to unlock all features'}
+
+
+
+
+ {completedCount}
+ /
+ {steps.length}
+ complete
+
+
+
+ {/* Progress Bar */}
+
+
+
+ {isComplete ? 'Setup Complete!' : `${completedCount} of ${steps.length} steps completed`}
+
+
+ {Math.round(progressPercent)}%
+
+
+
+
+
+ {/* Steps List */}
+
+ {steps.map((step, index) => {
+ const StepIcon = step.icon;
+ return (
+
+ {/* Step Number/Check */}
+
+ {step.done ? (
+
+ ) : (
+ {index + 1}
+ )}
+
+
+ {/* Step Icon */}
+
+
+
+
+ {/* Step Content */}
+
+
+ {step.label}
+
+
{step.description}
+
+
+ {/* Action Button */}
+ {step.done ? (
+
+ Done
+
+ ) : (
+
+ Start
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+interface Recommendation {
+ id: string;
+ type: 'follow_up' | 'stale_lead' | 'pipeline_stuck' | 'campaign_response' | 'no_response';
+ priority: 'high' | 'medium' | 'low';
+ title: string;
+ description: string;
+ actionLabel: string;
+ actionUrl: string;
+ contactId?: string;
+ contactName?: string;
+ daysOverdue?: number;
+ pipelineStage?: string;
+}
+
+function RecommendedTasks() {
+ const [recommendations, setRecommendations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ async function fetchRecommendations() {
+ try {
+ const response = await fetch('/api/v1/dashboard/recommendations');
+ if (response.ok) {
+ const data = await response.json();
+ setRecommendations(data.recommendations || []);
+ } else {
+ setError('Failed to load recommendations');
+ }
+ } catch (err) {
+ console.error('Failed to fetch recommendations:', err);
+ setError('Failed to load recommendations');
+ } finally {
+ setLoading(false);
+ }
+ }
+ fetchRecommendations();
+ }, []);
+
+ const getIconForType = (type: Recommendation['type']) => {
+ switch (type) {
+ case 'follow_up':
+ return UserCheck;
+ case 'no_response':
+ return PhoneCall;
+ case 'pipeline_stuck':
+ return Target;
+ case 'campaign_response':
+ return Megaphone;
+ case 'stale_lead':
+ return AlertCircle;
+ default:
+ return Zap;
+ }
+ };
+
+ const getColorForPriority = (priority: Recommendation['priority']) => {
+ switch (priority) {
+ case 'high':
+ return { bg: 'bg-red-100', icon: 'text-red-600', border: 'border-red-200 hover:border-red-300' };
+ case 'medium':
+ return { bg: 'bg-amber-100', icon: 'text-amber-600', border: 'border-amber-200 hover:border-amber-300' };
+ case 'low':
+ return { bg: 'bg-blue-100', icon: 'text-blue-600', border: 'border-blue-200 hover:border-blue-300' };
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
Recommended Tasks
+
AI-powered suggestions based on your CRM
+
+
+
+ {loading ? (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ ) : error ? (
+
+
+
+ Unable to load recommendations
+
+
+ Make sure your MCP server is running
+
+
{
+ setLoading(true);
+ setError(null);
+ fetch('/api/v1/dashboard/recommendations')
+ .then(r => r.json())
+ .then(data => setRecommendations(data.recommendations || []))
+ .catch(() => setError('Failed to load recommendations'))
+ .finally(() => setLoading(false));
+ }}
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-primary rounded-md hover:bg-primary/90 transition-colors mx-auto"
+ >
+ Try Again
+
+
+ ) : recommendations.length > 0 ? (
+
+ {recommendations.map((rec) => {
+ const Icon = getIconForType(rec.type);
+ const colors = getColorForPriority(rec.priority);
+ return (
+
+
+
+
+
+
{rec.title}
+
{rec.description}
+
+
+ {rec.actionLabel}
+
+
+
+ );
+ })}
+
+ ) : (
+ /* Empty State - All caught up */
+
+
+
+
+
+ You're all caught up!
+
+
+ No urgent tasks right now. Keep up the great work with your leads and deals.
+
+
+
+
+ Ask AI for Insights
+
+
+
+ Add More Contacts
+
+
+
+ )}
+
+ {/* Footer link to Control Center for more insights */}
+ {recommendations.length > 0 && (
+
+
+ Get more insights with AI
+
+
+
+ )}
+
+ );
+}
diff --git a/app/(app)/dfy/page.tsx b/app/(app)/dfy/page.tsx
new file mode 100644
index 0000000..c9e1eae
--- /dev/null
+++ b/app/(app)/dfy/page.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import { useState } from 'react';
+import { DFY_PRODUCTS } from '@/lib/stripe/dfy-products';
+import { Check, Loader2, Clock, Sparkles } from 'lucide-react';
+
+export default function DFYServicesPage() {
+ const [loading, setLoading] = useState(null);
+
+ const handleCheckout = async (productId: string) => {
+ setLoading(productId);
+ try {
+ const response = await fetch('/api/v1/stripe/checkout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ productId }),
+ });
+
+ const data = await response.json();
+ if (data.checkoutUrl) {
+ window.location.href = data.checkoutUrl;
+ }
+ } catch (error) {
+ console.error('Checkout error:', error);
+ } finally {
+ setLoading(null);
+ }
+ };
+
+ const formatPrice = (cents: number) => {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ }).format(cents / 100);
+ };
+
+ return (
+
+
+
+
+
Done-For-You Services
+
+
+ Let our experts handle the setup while you focus on closing deals
+
+
+
+
+ {Object.entries(DFY_PRODUCTS).map(([key, product]) => (
+
+
+
{product.name}
+
{product.description}
+
+
+
+ {formatPrice(product.priceInCents)}
+
+ one-time
+
+
+
+ {product.features.map((feature, i) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ Delivery: {product.deliveryDays} business days
+
+
+
+
handleCheckout(key)}
+ disabled={loading === key}
+ className="btn-primary w-full mt-6 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {loading === key ? (
+ <>
+
+ Processing...
+ >
+ ) : (
+ 'Get Started'
+ )}
+
+
+ ))}
+
+
+
+
+
+
Need a custom solution?
+
+ Contact us for enterprise packages and custom integrations
+
+
+
+ Contact Sales
+
+
+
+
+ );
+}
diff --git a/app/(app)/dfy/success/page.tsx b/app/(app)/dfy/success/page.tsx
new file mode 100644
index 0000000..98ee1f4
--- /dev/null
+++ b/app/(app)/dfy/success/page.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+import Link from 'next/link';
+import { CheckCircle, ArrowRight } from 'lucide-react';
+
+export default function DFYSuccessPage() {
+ const searchParams = useSearchParams();
+ const sessionId = searchParams.get('session_id');
+
+ return (
+
+
+
+
+
+
Payment Successful!
+
+ Thank you for your purchase. Our team will begin working on your setup immediately.
+ You'll receive an email with next steps within 24 hours.
+
+
+
+ What happens next?
+
+ Our team will review your account and reach out to schedule a kickoff call to understand your specific needs.
+
+
+
+ Return to Dashboard
+
+
+
+
+ );
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
new file mode 100644
index 0000000..30554a5
--- /dev/null
+++ b/app/(app)/layout.tsx
@@ -0,0 +1,685 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname, useRouter } from 'next/navigation';
+import {
+ LayoutDashboard,
+ Users,
+ MessageSquare,
+ Target,
+ Zap,
+ UserPlus,
+ Store,
+ Wrench,
+ BarChart3,
+ Settings,
+ Shield,
+ ChevronLeft,
+ ChevronRight,
+ LogOut,
+ Building2,
+ Bell,
+ Search,
+ Menu,
+ X,
+ Loader2,
+ Sparkles,
+ ListChecks,
+} from 'lucide-react';
+import { useState, useEffect, useRef } from 'react';
+import { AuthProvider, useAuth } from '@/lib/hooks/useAuth';
+import { RealtimeProvider, useRealtimeContext } from '@/components/realtime/RealtimeProvider';
+import { cn } from '@/lib/utils';
+
+const setupNavItems = [
+ { href: '/setup', label: 'Setup', icon: ListChecks },
+];
+
+const navItems = [
+ { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
+ { href: '/control-center', label: 'Control Center', icon: Sparkles },
+ { href: '/contacts', label: 'Contacts', icon: Users },
+ { href: '/conversations', label: 'Conversations', icon: MessageSquare },
+ { href: '/opportunities', label: 'Opportunities', icon: Target },
+ { href: '/automations', label: 'Automations', icon: Zap },
+ { href: '/leads', label: 'Get Leads', icon: UserPlus },
+ { href: '/marketplace', label: 'Marketplace', icon: Store },
+ { href: '/tools', label: 'Tools', icon: Wrench },
+ { href: '/reporting', label: 'Reporting', icon: BarChart3 },
+];
+
+const bottomNavItems = [
+ { href: '/settings', label: 'Settings', icon: Settings },
+ { href: '/admin', label: 'Admin', icon: Shield, adminOnly: true },
+];
+
+// Tooltip component for collapsed sidebar
+function Tooltip({ children, content, show }: { children: React.ReactNode; content: string; show: boolean }) {
+ if (!show) return <>{children}>;
+
+ return (
+
+ );
+}
+
+// Search Modal Component
+function SearchModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (isOpen && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [isOpen]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
+ e.preventDefault();
+ if (!isOpen) return;
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ const searchCategories = [
+ { label: 'Contacts', icon: Users, shortcut: 'C' },
+ { label: 'Conversations', icon: MessageSquare, shortcut: 'V' },
+ { label: 'Opportunities', icon: Target, shortcut: 'O' },
+ { label: 'Settings', icon: Settings, shortcut: 'S' },
+ ];
+
+ return (
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ aria-label="Search input"
+ />
+
+ Esc
+
+
+
+
+ Quick Search
+
+
+ {searchCategories.map(({ label, icon: Icon, shortcut }) => (
+ {
+ onClose();
+ }}
+ >
+
+ {label}
+ {shortcut}
+
+ ))}
+
+
+
+
+ Tab to navigate
+ Enter to select
+
+
+
+
+ );
+}
+
+// Helper function to get user initials
+function getUserInitials(user: { firstName?: string; lastName?: string; email?: string } | null): string {
+ if (!user) return 'U';
+
+ const first = user.firstName?.[0]?.toUpperCase() || '';
+ const last = user.lastName?.[0]?.toUpperCase() || '';
+
+ if (first && last) return `${first}${last}`;
+ if (first) return first;
+ if (user.email) return user.email[0].toUpperCase();
+ return 'U';
+}
+
+// Helper function to get user role display
+function getUserRoleDisplay(user: { role?: string; brokerage?: string } | null): string {
+ if (!user) return 'User';
+
+ if (user.brokerage) return user.brokerage;
+
+ switch (user.role?.toLowerCase()) {
+ case 'admin':
+ return 'Administrator';
+ case 'agent':
+ return 'Real Estate Agent';
+ case 'broker':
+ return 'Broker';
+ case 'manager':
+ return 'Team Manager';
+ default:
+ return user.role || 'User';
+ }
+}
+
+function NavLink({ item, collapsed, isActive }: { item: typeof navItems[0]; collapsed: boolean; isActive: boolean }) {
+ const Icon = item.icon;
+
+ const linkContent = (
+
+
+ {!collapsed && (
+
+ {item.label}
+
+ )}
+
+ );
+
+ return (
+
+ {linkContent}
+
+ );
+}
+
+function SidebarContent({ collapsed, setCollapsed, onMobileClose }: {
+ collapsed: boolean;
+ setCollapsed: (v: boolean) => void;
+ onMobileClose?: () => void;
+}) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const { user, logout } = useAuth();
+ const { isConnected } = useRealtimeContext();
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
+
+ const handleLogout = async () => {
+ setIsLoggingOut(true);
+ try {
+ await logout();
+ router.push('/login');
+ } finally {
+ setIsLoggingOut(false);
+ }
+ };
+
+ // Check if user is admin
+ const isAdmin = user?.role?.toLowerCase() === 'admin';
+
+ // Filter bottom nav items based on user role
+ const filteredBottomNavItems = bottomNavItems.filter(
+ (item) => !item.adminOnly || isAdmin
+ );
+
+ return (
+
+ {/* Logo */}
+
+
+
+
+
+
+ CRESync
+ Real Estate CRM
+
+
+
+ {onMobileClose && (
+
+
+
+ )}
+ setCollapsed(true)}
+ className="hidden lg:flex clay-icon-btn w-8 h-8 hover:bg-white/60 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ aria-label="Collapse sidebar"
+ >
+
+
+
+
+
+ {/* Connection Status */}
+
+
+ {isConnected ? (
+ <>
+
+ Connected
+ >
+ ) : (
+ <>
+
+ Connecting...
+ >
+ )}
+
+
+
+ {/* Setup Section */}
+
+
+ Setup
+
+
+ {setupNavItems.map((item) => (
+
+ ))}
+
+
+
+ {/* Main Navigation */}
+
+
+ Main Menu
+
+
+ {navItems.map((item) => (
+
+ ))}
+
+
+
+ {/* System Section */}
+
+
+ System
+
+
+ {filteredBottomNavItems.map((item) => (
+
+ ))}
+
+
+
+ {/* User Section */}
+
+ {/* User Menu */}
+
+
+
+ {getUserInitials(user)}
+
+
+
+
+ {user?.firstName} {user?.lastName}
+
+
{user?.email}
+
+
+
+ {isLoggingOut ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Logout button when collapsed */}
+ {collapsed && (
+
+
+
+ {isLoggingOut ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+
+ {/* Expand Button (when collapsed) */}
+ {collapsed && (
+
+
+ setCollapsed(false)}
+ className="w-full clay-icon-btn h-9 hover:bg-white/60 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ aria-label="Expand sidebar"
+ >
+
+
+
+
+ )}
+
+ );
+}
+
+function AppLayoutContent({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const [collapsed, setCollapsed] = useState(false);
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const [searchOpen, setSearchOpen] = useState(false);
+ const { user, loading } = useAuth();
+
+ // Placeholder notification count
+ const notificationCount = 3;
+
+ // Handle Cmd/Ctrl+K for search
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
+ e.preventDefault();
+ setSearchOpen((prev) => !prev);
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ const currentPage = [...setupNavItems, ...navItems, ...bottomNavItems].find((item) => pathname === item.href)?.label || 'CRESync';
+
+ return (
+
+ {/* Search Modal */}
+
setSearchOpen(false)} />
+
+ {/* Desktop Sidebar */}
+
+
+ {/* Mobile Sidebar Overlay */}
+ {mobileOpen && (
+
+
setMobileOpen(false)}
+ aria-hidden="true"
+ />
+
+ {}}
+ onMobileClose={() => setMobileOpen(false)}
+ />
+
+
+ )}
+
+ {/* Main Content Area */}
+
+ {/* Mobile Header */}
+
+
+
setMobileOpen(true)}
+ className={cn(
+ 'clay-icon-btn w-10 h-10',
+ 'hover:bg-white/60 transition-colors',
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary'
+ )}
+ aria-label="Open navigation menu"
+ aria-expanded={mobileOpen}
+ aria-controls="mobile-sidebar"
+ >
+
+
+
+
+
+ setSearchOpen(true)}
+ className={cn(
+ 'clay-icon-btn w-9 h-9',
+ 'hover:bg-white/60 hover:scale-105 transition-all',
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary'
+ )}
+ aria-label="Search"
+ >
+
+
+ 0 ? `, ${notificationCount} unread` : ''}`}
+ >
+
+ {notificationCount > 0 && (
+
+ {notificationCount > 99 ? '99+' : notificationCount}
+
+ )}
+
+
+
+
+ {/* Main Content */}
+
+ {/* Top Bar (Desktop) */}
+
+
+
+
+ setSearchOpen(true)}
+ className={cn(
+ 'clay-icon-btn w-10 h-10 transition-all duration-200',
+ 'hover:scale-105 hover:bg-white/60 hover:shadow-[3px_3px_6px_#c5c8cf,-3px_-3px_6px_#ffffff]',
+ 'active:scale-95 active:shadow-[inset_2px_2px_4px_#c5c8cf,inset_-2px_-2px_4px_#ffffff]',
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2'
+ )}
+ aria-label="Search (Cmd+K)"
+ >
+
+
+ 0 ? `, ${notificationCount} unread` : ''}`}
+ >
+
+ {notificationCount > 0 && (
+
+ {notificationCount > 99 ? '99+' : notificationCount}
+
+ )}
+
+
+
+
+
+ {getUserInitials(user)}
+
+
+
{user?.firstName} {user?.lastName}
+
{getUserRoleDisplay(user)}
+
+
+
+
+
+ {/* Page Content */}
+ {children}
+
+
+
+ {/* Skip to main content link for accessibility */}
+
+ Skip to main content
+
+
+ );
+}
+
+export default function AppLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/(app)/leads/page.tsx b/app/(app)/leads/page.tsx
new file mode 100644
index 0000000..38c2ebc
--- /dev/null
+++ b/app/(app)/leads/page.tsx
@@ -0,0 +1,193 @@
+'use client';
+
+import React, { useState } from 'react';
+import {
+ Building2,
+ Bell,
+ Search,
+ MapPin,
+ Users,
+ Database,
+ Sparkles,
+ Filter,
+ Download,
+ CheckCircle2,
+ Clock,
+ TrendingUp,
+ FileText,
+ Globe
+} from 'lucide-react';
+
+export default function LeadsPage() {
+ const [email, setEmail] = useState('');
+ const [submitted, setSubmitted] = useState(false);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (email) {
+ setSubmitted(true);
+ setEmail('');
+ }
+ };
+
+ const upcomingFeatures = [
+ { icon: Search, title: 'Property Search', description: 'Find properties by type, size, location, sale history, and ownership structure' },
+ { icon: Users, title: 'Owner Discovery', description: 'Uncover property owners with contact information and entity details' },
+ { icon: Database, title: 'Data Enrichment', description: 'Enhance your existing contacts with verified emails, phones, and social profiles' },
+ { icon: Filter, title: 'Smart Filters', description: 'Build custom filters to find exactly the properties and owners you need' },
+ { icon: MapPin, title: 'Market Mapping', description: 'Visualize opportunities on an interactive map with property overlays' },
+ { icon: Download, title: 'List Export', description: 'Export curated lead lists directly to your pipeline or CSV' },
+ ];
+
+ const dataPoints = [
+ 'Property ownership records',
+ 'Transaction history',
+ 'Building permits',
+ 'Lien & debt information',
+ 'Zoning data',
+ 'Tenant information',
+ ];
+
+ return (
+
+ {/* Hero Section */}
+
+ {/* Animated Icon Stack */}
+
+
+ {/* Badge */}
+
+
+ Coming Q1 2026
+
+
+
+ Lead Generation & Prospecting
+
+
+ Discover high-potential properties and owners in your target markets.
+ Powered by comprehensive property data, find your next deal before the competition.
+
+
+ Search millions of commercial properties, identify motivated sellers,
+ and enrich your contacts with verified owner information and contact details.
+
+
+ {/* Email Signup */}
+ {!submitted ? (
+
+ ) : (
+
+
+ You're on the list! We'll notify you when it's ready.
+
+ )}
+
+ Get early access when leads launch. No spam, just updates.
+
+
+
+ {/* Features Preview */}
+
+
+ What's Coming
+
+
+ {upcomingFeatures.map((feature, index) => (
+
+
+
+
+
{feature.title}
+
{feature.description}
+
+ ))}
+
+
+ {/* Search Interface Preview (Disabled/Grayed) */}
+
+
+
+ Lead Search Preview
+ Coming Soon
+
+
+ {/* Search Bar */}
+
+
+
+ Search properties, owners, or addresses...
+
+
+
+ Filters
+
+
+
+ {/* Results Preview */}
+
+ {[1, 2].map((i) => (
+
+ ))}
+
+
+
+ {/* Data Points */}
+
+
+
+
Data You'll Access
+
+
+ {dataPoints.map((point, index) => (
+
+
+ {point}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/(app)/marketplace/page.tsx b/app/(app)/marketplace/page.tsx
new file mode 100644
index 0000000..7236d21
--- /dev/null
+++ b/app/(app)/marketplace/page.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import React from 'react';
+import { Marketplace } from '@/components/Marketplace';
+
+export default function MarketplacePage() {
+ const handleQuizClick = () => {
+ // TODO: Implement quiz navigation or modal
+ console.log('Quiz clicked');
+ };
+
+ return (
+
+ );
+}
diff --git a/app/(app)/opportunities/page.tsx b/app/(app)/opportunities/page.tsx
new file mode 100644
index 0000000..cb568a3
--- /dev/null
+++ b/app/(app)/opportunities/page.tsx
@@ -0,0 +1,1414 @@
+'use client';
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ Plus,
+ DollarSign,
+ User,
+ MoreVertical,
+ Check,
+ X,
+ Trophy,
+ ChevronDown,
+ Edit2,
+ Trash2,
+ ArrowRight,
+ GripVertical,
+ Calendar,
+ TrendingUp,
+ Clock,
+ Filter,
+ AlertCircle,
+ Target,
+ XCircle
+} from 'lucide-react';
+
+// Types
+interface Pipeline {
+ id: string;
+ name: string;
+ stages: Stage[];
+}
+
+interface Stage {
+ id: string;
+ name: string;
+ order: number;
+ color: string;
+}
+
+interface Contact {
+ id: string;
+ name: string;
+ email: string;
+ company?: string;
+}
+
+interface Opportunity {
+ id: string;
+ name: string;
+ value: number;
+ stageId: string;
+ pipelineId: string;
+ contact: Contact;
+ status: 'open' | 'won' | 'lost';
+ createdAt: string;
+ updatedAt: string;
+ closingDate?: string;
+ probability?: number;
+ priority?: 'low' | 'medium' | 'high';
+ lastActivityDate?: string;
+}
+
+// Mock data
+const mockPipelines: Pipeline[] = [
+ {
+ id: 'pipeline-1',
+ name: 'Sales Pipeline',
+ stages: [
+ { id: 'stage-1', name: 'Lead', order: 1, color: '#6366f1' },
+ { id: 'stage-2', name: 'Qualified', order: 2, color: '#8b5cf6' },
+ { id: 'stage-3', name: 'Proposal', order: 3, color: '#f59e0b' },
+ { id: 'stage-4', name: 'Negotiation', order: 4, color: '#f97316' },
+ { id: 'stage-5', name: 'Closed Won', order: 5, color: '#22c55e' },
+ { id: 'stage-6', name: 'Closed Lost', order: 6, color: '#ef4444' },
+ ],
+ },
+ {
+ id: 'pipeline-2',
+ name: 'Property Acquisition',
+ stages: [
+ { id: 'stage-7', name: 'Initial Contact', order: 1, color: '#6366f1' },
+ { id: 'stage-8', name: 'Due Diligence', order: 2, color: '#8b5cf6' },
+ { id: 'stage-9', name: 'Offer Made', order: 3, color: '#f59e0b' },
+ { id: 'stage-10', name: 'Under Contract', order: 4, color: '#f97316' },
+ { id: 'stage-11', name: 'Closed', order: 5, color: '#22c55e' },
+ { id: 'stage-12', name: 'Closed Lost', order: 6, color: '#ef4444' },
+ ],
+ },
+];
+
+const mockOpportunities: Opportunity[] = [
+ {
+ id: 'opp-1',
+ name: 'Downtown Office Building',
+ value: 2500000,
+ stageId: 'stage-2',
+ pipelineId: 'pipeline-1',
+ contact: { id: 'c1', name: 'John Smith', email: 'john@example.com', company: 'Smith Properties' },
+ status: 'open',
+ createdAt: '2024-01-15',
+ updatedAt: '2024-01-20',
+ closingDate: '2024-03-15',
+ probability: 60,
+ priority: 'high',
+ lastActivityDate: '2024-01-20',
+ },
+ {
+ id: 'opp-2',
+ name: 'Retail Space Lease',
+ value: 180000,
+ stageId: 'stage-1',
+ pipelineId: 'pipeline-1',
+ contact: { id: 'c2', name: 'Sarah Johnson', email: 'sarah@retail.com', company: 'Retail Corp' },
+ status: 'open',
+ createdAt: '2024-01-18',
+ updatedAt: '2024-01-18',
+ closingDate: '2024-04-01',
+ probability: 25,
+ priority: 'low',
+ lastActivityDate: '2024-01-18',
+ },
+ {
+ id: 'opp-3',
+ name: 'Industrial Warehouse',
+ value: 4200000,
+ stageId: 'stage-3',
+ pipelineId: 'pipeline-1',
+ contact: { id: 'c3', name: 'Mike Wilson', email: 'mike@logistics.com', company: 'Logistics Inc' },
+ status: 'open',
+ createdAt: '2024-01-10',
+ updatedAt: '2024-01-22',
+ closingDate: '2024-02-28',
+ probability: 75,
+ priority: 'high',
+ lastActivityDate: '2024-01-22',
+ },
+ {
+ id: 'opp-4',
+ name: 'Suburban Office Park',
+ value: 1800000,
+ stageId: 'stage-4',
+ pipelineId: 'pipeline-1',
+ contact: { id: 'c4', name: 'Emily Brown', email: 'emily@tech.com', company: 'Tech Startup' },
+ status: 'open',
+ createdAt: '2024-01-05',
+ updatedAt: '2024-01-21',
+ closingDate: '2024-02-15',
+ probability: 85,
+ priority: 'medium',
+ lastActivityDate: '2024-01-21',
+ },
+ {
+ id: 'opp-5',
+ name: 'Mixed-Use Development',
+ value: 8500000,
+ stageId: 'stage-1',
+ pipelineId: 'pipeline-1',
+ contact: { id: 'c5', name: 'David Lee', email: 'david@dev.com', company: 'Development Group' },
+ status: 'open',
+ createdAt: '2024-01-22',
+ updatedAt: '2024-01-22',
+ closingDate: '2024-06-30',
+ probability: 20,
+ priority: 'medium',
+ lastActivityDate: '2024-01-22',
+ },
+ {
+ id: 'opp-6',
+ name: 'Failed Strip Mall Deal',
+ value: 950000,
+ stageId: 'stage-6',
+ pipelineId: 'pipeline-1',
+ contact: { id: 'c2', name: 'Sarah Johnson', email: 'sarah@retail.com', company: 'Retail Corp' },
+ status: 'lost',
+ createdAt: '2023-12-01',
+ updatedAt: '2024-01-10',
+ closingDate: '2024-01-10',
+ probability: 0,
+ priority: 'low',
+ lastActivityDate: '2024-01-10',
+ },
+];
+
+const mockContacts: Contact[] = [
+ { id: 'c1', name: 'John Smith', email: 'john@example.com', company: 'Smith Properties' },
+ { id: 'c2', name: 'Sarah Johnson', email: 'sarah@retail.com', company: 'Retail Corp' },
+ { id: 'c3', name: 'Mike Wilson', email: 'mike@logistics.com', company: 'Logistics Inc' },
+ { id: 'c4', name: 'Emily Brown', email: 'emily@tech.com', company: 'Tech Startup' },
+ { id: 'c5', name: 'David Lee', email: 'david@dev.com', company: 'Development Group' },
+];
+
+// Helper function to format currency
+const formatCurrency = (value: number): string => {
+ if (value >= 1000000) {
+ return `$${(value / 1000000).toFixed(1)}M`;
+ }
+ if (value >= 1000) {
+ return `$${(value / 1000).toFixed(0)}K`;
+ }
+ return `$${value}`;
+};
+
+// Helper function to format date
+const formatDate = (dateString?: string): string => {
+ if (!dateString) return 'N/A';
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+};
+
+// Helper to calculate days until closing
+const getDaysUntilClosing = (closingDate?: string): number | null => {
+ if (!closingDate) return null;
+ const today = new Date();
+ const closing = new Date(closingDate);
+ const diffTime = closing.getTime() - today.getTime();
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+ return diffDays;
+};
+
+// Helper to get urgency color
+const getUrgencyColor = (daysUntilClosing: number | null, priority?: string): string => {
+ if (priority === 'high') return 'border-l-red-500';
+ if (daysUntilClosing !== null && daysUntilClosing <= 7) return 'border-l-red-500';
+ if (daysUntilClosing !== null && daysUntilClosing <= 14) return 'border-l-amber-500';
+ if (priority === 'medium') return 'border-l-amber-500';
+ return 'border-l-blue-500';
+};
+
+// Priority badge component
+const PriorityBadge: React.FC<{ priority?: string }> = ({ priority }) => {
+ if (!priority) return null;
+
+ const colors = {
+ high: 'bg-red-100 text-red-700 border-red-200',
+ medium: 'bg-amber-100 text-amber-700 border-amber-200',
+ low: 'bg-blue-100 text-blue-700 border-blue-200',
+ };
+
+ return (
+
+ {priority}
+
+ );
+};
+
+// Progress bar for deal stage
+const StageProgress: React.FC<{ currentOrder: number; totalStages: number; color: string }> = ({
+ currentOrder,
+ totalStages,
+ color
+}) => {
+ // Exclude closed lost from progress calculation (it's not a progression)
+ const progressStages = totalStages - 1;
+ const progress = Math.min((currentOrder / progressStages) * 100, 100);
+
+ return (
+
+ );
+};
+
+// Pipeline Selector Component
+const PipelineSelector: React.FC<{
+ pipelines: Pipeline[];
+ selectedPipeline: Pipeline;
+ onSelect: (pipeline: Pipeline) => void;
+}> = ({ pipelines, selectedPipeline, onSelect }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ if (pipelines.length <= 1) return null;
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="clay-btn flex items-center gap-2 px-4 py-2.5 text-foreground font-medium"
+ >
+ {selectedPipeline.name}
+
+
+ {isOpen && (
+
+ {pipelines.map((pipeline) => (
+ {
+ onSelect(pipeline);
+ setIsOpen(false);
+ }}
+ className={`w-full px-4 py-2.5 rounded-lg text-left transition-colors ${
+ pipeline.id === selectedPipeline.id
+ ? 'bg-primary/10 text-primary font-semibold'
+ : 'text-foreground hover:bg-muted'
+ }`}
+ >
+ {pipeline.name}
+
+ ))}
+
+ )}
+
+ );
+};
+
+// Quick Filter Component
+type FilterType = 'all' | 'high-value' | 'closing-soon' | 'high-priority';
+
+const QuickFilters: React.FC<{
+ activeFilter: FilterType;
+ onFilterChange: (filter: FilterType) => void;
+}> = ({ activeFilter, onFilterChange }) => {
+ const filters: { key: FilterType; label: string; icon: React.ReactNode }[] = [
+ { key: 'all', label: 'All Deals', icon: },
+ { key: 'high-value', label: 'High Value', icon: },
+ { key: 'closing-soon', label: 'Closing Soon', icon: },
+ { key: 'high-priority', label: 'High Priority', icon: },
+ ];
+
+ return (
+
+
+
+ {filters.map((filter) => (
+ onFilterChange(filter.key)}
+ className={`flex items-center gap-2 px-5 py-3 rounded-md text-xs font-medium transition-all ${
+ activeFilter === filter.key
+ ? 'bg-background shadow-sm text-foreground'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+ {filter.icon}
+ {filter.label}
+
+ ))}
+
+
+ );
+};
+
+// Stage Move Menu Component
+const StageMoveMenu: React.FC<{
+ stages: Stage[];
+ currentStageId: string;
+ onMove: (stageId: string) => void;
+ onClose: () => void;
+}> = ({ stages, currentStageId, onMove, onClose }) => {
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [onClose]);
+
+ return (
+
+
+ Move to Stage
+
+ {stages.map((stage) => (
+
{
+ onMove(stage.id);
+ onClose();
+ }}
+ disabled={stage.id === currentStageId}
+ className={`w-full px-3 py-2 rounded-lg text-left flex items-center gap-2 transition-colors ${
+ stage.id === currentStageId
+ ? 'bg-muted/50 text-muted-foreground cursor-not-allowed'
+ : 'hover:bg-muted text-foreground'
+ }`}
+ >
+
+ {stage.name}
+ {stage.id === currentStageId && (
+
+ )}
+
+ ))}
+
+ );
+};
+
+// Opportunity Card Quick Actions Menu
+const QuickActionsMenu: React.FC<{
+ opportunity: Opportunity;
+ stages: Stage[];
+ onMarkWon: () => void;
+ onMarkLost: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onMove: (stageId: string) => void;
+ onClose: () => void;
+}> = ({ opportunity, stages, onMarkWon, onMarkLost, onEdit, onDelete, onMove, onClose }) => {
+ const menuRef = useRef(null);
+ const [showMoveMenu, setShowMoveMenu] = useState(false);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [onClose]);
+
+ return (
+
+ {opportunity.status === 'open' && (
+ <>
+
+
+ Mark as Won
+
+
+
+ Mark as Lost
+
+
+
setShowMoveMenu(!showMoveMenu)}
+ className="w-full px-3 py-2 rounded-lg text-left flex items-center gap-2 hover:bg-muted text-foreground transition-colors"
+ >
+
+ Move to Stage
+
+
+ {showMoveMenu && (
+
setShowMoveMenu(false)}
+ />
+ )}
+
+
+ >
+ )}
+
+
+ Edit
+
+
+
+ Delete
+
+
+ );
+};
+
+// Opportunity Card Component
+const OpportunityCard: React.FC<{
+ opportunity: Opportunity;
+ stages: Stage[];
+ onUpdate: (id: string, updates: Partial) => void;
+ onDelete: (id: string) => void;
+ onEdit: (opportunity: Opportunity) => void;
+ isDragging?: boolean;
+}> = ({ opportunity, stages, onUpdate, onDelete, onEdit, isDragging = false }) => {
+ const [showActions, setShowActions] = useState(false);
+
+ const daysUntilClosing = getDaysUntilClosing(opportunity.closingDate);
+ const urgencyColor = getUrgencyColor(daysUntilClosing, opportunity.priority);
+ const currentStage = stages.find(s => s.id === opportunity.stageId);
+ const stageOrder = currentStage?.order || 1;
+
+ const handleMarkWon = () => {
+ const wonStage = stages.find(s => s.name.toLowerCase().includes('won'));
+ onUpdate(opportunity.id, { status: 'won', stageId: wonStage?.id || opportunity.stageId });
+ setShowActions(false);
+ };
+
+ const handleMarkLost = () => {
+ const lostStage = stages.find(s => s.name.toLowerCase().includes('lost'));
+ onUpdate(opportunity.id, { status: 'lost', stageId: lostStage?.id || opportunity.stageId });
+ setShowActions(false);
+ };
+
+ const handleMove = (stageId: string) => {
+ onUpdate(opportunity.id, { stageId });
+ setShowActions(false);
+ };
+
+ return (
+
+ {/* Drag Handle */}
+
+
+
+
+
+
+
+
+
+ {opportunity.name}
+
+
+
+
+ {opportunity.contact.name}
+
+
+ {opportunity.contact.company && (
+
+ {opportunity.contact.company}
+
+ )}
+
+
+
+ {
+ e.stopPropagation();
+ setShowActions(!showActions);
+ }}
+ className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
+ aria-label="More options"
+ >
+
+
+ {showActions && (
+ {
+ onEdit(opportunity);
+ setShowActions(false);
+ }}
+ onDelete={() => {
+ onDelete(opportunity.id);
+ setShowActions(false);
+ }}
+ onMove={handleMove}
+ onClose={() => setShowActions(false)}
+ />
+ )}
+
+
+
+
+
+ {/* Value and Priority Row */}
+
+
+
+
+ {formatCurrency(opportunity.value)}
+
+
+
+
+
+ {/* Metadata Row */}
+
+
+
+
+ {opportunity.closingDate
+ ? `Close: ${formatDate(opportunity.closingDate)}`
+ : 'No close date'
+ }
+
+
+
+
+
+ {opportunity.lastActivityDate
+ ? `Last: ${formatDate(opportunity.lastActivityDate)}`
+ : 'No activity'
+ }
+
+
+
+
+ {/* Probability and Progress */}
+ {opportunity.status === 'open' && (
+
+
+
+
+ Probability
+
+
+ {opportunity.probability || 0}%
+
+
+
+
+ )}
+
+ {/* Status indicator for won/lost */}
+ {opportunity.status !== 'open' && (
+
+ {opportunity.status === 'won' ? (
+ <>
+
+ Deal Won
+ >
+ ) : (
+ <>
+
+ Deal Lost
+ >
+ )}
+
+ )}
+
+ );
+};
+
+// Empty State Component
+const EmptyColumnState: React.FC<{
+ stageName: string;
+ stageColor: string;
+ isClosedStage?: boolean;
+}> = ({ stageName, stageColor, isClosedStage }) => {
+ return (
+
+
+ {isClosedStage ? (
+ stageName.toLowerCase().includes('won') ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ No deals in {stageName}
+
+
+ {isClosedStage
+ ? stageName.toLowerCase().includes('won')
+ ? 'Close deals to see them here'
+ : 'Lost deals will appear here'
+ : 'Drag deals here or add new ones'
+ }
+
+
+ );
+};
+
+// Stage Column Component
+const StageColumn: React.FC<{
+ stage: Stage;
+ opportunities: Opportunity[];
+ allStages: Stage[];
+ onUpdateOpportunity: (id: string, updates: Partial) => void;
+ onDeleteOpportunity: (id: string) => void;
+ onEditOpportunity: (opportunity: Opportunity) => void;
+ onDragStart: (opportunityId: string) => void;
+ onDragEnd: () => void;
+ onDrop: (stageId: string) => void;
+ draggingId: string | null;
+}> = ({
+ stage,
+ opportunities,
+ allStages,
+ onUpdateOpportunity,
+ onDeleteOpportunity,
+ onEditOpportunity,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ draggingId
+}) => {
+ const [isDragOver, setIsDragOver] = useState(false);
+ const stageOpportunities = opportunities.filter(opp => opp.stageId === stage.id);
+ const totalValue = stageOpportunities.reduce((sum, opp) => sum + opp.value, 0);
+ const isClosedStage = stage.name.toLowerCase().includes('closed') || stage.name.toLowerCase().includes('lost') || stage.name.toLowerCase().includes('won');
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(true);
+ };
+
+ const handleDragLeave = () => {
+ setIsDragOver(false);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ onDrop(stage.id);
+ };
+
+ return (
+
+ {/* Stage Header */}
+
+
+
+
{stage.name}
+
+ {stageOpportunities.length}
+
+
+
+
+ {formatCurrency(totalValue)}
+
+
+
+ {/* Cards Container */}
+
+
+ {stageOpportunities.length === 0 ? (
+
+ ) : (
+ stageOpportunities.map((opportunity) => (
+
onDragStart(opportunity.id)}
+ onDragEnd={onDragEnd}
+ >
+
+
+ ))
+ )}
+
+
+
+ );
+};
+
+// Add/Edit Opportunity Modal
+const OpportunityModal: React.FC<{
+ isOpen: boolean;
+ onClose: () => void;
+ onSave: (opportunity: Partial) => void;
+ opportunity?: Opportunity | null;
+ stages: Stage[];
+ contacts: Contact[];
+ pipelineId: string;
+}> = ({ isOpen, onClose, onSave, opportunity, stages, contacts, pipelineId }) => {
+ const [formData, setFormData] = useState({
+ name: '',
+ value: '',
+ stageId: stages[0]?.id || '',
+ contactId: '',
+ status: 'open' as 'open' | 'won' | 'lost',
+ closingDate: '',
+ probability: '50',
+ priority: 'medium' as 'low' | 'medium' | 'high',
+ });
+ const [errors, setErrors] = useState>({});
+
+ useEffect(() => {
+ if (opportunity) {
+ setFormData({
+ name: opportunity.name,
+ value: opportunity.value.toString(),
+ stageId: opportunity.stageId,
+ contactId: opportunity.contact.id,
+ status: opportunity.status,
+ closingDate: opportunity.closingDate || '',
+ probability: (opportunity.probability || 50).toString(),
+ priority: opportunity.priority || 'medium',
+ });
+ } else {
+ setFormData({
+ name: '',
+ value: '',
+ stageId: stages[0]?.id || '',
+ contactId: '',
+ status: 'open',
+ closingDate: '',
+ probability: '50',
+ priority: 'medium',
+ });
+ }
+ setErrors({});
+ }, [opportunity, stages, isOpen]);
+
+ const validate = () => {
+ const newErrors: Record = {};
+ if (!formData.name.trim()) newErrors.name = 'Name is required';
+ if (!formData.value || parseFloat(formData.value) <= 0) newErrors.value = 'Valid value is required';
+ if (!formData.contactId) newErrors.contactId = 'Contact is required';
+ if (!formData.stageId) newErrors.stageId = 'Stage is required';
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!validate()) return;
+
+ const selectedContact = contacts.find(c => c.id === formData.contactId);
+ if (!selectedContact) return;
+
+ onSave({
+ id: opportunity?.id,
+ name: formData.name,
+ value: parseFloat(formData.value),
+ stageId: formData.stageId,
+ pipelineId,
+ contact: selectedContact,
+ status: formData.status,
+ closingDate: formData.closingDate || undefined,
+ probability: parseInt(formData.probability),
+ priority: formData.priority,
+ lastActivityDate: new Date().toISOString().split('T')[0],
+ });
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+ {opportunity ? 'Edit Opportunity' : 'Add Opportunity'}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Delete Confirmation Modal
+const DeleteConfirmModal: React.FC<{
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ opportunityName: string;
+}> = ({ isOpen, onClose, onConfirm, opportunityName }) => {
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+
Delete Opportunity
+
+ Are you sure you want to delete "{opportunityName}"? This action cannot be undone.
+
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+ );
+};
+
+// Main Page Component
+export default function OpportunitiesPage() {
+ const [pipelines] = useState(mockPipelines);
+ const [selectedPipeline, setSelectedPipeline] = useState(mockPipelines[0]);
+ const [opportunities, setOpportunities] = useState(mockOpportunities);
+ const [contacts] = useState(mockContacts);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingOpportunity, setEditingOpportunity] = useState(null);
+ const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string; name: string }>({
+ isOpen: false,
+ id: '',
+ name: '',
+ });
+ const [activeFilter, setActiveFilter] = useState('all');
+ const [draggingId, setDraggingId] = useState(null);
+
+ // Filter opportunities based on active filter
+ const filterOpportunities = (opps: Opportunity[]): Opportunity[] => {
+ const pipelineOpps = opps.filter((opp) => opp.pipelineId === selectedPipeline.id);
+
+ switch (activeFilter) {
+ case 'high-value':
+ return pipelineOpps.filter(opp => opp.value >= 1000000);
+ case 'closing-soon':
+ return pipelineOpps.filter(opp => {
+ const days = getDaysUntilClosing(opp.closingDate);
+ return days !== null && days <= 30 && days >= 0;
+ });
+ case 'high-priority':
+ return pipelineOpps.filter(opp => opp.priority === 'high');
+ default:
+ return pipelineOpps;
+ }
+ };
+
+ const pipelineOpportunities = filterOpportunities(opportunities);
+ const allPipelineOpportunities = opportunities.filter(
+ (opp) => opp.pipelineId === selectedPipeline.id
+ );
+
+ const totalPipelineValue = allPipelineOpportunities
+ .filter((opp) => opp.status === 'open')
+ .reduce((sum, opp) => sum + opp.value, 0);
+
+ const openDealsCount = allPipelineOpportunities.filter(o => o.status === 'open').length;
+ const wonDealsCount = allPipelineOpportunities.filter(o => o.status === 'won').length;
+ const lostDealsCount = allPipelineOpportunities.filter(o => o.status === 'lost').length;
+
+ const handleUpdateOpportunity = (id: string, updates: Partial) => {
+ setOpportunities((prev) =>
+ prev.map((opp) =>
+ opp.id === id ? { ...opp, ...updates, updatedAt: new Date().toISOString() } : opp
+ )
+ );
+ };
+
+ const handleDeleteOpportunity = (id: string) => {
+ const opportunity = opportunities.find((opp) => opp.id === id);
+ if (opportunity) {
+ setDeleteConfirm({ isOpen: true, id, name: opportunity.name });
+ }
+ };
+
+ const confirmDelete = () => {
+ setOpportunities((prev) => prev.filter((opp) => opp.id !== deleteConfirm.id));
+ setDeleteConfirm({ isOpen: false, id: '', name: '' });
+ };
+
+ const handleSaveOpportunity = (data: Partial) => {
+ if (data.id) {
+ // Update existing
+ handleUpdateOpportunity(data.id, data);
+ } else {
+ // Create new
+ const newOpportunity: Opportunity = {
+ id: `opp-${Date.now()}`,
+ name: data.name || '',
+ value: data.value || 0,
+ stageId: data.stageId || selectedPipeline.stages[0].id,
+ pipelineId: selectedPipeline.id,
+ contact: data.contact!,
+ status: 'open',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ closingDate: data.closingDate,
+ probability: data.probability,
+ priority: data.priority,
+ lastActivityDate: new Date().toISOString().split('T')[0],
+ };
+ setOpportunities((prev) => [...prev, newOpportunity]);
+ }
+ setEditingOpportunity(null);
+ };
+
+ const handleEditOpportunity = (opportunity: Opportunity) => {
+ setEditingOpportunity(opportunity);
+ setIsModalOpen(true);
+ };
+
+ const handleAddNew = () => {
+ setEditingOpportunity(null);
+ setIsModalOpen(true);
+ };
+
+ const handleDragStart = (opportunityId: string) => {
+ setDraggingId(opportunityId);
+ };
+
+ const handleDragEnd = () => {
+ setDraggingId(null);
+ };
+
+ const handleDrop = (stageId: string) => {
+ if (draggingId) {
+ handleUpdateOpportunity(draggingId, { stageId });
+ setDraggingId(null);
+ }
+ };
+
+ return (
+
+ {/* Header - Level 1 (subtle) since it's a page header */}
+
+
+
+
+
+
Pipeline
+
+ Manage and track your deals through the sales process
+
+
+
+
+
+
+
+ Add Opportunity
+
+
+
+
+
+ {/* Summary Stats */}
+
+
+
+
+
+
+
+
Total Pipeline
+
{formatCurrency(totalPipelineValue)}
+
+
+
+
+
+
+
+
+
+
Open Deals
+
{openDealsCount}
+
+
+
+
+
+
+
+
+
+
Won
+
{wonDealsCount}
+
+
+
+
+
+
+
+
+
+
Lost
+
{lostDealsCount}
+
+
+
+
+
+ {/* Quick Filters */}
+
+
+
+ {/* Kanban Board */}
+
+
+
+ {selectedPipeline.stages
+ .sort((a, b) => a.order - b.order)
+ .map((stage) => (
+
+ ))}
+
+
+
+
+ {/* Add/Edit Modal */}
+
{
+ setIsModalOpen(false);
+ setEditingOpportunity(null);
+ }}
+ onSave={handleSaveOpportunity}
+ opportunity={editingOpportunity}
+ stages={selectedPipeline.stages}
+ contacts={contacts}
+ pipelineId={selectedPipeline.id}
+ />
+
+ {/* Delete Confirmation Modal */}
+ setDeleteConfirm({ isOpen: false, id: '', name: '' })}
+ onConfirm={confirmDelete}
+ opportunityName={deleteConfirm.name}
+ />
+
+ );
+}
diff --git a/app/(app)/reporting/page.tsx b/app/(app)/reporting/page.tsx
new file mode 100644
index 0000000..f6bf84f
--- /dev/null
+++ b/app/(app)/reporting/page.tsx
@@ -0,0 +1,197 @@
+'use client';
+
+import React, { useState } from 'react';
+import {
+ BarChart3,
+ Bell,
+ TrendingUp,
+ PieChart,
+ FileSpreadsheet,
+ Calendar,
+ Download,
+ Target,
+ CheckCircle2,
+ Clock,
+ LineChart,
+ Activity,
+ Layers
+} from 'lucide-react';
+
+export default function ReportingPage() {
+ const [email, setEmail] = useState('');
+ const [submitted, setSubmitted] = useState(false);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (email) {
+ setSubmitted(true);
+ setEmail('');
+ }
+ };
+
+ const upcomingFeatures = [
+ { icon: LineChart, title: 'Pipeline Analytics', description: 'Track deal flow, conversion rates, and revenue forecasts across your pipeline' },
+ { icon: Activity, title: 'Outreach Performance', description: 'Measure email open rates, response rates, and engagement over time' },
+ { icon: PieChart, title: 'Deal Breakdown', description: 'Visualize your portfolio by property type, stage, market, and more' },
+ { icon: Target, title: 'Goal Tracking', description: 'Set targets and track progress with customizable KPI dashboards' },
+ { icon: Calendar, title: 'Time-Based Reports', description: 'Compare performance weekly, monthly, quarterly, or custom date ranges' },
+ { icon: Download, title: 'Export & Share', description: 'Export to PDF, Excel, or CSV. Schedule automated report delivery' },
+ ];
+
+ return (
+
+ {/* Hero Section */}
+
+ {/* Animated Icon Stack */}
+
+
+ {/* Badge */}
+
+
+ Coming Q2 2026
+
+
+
+ Analytics & Reporting
+
+
+ Turn your CRE data into actionable insights. Get beautiful dashboards,
+ custom reports, and the metrics you need to grow your business.
+
+
+ Track pipeline health, measure outreach effectiveness, and identify
+ opportunities with powerful analytics built specifically for commercial real estate.
+
+
+ {/* Email Signup */}
+ {!submitted ? (
+
+ ) : (
+
+
+ You're on the list! We'll notify you when it's ready.
+
+ )}
+
+ Be first to access reporting when it launches. No spam, just updates.
+
+
+
+ {/* Features Preview */}
+
+
+ What's Coming
+
+
+ {upcomingFeatures.map((feature, index) => (
+
+
+
+
+
{feature.title}
+
{feature.description}
+
+ ))}
+
+
+ {/* Dashboard Preview (Disabled/Grayed) */}
+
+
+
+ Dashboard Preview
+ Coming Soon
+
+
+ {/* Stats Row */}
+
+ {['Total Deals', 'Pipeline Value', 'Conversion Rate', 'Avg. Deal Size'].map((label, i) => (
+
+ ))}
+
+
+ {/* Charts Row */}
+
+ {/* Line Chart Placeholder */}
+
+
+
+ {[40, 65, 45, 80, 55, 90, 70, 85, 60, 95, 75, 88].map((h, i) => (
+
+ ))}
+
+
+
+ {/* Pie Chart Placeholder */}
+
+
+
+
+ {/* Report Types */}
+
+
+
+
Available Report Types
+
+
+ {[
+ 'Pipeline Summary',
+ 'Outreach Activity',
+ 'Deal Velocity',
+ 'Win/Loss Analysis',
+ 'Market Breakdown',
+ 'Team Performance',
+ ].map((report, index) => (
+
+
+ {report}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx
new file mode 100644
index 0000000..74350c2
--- /dev/null
+++ b/app/(app)/settings/page.tsx
@@ -0,0 +1,582 @@
+'use client';
+
+import React, { useState, useMemo } from 'react';
+import {
+ User,
+ Building,
+ Mail,
+ Lock,
+ Save,
+ Bell,
+ Camera,
+ RotateCcw,
+ Check,
+ X,
+ Eye,
+ EyeOff,
+ Shield,
+ AlertCircle
+} from 'lucide-react';
+
+type TabType = 'profile' | 'password' | 'notifications';
+
+interface NotificationSettings {
+ emailNotifications: boolean;
+ dealUpdates: boolean;
+ weeklyDigest: boolean;
+ marketingEmails: boolean;
+ smsAlerts: boolean;
+}
+
+export default function SettingsPage() {
+ const [activeTab, setActiveTab] = useState('profile');
+ const [showCurrentPassword, setShowCurrentPassword] = useState(false);
+ const [showNewPassword, setShowNewPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [profileSaveStatus, setProfileSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
+ const [passwordSaveStatus, setPasswordSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
+ const [notificationSaveStatus, setNotificationSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
+
+ // Simulated current user data
+ const initialProfileData = {
+ firstName: 'John',
+ lastName: 'Doe',
+ email: 'john.doe@example.com',
+ brokerage: 'Premier Real Estate',
+ phone: '(555) 123-4567',
+ };
+
+ const [formData, setFormData] = useState({
+ ...initialProfileData,
+ currentPassword: '',
+ newPassword: '',
+ confirmPassword: '',
+ });
+
+ const [notifications, setNotifications] = useState({
+ emailNotifications: true,
+ dealUpdates: true,
+ weeklyDigest: false,
+ marketingEmails: false,
+ smsAlerts: true,
+ });
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleNotificationChange = (key: keyof NotificationSettings) => {
+ setNotifications((prev) => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ const resetProfileForm = () => {
+ setFormData((prev) => ({
+ ...prev,
+ ...initialProfileData,
+ }));
+ setProfileSaveStatus('idle');
+ };
+
+ const resetPasswordForm = () => {
+ setFormData((prev) => ({
+ ...prev,
+ currentPassword: '',
+ newPassword: '',
+ confirmPassword: '',
+ }));
+ setPasswordSaveStatus('idle');
+ };
+
+ const handleProfileSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setProfileSaveStatus('saving');
+
+ // Simulate API call
+ setTimeout(() => {
+ setProfileSaveStatus('success');
+ setTimeout(() => setProfileSaveStatus('idle'), 3000);
+ }, 1000);
+ };
+
+ const handlePasswordSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (formData.newPassword !== formData.confirmPassword) {
+ setPasswordSaveStatus('error');
+ return;
+ }
+ setPasswordSaveStatus('saving');
+
+ // Simulate API call
+ setTimeout(() => {
+ setPasswordSaveStatus('success');
+ resetPasswordForm();
+ setTimeout(() => setPasswordSaveStatus('idle'), 3000);
+ }, 1000);
+ };
+
+ const handleNotificationSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setNotificationSaveStatus('saving');
+
+ // Simulate API call
+ setTimeout(() => {
+ setNotificationSaveStatus('success');
+ setTimeout(() => setNotificationSaveStatus('idle'), 3000);
+ }, 1000);
+ };
+
+ // Password strength calculation
+ const passwordStrength = useMemo(() => {
+ const password = formData.newPassword;
+ if (!password) return { score: 0, label: '', color: '' };
+
+ let score = 0;
+ if (password.length >= 8) score++;
+ if (password.length >= 12) score++;
+ if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
+ if (/\d/.test(password)) score++;
+ if (/[^a-zA-Z0-9]/.test(password)) score++;
+
+ if (score <= 1) return { score: 1, label: 'Weak', color: 'bg-red-500' };
+ if (score <= 2) return { score: 2, label: 'Fair', color: 'bg-orange-500' };
+ if (score <= 3) return { score: 3, label: 'Good', color: 'bg-yellow-500' };
+ if (score <= 4) return { score: 4, label: 'Strong', color: 'bg-green-500' };
+ return { score: 5, label: 'Very Strong', color: 'bg-emerald-500' };
+ }, [formData.newPassword]);
+
+ const tabs: { id: TabType; label: string; icon: React.ReactNode }[] = [
+ { id: 'profile', label: 'Profile', icon: },
+ { id: 'password', label: 'Password', icon: },
+ { id: 'notifications', label: 'Notifications', icon: },
+ ];
+
+ const SaveStatusBadge = ({ status }: { status: 'idle' | 'saving' | 'success' | 'error' }) => {
+ if (status === 'idle') return null;
+
+ return (
+
+ {status === 'saving' && (
+ <>
+
+ Saving changes...
+ >
+ )}
+ {status === 'success' && (
+ <>
+
+ Changes saved successfully!
+ >
+ )}
+ {status === 'error' && (
+ <>
+
+ Failed to save. Please try again.
+ >
+ )}
+
+ );
+ };
+
+ return (
+
+
+
Settings
+
Manage your account and preferences
+
+
+ {/* Tab Navigation */}
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.id)}
+ className={`flex items-center gap-2 px-5 py-3 rounded-lg font-medium transition-all ${
+ activeTab === tab.id
+ ? 'bg-white text-indigo-600 shadow-sm'
+ : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'
+ }`}
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ {/* Profile Tab */}
+ {activeTab === 'profile' && (
+
+
+
+
+
+
+
Profile Information
+
Update your personal details and photo
+
+
+
+ {/* Profile Photo Upload */}
+
+
+
+ {formData.firstName.charAt(0)}{formData.lastName.charAt(0)}
+
+
+
+
+
+
+
Profile Photo
+
JPG, PNG or GIF. Max size 2MB.
+
+ Upload new photo
+
+
+
+
+
+
+ )}
+
+ {/* Password Tab */}
+ {activeTab === 'password' && (
+
+
+
+
+
+
+
Change Password
+
Update your security credentials
+
+
+
+ {/* Security Tips */}
+
+
+
+
+
Password Security Tips
+
+ Use at least 12 characters with uppercase, lowercase, numbers, and symbols
+ Avoid using personal information or common words
+ Do not reuse passwords from other accounts
+
+
+
+
+
+
+
+ )}
+
+ {/* Notifications Tab */}
+ {activeTab === 'notifications' && (
+
+
+
+
+
+
+
Notification Preferences
+
Choose how you want to be notified
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/(app)/setup/page.tsx b/app/(app)/setup/page.tsx
new file mode 100644
index 0000000..d0cad1c
--- /dev/null
+++ b/app/(app)/setup/page.tsx
@@ -0,0 +1,290 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { useAuth } from '@/lib/hooks/useAuth';
+import {
+ MessageSquare, Mail, Users, Zap, CheckCircle2, ArrowUpRight,
+ Settings, Sparkles, Rocket
+} from 'lucide-react';
+
+interface SetupStatus {
+ smsConfigured: boolean;
+ emailConfigured: boolean;
+ contactsImported: boolean;
+ campaignsSetup: boolean;
+}
+
+export default function SetupPage() {
+ const { user } = useAuth();
+ const [progress, setProgress] = useState({
+ smsConfigured: false,
+ emailConfigured: false,
+ contactsImported: false,
+ campaignsSetup: false,
+ });
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch('/api/v1/onboarding/status')
+ .then(res => res.json())
+ .then(data => {
+ if (data.setupStatus) {
+ setProgress(data.setupStatus);
+ }
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ const steps = [
+ {
+ key: 'smsConfigured',
+ label: 'Configure SMS',
+ description: 'Set up two-way texting with A2P registration to communicate with your contacts via SMS.',
+ done: progress.smsConfigured,
+ href: '/settings/sms',
+ icon: MessageSquare,
+ color: 'purple'
+ },
+ {
+ key: 'emailConfigured',
+ label: 'Set up Email',
+ description: 'Connect your email account and configure domain authentication for better deliverability.',
+ done: progress.emailConfigured,
+ href: '/settings/email',
+ icon: Mail,
+ color: 'orange'
+ },
+ {
+ key: 'contactsImported',
+ label: 'Import Contacts',
+ description: 'Bring in your existing database of contacts to start managing relationships.',
+ done: progress.contactsImported,
+ href: '/contacts',
+ icon: Users,
+ color: 'blue'
+ },
+ {
+ key: 'campaignsSetup',
+ label: 'Create Campaigns',
+ description: 'Set up automated follow-up campaigns to nurture your leads and stay top of mind.',
+ done: progress.campaignsSetup,
+ href: '/automations',
+ icon: Zap,
+ color: 'emerald'
+ },
+ ];
+
+ const completedCount = steps.filter(s => s.done).length;
+ const progressPercent = (completedCount / steps.length) * 100;
+ const isComplete = progressPercent === 100;
+
+ const colorClasses: Record = {
+ purple: { bg: 'bg-purple-100', icon: 'text-purple-600', border: 'border-purple-200' },
+ orange: { bg: 'bg-orange-100', icon: 'text-orange-600', border: 'border-orange-200' },
+ blue: { bg: 'bg-blue-100', icon: 'text-blue-600', border: 'border-blue-200' },
+ emerald: { bg: 'bg-emerald-100', icon: 'text-emerald-600', border: 'border-emerald-200' },
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ {isComplete ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isComplete ? 'Setup Complete!' : 'Setup Your Account'}
+
+
+ {isComplete
+ ? `Great job${user?.firstName ? `, ${user.firstName}` : ''}! You're ready to make the most of CRESync.`
+ : `Complete these steps to unlock all features${user?.firstName ? `, ${user.firstName}` : ''}.`}
+
+
+
+
+
+ {/* Progress Overview */}
+
+
+
+
+ {isComplete ? (
+
+ ) : (
+
+ )}
+
+
+
Progress Overview
+
+ {completedCount} of {steps.length} steps completed
+
+
+
+
+ {Math.round(progressPercent)}%
+ complete
+
+
+
+ {/* Progress Bar */}
+
+
+
+ {steps.map((step, index) => (
+
+ {step.done ? (
+
+ ) : (
+
+ )}
+ {step.label.split(' ')[0]}
+
+ ))}
+
+
+
+
+ {/* Setup Steps */}
+
+
+
Setup Steps
+
Complete each step to get the most out of CRESync
+
+
+ {loading ? (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ ) : (
+
+ {steps.map((step, index) => {
+ const StepIcon = step.icon;
+ const colors = colorClasses[step.color];
+
+ return (
+
+
+ {/* Step Number & Icon */}
+
+
+ {step.done ? (
+
+ ) : (
+ index + 1
+ )}
+
+
+
+
+
+
+ {/* Content */}
+
+
+ {step.label}
+
+
{step.description}
+
+
+ {/* Action */}
+
+ {step.done ? (
+
+
+ Completed
+
+ ) : (
+
+ Get Started
+
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Completion Message */}
+ {isComplete && (
+
+
+
+
+
You're All Set!
+
+ Your account is fully configured. Start managing your contacts, running campaigns, and closing more deals.
+
+
+ Go to Dashboard
+
+
+
+ )}
+
+ );
+}
diff --git a/app/(app)/tools/page.tsx b/app/(app)/tools/page.tsx
new file mode 100644
index 0000000..b0ee56f
--- /dev/null
+++ b/app/(app)/tools/page.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import React from 'react';
+import { ExternalTools } from '@/components/ExternalTools';
+
+export default function ToolsPage() {
+ const handleToolClick = (toolName: string) => {
+ console.log(`Tool accessed: ${toolName}`);
+ };
+
+ return (
+
+ );
+}
diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx
new file mode 100644
index 0000000..aa2d531
--- /dev/null
+++ b/app/(auth)/layout.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { AuthProvider } from '@/lib/hooks/useAuth';
+
+export default function AuthLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..f175669
--- /dev/null
+++ b/app/(auth)/login/page.tsx
@@ -0,0 +1,167 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { useAuth } from '@/lib/hooks/useAuth';
+import { Building2, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
+
+export default function LoginPage() {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [rememberMe, setRememberMe] = useState(false);
+ const router = useRouter();
+ const { login } = useAuth();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await login(email, password);
+ router.push('/dashboard');
+ } catch (err: any) {
+ setError(err.message || 'Invalid credentials');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* Logo */}
+
+
+
+
+
+
+
CRESync
+
Commercial Real Estate CRM
+
+
+
+
+ {/* Login Card */}
+
+
+
Welcome Back
+
Enter your credentials to continue
+
+
+
+
+
+
+
+ Don't have an account?{' '}
+
+ Create one
+
+
+
+
+ {/* Footer */}
+
+ By signing in, you agree to our{' '}
+ Terms
+ {' '}and{' '}
+ Privacy Policy
+
+
+
+ );
+}
diff --git a/app/(auth)/onboarding/page.tsx b/app/(auth)/onboarding/page.tsx
new file mode 100644
index 0000000..34a4168
--- /dev/null
+++ b/app/(auth)/onboarding/page.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import { useState } from "react";
+import { ArrowRight, ArrowLeft, Check, Target, Users, Zap, BarChart3 } from "lucide-react";
+
+const steps = [
+ {
+ id: 1,
+ title: "What are your goals?",
+ description: "Select the goals that matter most to you",
+ },
+ {
+ id: 2,
+ title: "About your business",
+ description: "Help us customize your experience",
+ },
+ {
+ id: 3,
+ title: "Communication channels",
+ description: "Choose how you want to reach your leads",
+ },
+];
+
+const goals = [
+ { id: "seller-leads", label: "Generate seller leads", icon: Target },
+ { id: "buyer-leads", label: "Generate buyer leads", icon: Users },
+ { id: "automation", label: "Automate follow-ups", icon: Zap },
+ { id: "analytics", label: "Track metrics", icon: BarChart3 },
+];
+
+export default function OnboardingPage() {
+ const [currentStep, setCurrentStep] = useState(1);
+ const [selectedGoals, setSelectedGoals] = useState([]);
+
+ const toggleGoal = (goalId: string) => {
+ setSelectedGoals((prev) =>
+ prev.includes(goalId)
+ ? prev.filter((id) => id !== goalId)
+ : [...prev, goalId]
+ );
+ };
+
+ return (
+
+ {/* Progress */}
+
+
+ {steps.map((step, index) => (
+
+
step.id
+ ? "bg-green-500 text-white"
+ : currentStep === step.id
+ ? "bg-primary-600 text-white"
+ : "bg-slate-200 text-slate-500"
+ }`}
+ >
+ {currentStep > step.id ? : step.id}
+
+ {index < steps.length - 1 && (
+
step.id ? "bg-green-500" : "bg-slate-200"
+ }`}
+ />
+ )}
+
+ ))}
+
+
+
+ {steps[currentStep - 1].title}
+
+
+ {steps[currentStep - 1].description}
+
+
+
+
+ {/* Step Content */}
+ {currentStep === 1 && (
+
+ {goals.map((goal) => {
+ const Icon = goal.icon;
+ const isSelected = selectedGoals.includes(goal.id);
+ return (
+
toggleGoal(goal.id)}
+ className={`w-full p-4 rounded-xl border-2 flex items-center gap-4 transition-all ${
+ isSelected
+ ? "border-primary-500 bg-primary-50"
+ : "border-slate-200 hover:border-slate-300"
+ }`}
+ >
+
+
+
+
+ {goal.label}
+
+ {isSelected && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {currentStep === 2 && (
+
+
+
+ How many deals do you close per month?
+
+
+ 1-5 deals
+ 6-10 deals
+ 11-20 deals
+ 20+ deals
+
+
+
+
+ Team size
+
+
+ Just me
+ 2-5 people
+ 6-10 people
+ 10+ people
+
+
+
+
+ Primary market
+
+
+ Office
+ Retail
+ Industrial
+ Multifamily
+ Mixed Use
+
+
+
+ )}
+
+ {currentStep === 3 && (
+
+
+ Select the channels you want to use for outreach:
+
+ {[
+ { id: "email", label: "Email", description: "Send automated email campaigns" },
+ { id: "sms", label: "SMS", description: "Text message follow-ups" },
+ { id: "phone", label: "Phone", description: "Call tracking and logging" },
+ ].map((channel) => (
+
+
+
+
{channel.label}
+
{channel.description}
+
+
+ ))}
+
+ )}
+
+ {/* Navigation */}
+
+
setCurrentStep((prev) => Math.max(1, prev - 1))}
+ className={`btn-secondary flex items-center gap-2 ${
+ currentStep === 1 ? "invisible" : ""
+ }`}
+ >
+
+ Back
+
+
setCurrentStep((prev) => Math.min(3, prev + 1))}
+ className="btn-primary flex items-center gap-2"
+ >
+ {currentStep === 3 ? "Finish Setup" : "Continue"}
+
+
+
+
+ );
+}
diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx
new file mode 100644
index 0000000..a611dce
--- /dev/null
+++ b/app/(auth)/signup/page.tsx
@@ -0,0 +1,266 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { useAuth } from '@/lib/hooks/useAuth';
+import { Building2, Mail, Lock, User, Briefcase, ArrowRight, Loader2 } from 'lucide-react';
+
+export default function SignupPage() {
+ const [formData, setFormData] = useState({
+ email: '',
+ password: '',
+ confirmPassword: '',
+ firstName: '',
+ lastName: '',
+ brokerage: '',
+ });
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [agreed, setAgreed] = useState(false);
+ const router = useRouter();
+ const { signup } = useAuth();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!agreed) {
+ setError('Please agree to the Terms of Service and Privacy Policy');
+ return;
+ }
+
+ if (formData.password !== formData.confirmPassword) {
+ setError('Passwords do not match');
+ return;
+ }
+
+ if (formData.password.length < 8) {
+ setError('Password must be at least 8 characters');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await signup({
+ email: formData.email,
+ password: formData.password,
+ firstName: formData.firstName,
+ lastName: formData.lastName,
+ });
+ router.push('/onboarding');
+ } catch (err: any) {
+ setError(err.message || 'Signup failed');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* Logo */}
+
+
+
+
+
+
+
CRESync
+
Commercial Real Estate CRM
+
+
+
+
+ {/* Signup Card */}
+
+
+
Create Account
+
Start managing your CRE business
+
+
+
+
+
+
+
+ Already have an account?{' '}
+
+ Sign in
+
+
+
+
+ {/* Footer */}
+
+ Protected by industry-standard encryption
+
+
+
+ );
+}
diff --git a/app/api/v1/admin/route.ts b/app/api/v1/admin/route.ts
new file mode 100644
index 0000000..847ab98
--- /dev/null
+++ b/app/api/v1/admin/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+
+export async function GET() {
+ // TODO: Implement admin data retrieval
+ return NextResponse.json({
+ stats: {
+ totalUsers: 0,
+ activeSessions: 0,
+ storageUsed: "0 GB",
+ alerts: 0,
+ },
+ });
+}
+
+export async function POST(request: Request) {
+ // TODO: Implement admin actions
+ const body = await request.json();
+
+ return NextResponse.json({
+ message: "Admin action completed",
+ action: body,
+ });
+}
diff --git a/app/api/v1/admin/settings/route.ts b/app/api/v1/admin/settings/route.ts
new file mode 100644
index 0000000..4f9c13b
--- /dev/null
+++ b/app/api/v1/admin/settings/route.ts
@@ -0,0 +1,70 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession, isSuperAdmin } from '@/lib/auth';
+import { settingsService } from '@/lib/settings';
+import { Role } from '@/types';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session || !isSuperAdmin(session.user.role as Role)) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ const settings = await settingsService.getAllMasked();
+ return NextResponse.json({ settings });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
+ }
+}
+
+const updateSettingsSchema = z.object({
+ ghlAgencyApiKey: z.string().optional(),
+ ghlAgencyId: z.string().optional(),
+ ghlPrivateToken: z.string().optional(),
+ ghlOwnerLocationId: z.string().optional(),
+ ghlWebhookSecret: z.string().optional(),
+ tagHighGCI: z.string().optional(),
+ tagOnboardingComplete: z.string().optional(),
+ tagDFYRequested: z.string().optional(),
+ stripeSecretKey: z.string().optional(),
+ stripeWebhookSecret: z.string().optional(),
+ clickupApiKey: z.string().optional(),
+ clickupListId: z.string().optional(),
+ dfyPriceFullSetup: z.string().optional(),
+ dfyPriceSmsSetup: z.string().optional(),
+ dfyPriceEmailSetup: z.string().optional(),
+ calendlyCoachingLink: z.string().optional(),
+ calendlyTeamLink: z.string().optional(),
+ notificationEmail: z.string().email().optional(),
+ // AI Configuration
+ claudeApiKey: z.string().optional(),
+ openaiApiKey: z.string().optional(),
+ mcpServerUrl: z.string().optional(),
+});
+
+export async function PUT(request: NextRequest) {
+ const session = await getSession();
+ if (!session || !isSuperAdmin(session.user.role as Role)) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ const body = await request.json();
+ const validated = updateSettingsSchema.parse(body);
+
+ // Filter out empty strings
+ const filteredSettings = Object.fromEntries(
+ Object.entries(validated).filter(([_, v]) => v !== '' && v !== undefined)
+ );
+
+ await settingsService.setMany(filteredSettings, session.user.id);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Validation failed', details: error.issues }, { status: 400 });
+ }
+ return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/admin/settings/test/route.ts b/app/api/v1/admin/settings/test/route.ts
new file mode 100644
index 0000000..76fc602
--- /dev/null
+++ b/app/api/v1/admin/settings/test/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession, isSuperAdmin } from '@/lib/auth';
+import { settingsService } from '@/lib/settings';
+import { Role } from '@/types';
+
+const testSchema = z.object({
+ service: z.enum(['ghl', 'stripe']),
+});
+
+export async function POST(request: NextRequest) {
+ const session = await getSession();
+ if (!session || !isSuperAdmin(session.user.role as Role)) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ const body = await request.json();
+ const { service } = testSchema.parse(body);
+
+ let result;
+ if (service === 'ghl') {
+ result = await settingsService.testGHLConnection();
+ } else if (service === 'stripe') {
+ result = await settingsService.testStripeConnection();
+ }
+
+ return NextResponse.json(result);
+ } catch (error) {
+ return NextResponse.json({ error: 'Test failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/admin/stats/route.ts b/app/api/v1/admin/stats/route.ts
new file mode 100644
index 0000000..376423a
--- /dev/null
+++ b/app/api/v1/admin/stats/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma } from '@/lib/db';
+import { getSession, isAdmin } from '@/lib/auth';
+import { Role } from '@/types';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session || !isAdmin(session.user.role as Role)) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ const [
+ totalUsers,
+ recentSignups,
+ pendingDFY,
+ usersWithOnboarding,
+ ] = await Promise.all([
+ prisma.user.count(),
+ prisma.user.count({
+ where: {
+ createdAt: {
+ gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
+ },
+ },
+ }),
+ prisma.dFYRequest.count({ where: { status: 'PENDING' } }),
+ prisma.user.findMany({
+ include: { onboarding: true, setupStatus: true },
+ }),
+ ]);
+
+ // Calculate high GCI users
+ const highGCIUsers = usersWithOnboarding.filter(u =>
+ u.onboarding?.gciLast12Months?.includes('100') ||
+ u.onboarding?.gciLast12Months?.includes('250')
+ ).length;
+
+ // Calculate incomplete setup
+ const incompleteSetup = usersWithOnboarding.filter(u =>
+ !u.setupStatus?.smsConfigured || !u.setupStatus?.emailConfigured
+ ).length;
+
+ return NextResponse.json({
+ stats: {
+ totalUsers,
+ highGCIUsers,
+ incompleteSetup,
+ dfyRequestsPending: pendingDFY,
+ recentSignups,
+ },
+ });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/auth/login/route.ts b/app/api/v1/auth/login/route.ts
new file mode 100644
index 0000000..4448980
--- /dev/null
+++ b/app/api/v1/auth/login/route.ts
@@ -0,0 +1,73 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { prisma } from '@/lib/db';
+import { verifyPassword, signToken, setSessionCookie } from '@/lib/auth';
+import { Role } from '@/types';
+
+const loginSchema = z.object({
+ email: z.string().email(),
+ password: z.string(),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const validated = loginSchema.parse(body);
+
+ // Find user
+ const user = await prisma.user.findUnique({
+ where: { email: validated.email },
+ });
+
+ if (!user) {
+ return NextResponse.json(
+ { error: 'Invalid email or password' },
+ { status: 401 }
+ );
+ }
+
+ // Verify password
+ const isValid = await verifyPassword(validated.password, user.passwordHash);
+ if (!isValid) {
+ return NextResponse.json(
+ { error: 'Invalid email or password' },
+ { status: 401 }
+ );
+ }
+
+ // Generate token
+ const token = signToken({
+ userId: user.id,
+ email: user.email,
+ role: user.role as Role,
+ });
+
+ // Set session cookie
+ await setSessionCookie(token);
+
+ return NextResponse.json({
+ success: true,
+ user: {
+ id: user.id,
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ role: user.role,
+ ghlLocationId: user.ghlLocationId,
+ },
+ token,
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: error.issues },
+ { status: 400 }
+ );
+ }
+ console.error('Login error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/auth/logout/route.ts b/app/api/v1/auth/logout/route.ts
new file mode 100644
index 0000000..3f5c894
--- /dev/null
+++ b/app/api/v1/auth/logout/route.ts
@@ -0,0 +1,7 @@
+import { NextResponse } from 'next/server';
+import { clearSessionCookie } from '@/lib/auth';
+
+export async function POST() {
+ await clearSessionCookie();
+ return NextResponse.json({ success: true });
+}
diff --git a/app/api/v1/auth/me/route.ts b/app/api/v1/auth/me/route.ts
new file mode 100644
index 0000000..b1c34e7
--- /dev/null
+++ b/app/api/v1/auth/me/route.ts
@@ -0,0 +1,87 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { prisma } from '@/lib/db';
+import { getSession } from '@/lib/auth';
+
+export async function GET() {
+ const session = await getSession();
+
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ include: {
+ onboarding: true,
+ setupStatus: true,
+ },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({
+ user: {
+ id: user.id,
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ brokerage: user.brokerage,
+ role: user.role,
+ ghlLocationId: user.ghlLocationId,
+ onboardingCompleted: !!user.onboarding,
+ setupStatus: user.setupStatus,
+ createdAt: user.createdAt,
+ },
+ });
+}
+
+const updateSchema = z.object({
+ firstName: z.string().min(1).optional(),
+ lastName: z.string().min(1).optional(),
+ brokerage: z.string().optional(),
+});
+
+export async function PATCH(request: NextRequest) {
+ const session = await getSession();
+
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const validated = updateSchema.parse(body);
+
+ const user = await prisma.user.update({
+ where: { id: session.user.id },
+ data: validated,
+ });
+
+ return NextResponse.json({
+ success: true,
+ user: {
+ id: user.id,
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ brokerage: user.brokerage,
+ role: user.role,
+ },
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: error.issues },
+ { status: 400 }
+ );
+ }
+ console.error('Update user error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/auth/signup/route.ts b/app/api/v1/auth/signup/route.ts
new file mode 100644
index 0000000..50c6d4d
--- /dev/null
+++ b/app/api/v1/auth/signup/route.ts
@@ -0,0 +1,94 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { prisma } from '@/lib/db';
+import { hashPassword, signToken, setSessionCookie } from '@/lib/auth';
+import { provisionGHLForUser } from '@/lib/ghl';
+import { adminTaggingService } from '@/lib/ghl';
+import { Role } from '@/types';
+
+const signupSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(8),
+ firstName: z.string().min(1),
+ lastName: z.string().min(1),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const validated = signupSchema.parse(body);
+
+ // Check if user already exists
+ const existingUser = await prisma.user.findUnique({
+ where: { email: validated.email },
+ });
+
+ if (existingUser) {
+ return NextResponse.json(
+ { error: 'User with this email already exists' },
+ { status: 409 }
+ );
+ }
+
+ // Hash password
+ const passwordHash = await hashPassword(validated.password);
+
+ // Create user
+ const user = await prisma.user.create({
+ data: {
+ email: validated.email,
+ passwordHash,
+ firstName: validated.firstName,
+ lastName: validated.lastName,
+ role: Role.USER,
+ },
+ });
+
+ // Create initial setup status
+ await prisma.setupStatus.create({
+ data: { userId: user.id },
+ });
+
+ // Provision GHL sub-account (async, don't block signup)
+ provisionGHLForUser({
+ userId: user.id,
+ email: user.email,
+ firstName: validated.firstName,
+ lastName: validated.lastName,
+ }).catch(err => console.error('GHL provisioning failed:', err));
+
+ // Generate token
+ const token = signToken({
+ userId: user.id,
+ email: user.email,
+ role: user.role as Role,
+ });
+
+ // Set session cookie
+ await setSessionCookie(token);
+
+ return NextResponse.json({
+ success: true,
+ user: {
+ id: user.id,
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ role: user.role,
+ },
+ token,
+ }, { status: 201 });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: error.issues },
+ { status: 400 }
+ );
+ }
+ console.error('Signup error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/contacts/[contactId]/route.ts b/app/api/v1/contacts/[contactId]/route.ts
new file mode 100644
index 0000000..df615ce
--- /dev/null
+++ b/app/api/v1/contacts/[contactId]/route.ts
@@ -0,0 +1,115 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClient } from '@/lib/api';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ contactId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json(
+ { error: 'GHL not configured' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const { contactId } = await params;
+ const contact = await ghl.contacts.getById(contactId);
+ return NextResponse.json(contact);
+ } catch (error) {
+ console.error('Failed to get contact:', error);
+ return NextResponse.json(
+ { error: 'Contact not found' },
+ { status: 404 }
+ );
+ }
+}
+
+const updateContactSchema = z.object({
+ firstName: z.string().optional(),
+ lastName: z.string().optional(),
+ email: z.string().email().optional(),
+ phone: z.string().optional(),
+ tags: z.array(z.string()).optional(),
+ customFields: z.array(z.object({
+ key: z.string(),
+ value: z.any(),
+ })).optional(),
+});
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ contactId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json(
+ { error: 'GHL not configured' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const { contactId } = await params;
+ const body = await request.json();
+ const validated = updateContactSchema.parse(body);
+
+ const contact = await ghl.contacts.update(contactId, validated);
+ return NextResponse.json(contact);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: error.issues },
+ { status: 400 }
+ );
+ }
+ console.error('Failed to update contact:', error);
+ return NextResponse.json(
+ { error: 'Failed to update contact' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ contactId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json(
+ { error: 'GHL not configured' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const { contactId } = await params;
+ await ghl.contacts.delete(contactId);
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Failed to delete contact:', error);
+ return NextResponse.json(
+ { error: 'Failed to delete contact' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/contacts/[contactId]/tags/route.ts b/app/api/v1/contacts/[contactId]/tags/route.ts
new file mode 100644
index 0000000..df5a348
--- /dev/null
+++ b/app/api/v1/contacts/[contactId]/tags/route.ts
@@ -0,0 +1,63 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClient } from '@/lib/api';
+
+const tagsSchema = z.object({
+ tags: z.array(z.string()),
+});
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ contactId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ try {
+ const { contactId } = await params;
+ const body = await request.json();
+ const { tags } = tagsSchema.parse(body);
+
+ const contact = await ghl.contacts.addTags(contactId, tags);
+ return NextResponse.json(contact);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
+ }
+ return NextResponse.json({ error: 'Failed to add tags' }, { status: 500 });
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ contactId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ try {
+ const { contactId } = await params;
+ const body = await request.json();
+ const { tags } = tagsSchema.parse(body);
+
+ const contact = await ghl.contacts.removeTags(contactId, tags);
+ return NextResponse.json(contact);
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to remove tags' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/contacts/[contactId]/workflow/[workflowId]/route.ts b/app/api/v1/contacts/[contactId]/workflow/[workflowId]/route.ts
new file mode 100644
index 0000000..e76e732
--- /dev/null
+++ b/app/api/v1/contacts/[contactId]/workflow/[workflowId]/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { getGHLClient } from '@/lib/api';
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ contactId: string; workflowId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ try {
+ const { contactId, workflowId } = await params;
+ await ghl.contacts.addToWorkflow(contactId, workflowId);
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to add to workflow' }, { status: 500 });
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ contactId: string; workflowId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ try {
+ const { contactId, workflowId } = await params;
+ await ghl.contacts.removeFromWorkflow(contactId, workflowId);
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to remove from workflow' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/contacts/route.ts b/app/api/v1/contacts/route.ts
new file mode 100644
index 0000000..cdbde13
--- /dev/null
+++ b/app/api/v1/contacts/route.ts
@@ -0,0 +1,82 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClient } from '@/lib/api';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json(
+ { error: 'GHL not configured for this user' },
+ { status: 400 }
+ );
+ }
+
+ const { searchParams } = new URL(request.url);
+ const limit = parseInt(searchParams.get('limit') || '20');
+ const offset = parseInt(searchParams.get('offset') || '0');
+ const query = searchParams.get('query') || undefined;
+
+ try {
+ const contacts = await ghl.contacts.getAll({ limit, offset, query });
+ return NextResponse.json(contacts);
+ } catch (error) {
+ console.error('Failed to get contacts:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch contacts' },
+ { status: 500 }
+ );
+ }
+}
+
+const createContactSchema = z.object({
+ firstName: z.string().optional(),
+ lastName: z.string().optional(),
+ email: z.string().email().optional(),
+ phone: z.string().optional(),
+ tags: z.array(z.string()).optional(),
+ customFields: z.array(z.object({
+ key: z.string(),
+ value: z.any(),
+ })).optional(),
+});
+
+export async function POST(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClient(session.user.id);
+ if (!ghl) {
+ return NextResponse.json(
+ { error: 'GHL not configured for this user' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+ const validated = createContactSchema.parse(body);
+
+ const contact = await ghl.contacts.create(validated);
+ return NextResponse.json(contact, { status: 201 });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: error.issues },
+ { status: 400 }
+ );
+ }
+ console.error('Failed to create contact:', error);
+ return NextResponse.json(
+ { error: 'Failed to create contact' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/control-center/chat/route.ts b/app/api/v1/control-center/chat/route.ts
new file mode 100644
index 0000000..d712703
--- /dev/null
+++ b/app/api/v1/control-center/chat/route.ts
@@ -0,0 +1,345 @@
+/**
+ * Control Center Chat API Endpoint
+ * CRESyncFlow - Commercial Real Estate CRM
+ *
+ * POST /api/v1/control-center/chat
+ *
+ * Handles the main AI chat functionality with SSE streaming.
+ * Supports tool use with an agentic loop for multi-step interactions.
+ */
+
+import { NextRequest } from 'next/server';
+import type { Prisma } from '@prisma/client';
+import { getSession } from '@/lib/auth';
+import { settingsService } from '@/lib/settings/settings-service';
+import { conversationService } from '@/lib/control-center/conversation-service';
+import { createAICompletion, AIClientError } from '@/lib/control-center/ai-client';
+import { createToolRouter } from '@/lib/control-center/tool-router';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+import type {
+ StreamEvent,
+ ControlCenterMessage,
+ ToolCall,
+ ToolResult,
+} from '@/types/control-center';
+import type { ToolContext } from '@/lib/control-center/types';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+interface ChatRequestBody {
+ conversationId?: string;
+ message: string;
+ provider?: 'claude' | 'openai';
+}
+
+// =============================================================================
+// SSE Helpers
+// =============================================================================
+
+/**
+ * Create an SSE-formatted string for an event
+ */
+function formatSSE(event: StreamEvent): string {
+ return `data: ${JSON.stringify(event)}\n\n`;
+}
+
+/**
+ * Create an SSE error response
+ */
+function createErrorResponse(message: string, status: number): Response {
+ return new Response(JSON.stringify({ error: message }), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+// =============================================================================
+// System Prompt
+// =============================================================================
+
+const SYSTEM_PROMPT = `You are an AI assistant for CRESyncFlow, a commercial real estate CRM platform. You help users manage their leads, contacts, conversations, and business workflows.
+
+You have access to tools that allow you to:
+- Search and manage contacts and leads
+- View and send messages in conversations
+- Look up opportunity and deal information
+- Access CRM data and analytics
+
+When users ask questions about their CRM data, use the available tools to fetch real information. Be helpful, professional, and concise in your responses.
+
+If you don't have access to a specific piece of information or a tool to retrieve it, let the user know what you can help with instead.`;
+
+// =============================================================================
+// Main Handler
+// =============================================================================
+
+export async function POST(request: NextRequest): Promise
{
+ // Authenticate user
+ const session = await getSession();
+ if (!session) {
+ return createErrorResponse('Unauthorized', 401);
+ }
+
+ // Parse and validate request body
+ let body: ChatRequestBody;
+ try {
+ body = await request.json();
+ } catch {
+ return createErrorResponse('Invalid JSON body', 400);
+ }
+
+ if (!body.message || typeof body.message !== 'string' || body.message.trim() === '') {
+ return createErrorResponse('Missing or empty message', 400);
+ }
+
+ const provider = body.provider || 'claude';
+ if (provider !== 'claude' && provider !== 'openai') {
+ return createErrorResponse('Invalid provider. Must be "claude" or "openai"', 400);
+ }
+
+ // Check for AI API key
+ const apiKeyName = provider === 'claude' ? 'claudeApiKey' : 'openaiApiKey';
+ const apiKey = await settingsService.get(apiKeyName);
+
+ if (!apiKey) {
+ return createErrorResponse(
+ `No ${provider === 'claude' ? 'Claude' : 'OpenAI'} API key configured. Please add your API key in settings.`,
+ 400
+ );
+ }
+
+ // Create SSE stream
+ const encoder = new TextEncoder();
+ const stream = new TransformStream();
+ const writer = stream.writable.getWriter();
+
+ // Start processing in the background
+ (async () => {
+ try {
+ // Create or get conversation
+ let conversationId = body.conversationId;
+ if (!conversationId) {
+ const conversation = await conversationService.create(
+ session.user.id,
+ body.message.slice(0, 100) // Use first 100 chars as title
+ );
+ conversationId = conversation.id;
+ }
+
+ // Verify conversation belongs to user
+ const existingConversation = await conversationService.getById(conversationId);
+ if (!existingConversation) {
+ await writer.write(
+ encoder.encode(formatSSE({ type: 'error', error: 'Conversation not found' }))
+ );
+ await writer.close();
+ return;
+ }
+
+ // Save user message
+ const userMessage = await conversationService.addMessage(conversationId, {
+ role: 'user',
+ content: body.message,
+ });
+
+ // Generate a message ID for the assistant response
+ const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
+
+ // Send message_start event
+ await writer.write(
+ encoder.encode(
+ formatSSE({
+ type: 'message_start',
+ conversationId,
+ messageId,
+ })
+ )
+ );
+
+ // Get available tools (limited to 128 for OpenAI compatibility)
+ const toolRouter = await createToolRouter();
+ const allTools = await toolRouter.getAllTools();
+
+ // Prioritize CRE-relevant tools: contacts, conversations, opportunities, calendar, email
+ const priorityPrefixes = ['contact', 'conversation', 'opportunity', 'calendar', 'email', 'location'];
+ const priorityTools = allTools.filter(t =>
+ priorityPrefixes.some(prefix => t.name.toLowerCase().startsWith(prefix))
+ );
+ const otherTools = allTools.filter(t =>
+ !priorityPrefixes.some(prefix => t.name.toLowerCase().startsWith(prefix))
+ );
+
+ // Combine priority tools first, then fill with others up to 128 max
+ const tools = [...priorityTools, ...otherTools].slice(0, 128);
+
+ // Build messages array from conversation history
+ const messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> =
+ existingConversation.messages.map((msg) => ({
+ role: msg.role as 'user' | 'assistant',
+ content: msg.content,
+ }));
+
+ // Add the current user message
+ messages.push({ role: 'user', content: body.message });
+
+ // Set up tool context
+ const ghlClient = await getGHLClientForUser(session.user.id);
+ const toolContext: ToolContext = {
+ userId: session.user.id,
+ ghlClient,
+ };
+
+ // Agentic loop: continue until AI doesn't return tool calls
+ let fullContent = '';
+ const allToolCalls: ToolCall[] = [];
+ const allToolResults: ToolResult[] = [];
+ let iterations = 0;
+ const maxIterations = 10; // Prevent infinite loops
+
+ while (iterations < maxIterations) {
+ iterations++;
+
+ // Call AI
+ const result = await createAICompletion({
+ provider,
+ apiKey,
+ messages,
+ tools,
+ systemPrompt: SYSTEM_PROMPT,
+ });
+
+ // Stream content delta if there's text
+ if (result.content) {
+ fullContent += result.content;
+ await writer.write(
+ encoder.encode(
+ formatSSE({
+ type: 'content_delta',
+ delta: result.content,
+ })
+ )
+ );
+ }
+
+ // If no tool calls, we're done
+ if (!result.toolCalls || result.toolCalls.length === 0) {
+ break;
+ }
+
+ // Process tool calls
+ for (const toolCall of result.toolCalls) {
+ allToolCalls.push(toolCall);
+
+ // Send tool_call_start event
+ await writer.write(
+ encoder.encode(
+ formatSSE({
+ type: 'tool_call_start',
+ toolCall,
+ })
+ )
+ );
+
+ // Execute the tool
+ const toolResult = await toolRouter.execute(toolCall, toolContext);
+ allToolResults.push(toolResult);
+
+ // Send tool_result event
+ await writer.write(
+ encoder.encode(
+ formatSSE({
+ type: 'tool_result',
+ toolResult,
+ })
+ )
+ );
+ }
+
+ // Add assistant message with tool calls to conversation
+ messages.push({
+ role: 'assistant',
+ content: result.content || '',
+ });
+
+ // Add tool results as a user message (following Claude's format)
+ const toolResultsContent = allToolResults
+ .slice(-result.toolCalls.length)
+ .map((tr) => {
+ if (tr.success) {
+ return `Tool ${tr.toolCallId} result: ${JSON.stringify(tr.result)}`;
+ }
+ return `Tool ${tr.toolCallId} error: ${tr.error}`;
+ })
+ .join('\n');
+
+ messages.push({
+ role: 'user',
+ content: toolResultsContent,
+ });
+ }
+
+ // Save final assistant message
+ const savedMessage = await conversationService.addMessage(conversationId, {
+ role: 'assistant',
+ content: fullContent,
+ toolCalls: allToolCalls.length > 0 ? (allToolCalls as unknown as Prisma.InputJsonValue) : undefined,
+ toolResults: allToolResults.length > 0 ? (allToolResults as unknown as Prisma.InputJsonValue) : undefined,
+ });
+
+ // Build the complete message object
+ const completeMessage: ControlCenterMessage = {
+ id: savedMessage.id,
+ role: 'assistant',
+ content: fullContent,
+ toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
+ toolResults: allToolResults.length > 0 ? allToolResults : undefined,
+ createdAt: savedMessage.createdAt.toISOString(),
+ };
+
+ // Send message_complete event
+ await writer.write(
+ encoder.encode(
+ formatSSE({
+ type: 'message_complete',
+ message: completeMessage,
+ })
+ )
+ );
+ } catch (error) {
+ console.error('Control Center chat error:', error);
+
+ let errorMessage = 'An unexpected error occurred';
+ let errorCode: string | undefined;
+
+ if (error instanceof AIClientError) {
+ errorMessage = error.message;
+ errorCode = error.statusCode?.toString();
+ } else if (error instanceof Error) {
+ errorMessage = error.message;
+ }
+
+ await writer.write(
+ encoder.encode(
+ formatSSE({
+ type: 'error',
+ error: errorMessage,
+ code: errorCode,
+ })
+ )
+ );
+ } finally {
+ await writer.close();
+ }
+ })();
+
+ // Return the SSE response
+ return new Response(stream.readable, {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ },
+ });
+}
diff --git a/app/api/v1/control-center/conversations/route.ts b/app/api/v1/control-center/conversations/route.ts
new file mode 100644
index 0000000..476721b
--- /dev/null
+++ b/app/api/v1/control-center/conversations/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { conversationService } from '@/lib/control-center';
+import { prisma } from '@/lib/db';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const limit = parseInt(searchParams.get('limit') || '20', 10);
+ const offset = parseInt(searchParams.get('offset') || '0', 10);
+
+ try {
+ const [conversations, total] = await Promise.all([
+ conversationService.listByUser(session.user.id, limit, offset),
+ prisma.controlCenterConversation.count({
+ where: { userId: session.user.id }
+ })
+ ]);
+
+ return NextResponse.json({
+ conversations,
+ total,
+ limit,
+ offset
+ });
+ } catch (error) {
+ console.error('Failed to list conversations:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch conversations' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/control-center/history/[conversationId]/route.ts b/app/api/v1/control-center/history/[conversationId]/route.ts
new file mode 100644
index 0000000..dc94cf5
--- /dev/null
+++ b/app/api/v1/control-center/history/[conversationId]/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { conversationService } from '@/lib/control-center';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ conversationId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { conversationId } = await params;
+
+ try {
+ const conversation = await conversationService.getById(conversationId);
+
+ if (!conversation) {
+ return NextResponse.json(
+ { error: 'Conversation not found' },
+ { status: 404 }
+ );
+ }
+
+ // Verify conversation belongs to the requesting user
+ if (conversation.userId !== session.user.id) {
+ return NextResponse.json(
+ { error: 'Conversation not found' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json(conversation);
+ } catch (error) {
+ console.error('Failed to get conversation:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch conversation' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/control-center/tools/route.ts b/app/api/v1/control-center/tools/route.ts
new file mode 100644
index 0000000..f1753cc
--- /dev/null
+++ b/app/api/v1/control-center/tools/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { createMCPClient } from '@/lib/control-center/mcp-client';
+import type { ToolDefinition } from '@/types/control-center';
+
+interface ToolsResponse {
+ tools: ToolDefinition[];
+ toolCount: number;
+ mcpStatus: {
+ connected: boolean;
+ serverUrl: string;
+ };
+}
+
+export async function GET() {
+ // Check authentication
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const mcpClient = await createMCPClient();
+
+ if (!mcpClient) {
+ return NextResponse.json({
+ tools: [],
+ toolCount: 0,
+ mcpStatus: { connected: false, serverUrl: 'Not configured' }
+ });
+ }
+
+ const isHealthy = await mcpClient.healthCheck();
+ if (!isHealthy) {
+ return NextResponse.json({
+ tools: [],
+ toolCount: 0,
+ mcpStatus: { connected: false, serverUrl: 'Server not responding' }
+ });
+ }
+
+ const tools = await mcpClient.getTools();
+
+ const response: ToolsResponse = {
+ tools,
+ toolCount: tools.length,
+ mcpStatus: { connected: true, serverUrl: 'http://localhost:8000' }
+ };
+
+ return NextResponse.json(response);
+ } catch (error) {
+ console.error('Failed to fetch tools:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch tools', details: error instanceof Error ? error.message : 'Unknown' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/v1/conversations/[conversationId]/messages/route.ts b/app/api/v1/conversations/[conversationId]/messages/route.ts
new file mode 100644
index 0000000..093fc04
--- /dev/null
+++ b/app/api/v1/conversations/[conversationId]/messages/route.ts
@@ -0,0 +1,29 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ conversationId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { conversationId } = await params;
+ const { searchParams } = new URL(request.url);
+ const limit = parseInt(searchParams.get('limit') || '50');
+
+ try {
+ const messages = await ghl.conversations.getMessages(conversationId, { limit });
+ return NextResponse.json(messages);
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to fetch messages' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/conversations/[conversationId]/route.ts b/app/api/v1/conversations/[conversationId]/route.ts
new file mode 100644
index 0000000..091d3a0
--- /dev/null
+++ b/app/api/v1/conversations/[conversationId]/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ conversationId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { conversationId } = await params;
+
+ try {
+ const conversation = await ghl.conversations.getById(conversationId);
+ return NextResponse.json(conversation);
+ } catch (error) {
+ return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ conversationId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { conversationId } = await params;
+
+ try {
+ await ghl.conversations.delete(conversationId);
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/conversations/[conversationId]/status/route.ts b/app/api/v1/conversations/[conversationId]/status/route.ts
new file mode 100644
index 0000000..28d9856
--- /dev/null
+++ b/app/api/v1/conversations/[conversationId]/status/route.ts
@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+const statusSchema = z.object({
+ status: z.enum(['read', 'unread']).optional(),
+ starred: z.boolean().optional(),
+});
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ conversationId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { conversationId } = await params;
+
+ try {
+ const body = await request.json();
+ const validated = statusSchema.parse(body);
+
+ if (validated.status === 'read') {
+ await ghl.conversations.markAsRead(conversationId);
+ } else if (validated.status === 'unread') {
+ await ghl.conversations.markAsUnread(conversationId);
+ }
+
+ if (validated.starred === true) {
+ await ghl.conversations.star(conversationId);
+ } else if (validated.starred === false) {
+ await ghl.conversations.unstar(conversationId);
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to update status' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/conversations/route.ts b/app/api/v1/conversations/route.ts
new file mode 100644
index 0000000..1e0ad97
--- /dev/null
+++ b/app/api/v1/conversations/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const limit = parseInt(searchParams.get('limit') || '20');
+ const status = searchParams.get('status') as 'all' | 'read' | 'unread' | 'starred' || 'all';
+ const contactId = searchParams.get('contactId') || undefined;
+
+ try {
+ const conversations = await ghl.conversations.getAll({
+ limit,
+ status,
+ contactId,
+ });
+ return NextResponse.json(conversations);
+ } catch (error) {
+ console.error('Failed to get conversations:', error);
+ return NextResponse.json({ error: 'Failed to fetch conversations' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/conversations/send/route.ts b/app/api/v1/conversations/send/route.ts
new file mode 100644
index 0000000..89cb7f7
--- /dev/null
+++ b/app/api/v1/conversations/send/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+const sendSMSSchema = z.object({
+ type: z.literal('SMS'),
+ contactId: z.string(),
+ message: z.string().min(1),
+});
+
+const sendEmailSchema = z.object({
+ type: z.literal('EMAIL'),
+ contactId: z.string(),
+ subject: z.string().min(1),
+ htmlBody: z.string().min(1),
+ fromName: z.string().optional(),
+ replyTo: z.string().email().optional(),
+});
+
+const sendMessageSchema = z.discriminatedUnion('type', [sendSMSSchema, sendEmailSchema]);
+
+export async function POST(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ try {
+ const body = await request.json();
+ const validated = sendMessageSchema.parse(body);
+
+ let message;
+ if (validated.type === 'SMS') {
+ message = await ghl.conversations.sendSMS({
+ contactId: validated.contactId,
+ message: validated.message,
+ });
+ } else {
+ message = await ghl.conversations.sendEmail({
+ contactId: validated.contactId,
+ subject: validated.subject,
+ htmlBody: validated.htmlBody,
+ fromName: validated.fromName,
+ replyTo: validated.replyTo,
+ });
+ }
+
+ return NextResponse.json(message, { status: 201 });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Validation failed', details: error.issues }, { status: 400 });
+ }
+ console.error('Failed to send message:', error);
+ return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/dashboard/recommendations/route.ts b/app/api/v1/dashboard/recommendations/route.ts
new file mode 100644
index 0000000..4c13375
--- /dev/null
+++ b/app/api/v1/dashboard/recommendations/route.ts
@@ -0,0 +1,297 @@
+import { NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { createMCPClient } from '@/lib/control-center/mcp-client';
+
+export interface Recommendation {
+ id: string;
+ type: 'follow_up' | 'stale_lead' | 'pipeline_stuck' | 'campaign_response' | 'no_response';
+ priority: 'high' | 'medium' | 'low';
+ title: string;
+ description: string;
+ actionLabel: string;
+ actionUrl: string;
+ contactId?: string;
+ contactName?: string;
+ daysOverdue?: number;
+ pipelineStage?: string;
+}
+
+interface RecommendationsResponse {
+ recommendations: Recommendation[];
+ generatedAt: string;
+}
+
+// Fallback recommendations shown when MCP isn't available or returns no data
+function getFallbackRecommendations(): Recommendation[] {
+ return [
+ {
+ id: 'fallback-import-contacts',
+ type: 'follow_up',
+ priority: 'high',
+ title: 'Import your contacts',
+ description: 'Get started by importing your existing contacts from a CSV or CRM',
+ actionLabel: 'Import Now',
+ actionUrl: '/contacts',
+ },
+ {
+ id: 'fallback-configure-sms',
+ type: 'follow_up',
+ priority: 'medium',
+ title: 'Set up SMS messaging',
+ description: 'Configure two-way SMS to reach contacts via text',
+ actionLabel: 'Configure',
+ actionUrl: '/settings/sms',
+ },
+ {
+ id: 'fallback-create-pipeline',
+ type: 'pipeline_stuck',
+ priority: 'medium',
+ title: 'Create your first pipeline',
+ description: 'Track deals from lead to close with custom stages',
+ actionLabel: 'Create Pipeline',
+ actionUrl: '/opportunities',
+ },
+ {
+ id: 'fallback-explore-control-center',
+ type: 'campaign_response',
+ priority: 'low',
+ title: 'Explore AI Control Center',
+ description: 'Use AI to analyze your CRM data and get insights',
+ actionLabel: 'Explore',
+ actionUrl: '/control-center',
+ },
+ ];
+}
+
+export async function GET() {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const mcpClient = await createMCPClient();
+
+ if (!mcpClient) {
+ // Return fallback recommendations if MCP not configured
+ return NextResponse.json({
+ recommendations: getFallbackRecommendations(),
+ generatedAt: new Date().toISOString(),
+ });
+ }
+
+ const isHealthy = await mcpClient.healthCheck();
+ if (!isHealthy) {
+ return NextResponse.json({
+ recommendations: getFallbackRecommendations(),
+ generatedAt: new Date().toISOString(),
+ });
+ }
+
+ // Ensure connected for tool execution
+ await mcpClient.connect();
+
+ const recommendations: Recommendation[] = [];
+
+ // 1. Get conversations and check for ones needing follow-up
+ try {
+ const conversationsResult = await mcpClient.executeTool('search_conversations', {
+ limit: 50,
+ });
+
+ if (conversationsResult.success && conversationsResult.result) {
+ const data = conversationsResult.result as any;
+ const conversations = data?.conversations || data || [];
+
+ for (const conv of (Array.isArray(conversations) ? conversations : []).slice(0, 15)) {
+ // Check if last message was from us and no response
+ const lastMessageAt = conv.lastMessageDate || conv.dateUpdated || conv.updatedAt;
+ const lastDate = lastMessageAt ? new Date(lastMessageAt) : null;
+ const daysSinceMessage = lastDate
+ ? Math.floor((Date.now() - lastDate.getTime()) / (1000 * 60 * 60 * 24))
+ : null;
+
+ const lastMessageDirection = conv.lastMessageDirection || conv.lastMessageType || '';
+ const isOutbound = lastMessageDirection === 'outbound' || lastMessageDirection === 'out';
+
+ if (daysSinceMessage && daysSinceMessage >= 7 && isOutbound) {
+ const contactName = conv.contactName || conv.fullName || conv.name || 'contact';
+ recommendations.push({
+ id: `no-response-${conv.id}`,
+ type: 'no_response',
+ priority: daysSinceMessage >= 14 ? 'high' : 'medium',
+ title: `Follow up with ${contactName}`,
+ description: `No response in ${daysSinceMessage} days since your last message`,
+ actionLabel: 'Send Follow-up',
+ actionUrl: `/conversations/${conv.id}`,
+ contactId: conv.contactId,
+ contactName: contactName,
+ daysOverdue: daysSinceMessage,
+ });
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('[Recommendations] Failed to fetch conversations:', e);
+ }
+
+ // 2. Get opportunities stuck in pipeline stages
+ try {
+ const oppsResult = await mcpClient.executeTool('search_opportunities', {
+ status: 'open',
+ limit: 30,
+ });
+
+ if (oppsResult.success && oppsResult.result) {
+ const data = oppsResult.result as any;
+ const opportunities = data?.opportunities || data || [];
+
+ for (const opp of (Array.isArray(opportunities) ? opportunities : []).slice(0, 20)) {
+ const stageEnteredAt = opp.lastStageChangeAt || opp.stageEnteredAt || opp.updatedAt || opp.dateUpdated;
+ const stageDate = stageEnteredAt ? new Date(stageEnteredAt) : null;
+ const daysInStage = stageDate
+ ? Math.floor((Date.now() - stageDate.getTime()) / (1000 * 60 * 60 * 24))
+ : null;
+
+ // Flag opportunities stuck in same stage for 14+ days
+ if (daysInStage && daysInStage >= 14) {
+ const oppName = opp.name || opp.title || 'Opportunity';
+ const stageName = opp.pipelineStage || opp.stageName || opp.stage || 'current stage';
+ recommendations.push({
+ id: `pipeline-stuck-${opp.id}`,
+ type: 'pipeline_stuck',
+ priority: daysInStage >= 30 ? 'high' : 'medium',
+ title: `Move "${oppName}" forward`,
+ description: `Stuck in "${stageName}" for ${daysInStage} days`,
+ actionLabel: 'Review Deal',
+ actionUrl: `/opportunities/${opp.id}`,
+ contactId: opp.contactId,
+ contactName: opp.contactName || opp.contact?.name,
+ daysOverdue: daysInStage,
+ pipelineStage: stageName,
+ });
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('[Recommendations] Failed to fetch opportunities:', e);
+ }
+
+ // 3. Get email campaigns (optional - may not have data)
+ try {
+ const campaignsResult = await mcpClient.executeTool('get_email_campaigns', {
+ limit: 10,
+ });
+
+ if (campaignsResult.success && campaignsResult.result) {
+ const data = campaignsResult.result as any;
+ const campaigns = data?.campaigns || data || [];
+
+ for (const campaign of (Array.isArray(campaigns) ? campaigns : []).slice(0, 5)) {
+ // Check for campaigns with good open/click rates that might need follow-up
+ const opens = campaign.opens || campaign.openCount || 0;
+ const clicks = campaign.clicks || campaign.clickCount || 0;
+
+ if (opens > 0 || clicks > 0) {
+ recommendations.push({
+ id: `campaign-engagement-${campaign.id}`,
+ type: 'campaign_response',
+ priority: clicks > 5 ? 'high' : 'medium',
+ title: `"${campaign.name || 'Campaign'}" has engagement`,
+ description: `${opens} opens, ${clicks} clicks - follow up with engaged leads`,
+ actionLabel: 'View Campaign',
+ actionUrl: `/campaigns/${campaign.id}`,
+ });
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('[Recommendations] Failed to fetch campaigns:', e);
+ }
+
+ // 4. Get contacts and find ones needing outreach
+ try {
+ const contactsResult = await mcpClient.executeTool('search_contacts', {
+ limit: 30,
+ });
+
+ if (contactsResult.success && contactsResult.result) {
+ const data = contactsResult.result as any;
+ const contacts = data?.contacts || data || [];
+
+ for (const contact of (Array.isArray(contacts) ? contacts : []).slice(0, 20)) {
+ const dateAdded = contact.dateAdded || contact.createdAt || contact.dateCreated;
+ const addedDate = dateAdded ? new Date(dateAdded) : null;
+ const daysSinceAdded = addedDate
+ ? Math.floor((Date.now() - addedDate.getTime()) / (1000 * 60 * 60 * 24))
+ : null;
+
+ // Check if contact was added in last 14 days and has no conversation
+ const hasConversation = contact.lastMessageDate || contact.lastConversationDate;
+
+ if (daysSinceAdded !== null && daysSinceAdded <= 14 && !hasConversation) {
+ const firstName = contact.firstName || contact.name?.split(' ')[0] || '';
+ const lastName = contact.lastName || '';
+ const displayName = firstName || contact.email || 'new contact';
+
+ recommendations.push({
+ id: `new-contact-${contact.id}`,
+ type: 'follow_up',
+ priority: daysSinceAdded <= 3 ? 'high' : 'medium',
+ title: `Reach out to ${displayName}`,
+ description: `Added ${getRelativeTime(dateAdded)} - no outreach yet`,
+ actionLabel: 'Start Conversation',
+ actionUrl: `/contacts/${contact.id}`,
+ contactId: contact.id,
+ contactName: firstName ? `${firstName} ${lastName}`.trim() : contact.email,
+ });
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('[Recommendations] Failed to fetch contacts:', e);
+ }
+
+ // Sort by priority and limit to top 4
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
+ recommendations.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
+ const topRecommendations = recommendations.slice(0, 4);
+
+ // If no recommendations from MCP data, return fallback recommendations
+ const finalRecommendations = topRecommendations.length > 0
+ ? topRecommendations
+ : getFallbackRecommendations();
+
+ const response: RecommendationsResponse = {
+ recommendations: finalRecommendations,
+ generatedAt: new Date().toISOString(),
+ };
+
+ return NextResponse.json(response);
+ } catch (error) {
+ console.error('[Recommendations] Error:', error);
+ // Return fallback recommendations on error instead of failing
+ return NextResponse.json({
+ recommendations: getFallbackRecommendations(),
+ generatedAt: new Date().toISOString(),
+ });
+ }
+}
+
+function getRelativeTime(dateString: string | number | undefined): string {
+ if (!dateString) return 'recently';
+
+ const date = typeof dateString === 'number'
+ ? new Date(dateString * 1000)
+ : new Date(dateString);
+
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) return 'today';
+ if (diffDays === 1) return 'yesterday';
+ if (diffDays < 7) return `${diffDays} days ago`;
+ if (diffDays < 14) return 'last week';
+ return `${Math.floor(diffDays / 7)} weeks ago`;
+}
diff --git a/app/api/v1/dashboard/stats/route.ts b/app/api/v1/dashboard/stats/route.ts
new file mode 100644
index 0000000..4f5877a
--- /dev/null
+++ b/app/api/v1/dashboard/stats/route.ts
@@ -0,0 +1,70 @@
+import { NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth/session';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET() {
+ try {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { user } = session;
+
+ // Try to get real stats from GHL if configured
+ const ghlClient = await getGHLClientForUser(user.id);
+
+ if (ghlClient) {
+ try {
+ // Fetch real data from GHL
+ const [contactsResponse, conversationsResponse, opportunitiesResponse] = await Promise.allSettled([
+ ghlClient.contacts.getAll({ limit: 1 }), // Just to get count from meta
+ ghlClient.conversations.getAll({ limit: 1 }),
+ ghlClient.opportunities.getAll({ limit: 100 }),
+ ]);
+
+ let totalContacts = 0;
+ let activeConversations = 0;
+ let openOpportunities = 0;
+ let pipelineValue = 0;
+
+ if (contactsResponse.status === 'fulfilled') {
+ totalContacts = contactsResponse.value.meta?.total || 0;
+ }
+
+ if (conversationsResponse.status === 'fulfilled') {
+ activeConversations = conversationsResponse.value.data?.length || 0;
+ }
+
+ if (opportunitiesResponse.status === 'fulfilled') {
+ const opportunities = opportunitiesResponse.value.data || [];
+ openOpportunities = opportunities.filter((o: any) => o.status === 'open').length;
+ pipelineValue = opportunities.reduce((sum: number, o: any) => {
+ return sum + (parseFloat(o.monetaryValue) || 0);
+ }, 0);
+ }
+
+ return NextResponse.json({
+ totalContacts,
+ activeConversations,
+ openOpportunities,
+ pipelineValue,
+ });
+ } catch (ghlError) {
+ console.error('GHL fetch error:', ghlError);
+ // Fall through to return mock stats
+ }
+ }
+
+ // Return mock stats for users without GHL configured
+ return NextResponse.json({
+ totalContacts: 0,
+ activeConversations: 0,
+ openOpportunities: 0,
+ pipelineValue: 0,
+ });
+ } catch (error) {
+ console.error('Dashboard stats error:', error);
+ return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/onboarding/route.ts b/app/api/v1/onboarding/route.ts
new file mode 100644
index 0000000..006253b
--- /dev/null
+++ b/app/api/v1/onboarding/route.ts
@@ -0,0 +1,96 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { prisma } from '@/lib/db';
+import { getSession } from '@/lib/auth';
+import { adminTaggingService } from '@/lib/ghl';
+
+const onboardingSchema = z.object({
+ yearsInBusiness: z.string().optional(),
+ gciLast12Months: z.string().optional(),
+ usingOtherCrm: z.boolean().optional(),
+ currentCrmName: z.string().optional(),
+ crmPainPoints: z.array(z.string()).optional(),
+ goalsSelected: z.array(z.string()).optional(),
+ hasLeadSource: z.boolean().optional(),
+ leadSourceName: z.string().optional(),
+ wantsMoreLeads: z.boolean().optional(),
+ leadTypesDesired: z.array(z.string()).optional(),
+ leadsPerMonthTarget: z.string().optional(),
+ channelsSelected: z.array(z.string()).optional(),
+ systemsToConnect: z.array(z.string()).optional(),
+});
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const onboarding = await prisma.onboardingData.findUnique({
+ where: { userId: session.user.id },
+ });
+
+ return NextResponse.json({ onboarding });
+}
+
+export async function POST(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const validated = onboardingSchema.parse(body);
+
+ // Convert arrays to JSON strings for Prisma (schema stores arrays as JSON strings)
+ const prismaData = {
+ ...validated,
+ crmPainPoints: validated.crmPainPoints ? JSON.stringify(validated.crmPainPoints) : undefined,
+ goalsSelected: validated.goalsSelected ? JSON.stringify(validated.goalsSelected) : undefined,
+ leadTypesDesired: validated.leadTypesDesired ? JSON.stringify(validated.leadTypesDesired) : undefined,
+ channelsSelected: validated.channelsSelected ? JSON.stringify(validated.channelsSelected) : undefined,
+ systemsToConnect: validated.systemsToConnect ? JSON.stringify(validated.systemsToConnect) : undefined,
+ };
+
+ // Upsert onboarding data
+ const onboarding = await prisma.onboardingData.upsert({
+ where: { userId: session.user.id },
+ update: prismaData,
+ create: {
+ userId: session.user.id,
+ ...prismaData,
+ },
+ });
+
+ // Update user profile with brokerage if provided
+ if (body.brokerageName) {
+ await prisma.user.update({
+ where: { id: session.user.id },
+ data: { brokerage: body.brokerageName },
+ });
+ }
+
+ // Tag user in owner's account for onboarding complete
+ adminTaggingService.tagOnboardingComplete(session.user.id)
+ .catch(err => console.error('Failed to tag onboarding complete:', err));
+
+ // If high GCI, tag them
+ const gci = validated.gciLast12Months || '';
+ if (gci.includes('100') || gci.includes('250')) {
+ adminTaggingService.tagHighGCI(session.user.id, gci)
+ .catch(err => console.error('Failed to tag high GCI:', err));
+ }
+
+ // Sync all user data to owner
+ adminTaggingService.syncUserToOwner(session.user.id)
+ .catch(err => console.error('Failed to sync user to owner:', err));
+
+ return NextResponse.json({ success: true, onboarding });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
+ }
+ return NextResponse.json({ error: 'Failed to save onboarding' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/onboarding/status/route.ts b/app/api/v1/onboarding/status/route.ts
new file mode 100644
index 0000000..1eb5201
--- /dev/null
+++ b/app/api/v1/onboarding/status/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { prisma } from '@/lib/db';
+import { getSession } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const status = await prisma.setupStatus.findUnique({
+ where: { userId: session.user.id },
+ });
+
+ return NextResponse.json({ status: status || {
+ smsConfigured: false,
+ emailConfigured: false,
+ contactsImported: false,
+ campaignsSetup: false,
+ }});
+}
+
+const updateStatusSchema = z.object({
+ smsConfigured: z.boolean().optional(),
+ emailConfigured: z.boolean().optional(),
+ contactsImported: z.boolean().optional(),
+ campaignsSetup: z.boolean().optional(),
+});
+
+export async function PUT(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const validated = updateStatusSchema.parse(body);
+
+ const status = await prisma.setupStatus.upsert({
+ where: { userId: session.user.id },
+ update: validated,
+ create: {
+ userId: session.user.id,
+ ...validated,
+ },
+ });
+
+ return NextResponse.json({ success: true, status });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to update status' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/opportunities/[opportunityId]/route.ts b/app/api/v1/opportunities/[opportunityId]/route.ts
new file mode 100644
index 0000000..afc83c6
--- /dev/null
+++ b/app/api/v1/opportunities/[opportunityId]/route.ts
@@ -0,0 +1,89 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ opportunityId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { opportunityId } = await params;
+
+ try {
+ const opportunity = await ghl.opportunities.getById(opportunityId);
+ return NextResponse.json(opportunity);
+ } catch (error) {
+ return NextResponse.json({ error: 'Opportunity not found' }, { status: 404 });
+ }
+}
+
+const updateOpportunitySchema = z.object({
+ name: z.string().optional(),
+ pipelineStageId: z.string().optional(),
+ monetaryValue: z.number().optional(),
+ status: z.enum(['open', 'won', 'lost', 'abandoned']).optional(),
+});
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ opportunityId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { opportunityId } = await params;
+
+ try {
+ const body = await request.json();
+ const validated = updateOpportunitySchema.parse(body);
+
+ const opportunity = await ghl.opportunities.update(opportunityId, validated);
+ return NextResponse.json(opportunity);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
+ }
+ return NextResponse.json({ error: 'Failed to update opportunity' }, { status: 500 });
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ opportunityId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { opportunityId } = await params;
+
+ try {
+ await ghl.opportunities.delete(opportunityId);
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to delete opportunity' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/opportunities/pipelines/[pipelineId]/route.ts b/app/api/v1/opportunities/pipelines/[pipelineId]/route.ts
new file mode 100644
index 0000000..ec6b292
--- /dev/null
+++ b/app/api/v1/opportunities/pipelines/[pipelineId]/route.ts
@@ -0,0 +1,83 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ pipelineId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { pipelineId } = await params;
+
+ try {
+ const pipeline = await ghl.pipelines.getById(pipelineId);
+ return NextResponse.json(pipeline);
+ } catch (error) {
+ return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 });
+ }
+}
+
+const updatePipelineSchema = z.object({
+ name: z.string().optional(),
+});
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ pipelineId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { pipelineId } = await params;
+
+ try {
+ const body = await request.json();
+ const validated = updatePipelineSchema.parse(body);
+
+ const pipeline = await ghl.pipelines.update(pipelineId, validated);
+ return NextResponse.json(pipeline);
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to update pipeline' }, { status: 500 });
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ pipelineId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { pipelineId } = await params;
+
+ try {
+ await ghl.pipelines.delete(pipelineId);
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to delete pipeline' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/opportunities/pipelines/[pipelineId]/stages/route.ts b/app/api/v1/opportunities/pipelines/[pipelineId]/stages/route.ts
new file mode 100644
index 0000000..39ccf63
--- /dev/null
+++ b/app/api/v1/opportunities/pipelines/[pipelineId]/stages/route.ts
@@ -0,0 +1,60 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ pipelineId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { pipelineId } = await params;
+
+ try {
+ const stages = await ghl.pipelines.getStages(pipelineId);
+ return NextResponse.json({ stages });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to fetch stages' }, { status: 500 });
+ }
+}
+
+const addStageSchema = z.object({
+ name: z.string().min(1),
+ position: z.number().optional(),
+});
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ pipelineId: string }> }
+) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { pipelineId } = await params;
+
+ try {
+ const body = await request.json();
+ const validated = addStageSchema.parse(body);
+
+ const stage = await ghl.pipelines.addStage(pipelineId, validated.name, validated.position);
+ return NextResponse.json(stage, { status: 201 });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to add stage' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/opportunities/pipelines/route.ts b/app/api/v1/opportunities/pipelines/route.ts
new file mode 100644
index 0000000..4f7be58
--- /dev/null
+++ b/app/api/v1/opportunities/pipelines/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ try {
+ const pipelines = await ghl.pipelines.getAll();
+ return NextResponse.json({ pipelines });
+ } catch (error) {
+ console.error('Failed to get pipelines:', error);
+ return NextResponse.json({ error: 'Failed to fetch pipelines' }, { status: 500 });
+ }
+}
+
+const createPipelineSchema = z.object({
+ name: z.string().min(1),
+ stages: z.array(z.object({ name: z.string() })).min(1),
+});
+
+export async function POST(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ try {
+ const body = await request.json();
+ const validated = createPipelineSchema.parse(body);
+
+ const pipeline = await ghl.pipelines.create(validated);
+ return NextResponse.json(pipeline, { status: 201 });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
+ }
+ return NextResponse.json({ error: 'Failed to create pipeline' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/opportunities/route.ts b/app/api/v1/opportunities/route.ts
new file mode 100644
index 0000000..645eb8b
--- /dev/null
+++ b/app/api/v1/opportunities/route.ts
@@ -0,0 +1,70 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getSession } from '@/lib/auth';
+import { getGHLClientForUser } from '@/lib/ghl/helpers';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const pipelineId = searchParams.get('pipelineId') || undefined;
+ const pipelineStageId = searchParams.get('stageId') || undefined;
+ const status = searchParams.get('status') as 'open' | 'won' | 'lost' | 'abandoned' | 'all' || undefined;
+ const limit = parseInt(searchParams.get('limit') || '50');
+
+ try {
+ const opportunities = await ghl.opportunities.getAll({
+ pipelineId,
+ pipelineStageId,
+ status,
+ limit,
+ });
+ return NextResponse.json(opportunities);
+ } catch (error) {
+ console.error('Failed to get opportunities:', error);
+ return NextResponse.json({ error: 'Failed to fetch opportunities' }, { status: 500 });
+ }
+}
+
+const createOpportunitySchema = z.object({
+ name: z.string().min(1),
+ pipelineId: z.string(),
+ pipelineStageId: z.string(),
+ contactId: z.string(),
+ monetaryValue: z.number().optional(),
+ status: z.enum(['open', 'won', 'lost', 'abandoned']).optional(),
+});
+
+export async function POST(request: NextRequest) {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ghl = await getGHLClientForUser(session.user.id);
+ if (!ghl) {
+ return NextResponse.json({ error: 'GHL not configured' }, { status: 400 });
+ }
+
+ try {
+ const body = await request.json();
+ const validated = createOpportunitySchema.parse(body);
+
+ const opportunity = await ghl.opportunities.create(validated);
+ return NextResponse.json(opportunity, { status: 201 });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Validation failed', details: error.issues }, { status: 400 });
+ }
+ console.error('Failed to create opportunity:', error);
+ return NextResponse.json({ error: 'Failed to create opportunity' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/realtime/events/route.ts b/app/api/v1/realtime/events/route.ts
new file mode 100644
index 0000000..56c3ec0
--- /dev/null
+++ b/app/api/v1/realtime/events/route.ts
@@ -0,0 +1,74 @@
+import { NextRequest } from 'next/server';
+import { verifyToken } from '@/lib/auth/jwt';
+import { broadcaster } from '@/lib/realtime/broadcaster';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+// Helper to get user from request
+function getUserFromRequest(request: NextRequest): { userId: string; email: string } | null {
+ const authHeader = request.headers.get('authorization');
+ const token = authHeader?.replace('Bearer ', '');
+
+ if (!token) return null;
+
+ try {
+ const payload = verifyToken(token);
+ return { userId: payload.userId, email: payload.email };
+ } catch {
+ return null;
+ }
+}
+
+export async function GET(request: NextRequest) {
+ const user = getUserFromRequest(request);
+
+ if (!user) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const encoder = new TextEncoder();
+
+ const stream = new ReadableStream({
+ start(controller) {
+ // Send initial connection message
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId: user.userId })}\n\n`)
+ );
+
+ // Subscribe to user's events
+ const unsubscribe = broadcaster.subscribe(user.userId, (message) => {
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
+ );
+ });
+
+ // Also subscribe to global events
+ const unsubscribeGlobal = broadcaster.subscribe('global', (message) => {
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
+ );
+ });
+
+ // Keep connection alive with heartbeat
+ const heartbeat = setInterval(() => {
+ controller.enqueue(encoder.encode(`: heartbeat\n\n`));
+ }, 30000);
+
+ // Cleanup on close
+ request.signal.addEventListener('abort', () => {
+ clearInterval(heartbeat);
+ unsubscribe();
+ unsubscribeGlobal();
+ });
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ },
+ });
+}
diff --git a/app/api/v1/stripe/checkout/route.ts b/app/api/v1/stripe/checkout/route.ts
new file mode 100644
index 0000000..6f8d25e
--- /dev/null
+++ b/app/api/v1/stripe/checkout/route.ts
@@ -0,0 +1,40 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { createDFYCheckoutSession } from '@/lib/stripe/checkout';
+import { z } from 'zod';
+
+const checkoutSchema = z.object({
+ productId: z.string(),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const { productId } = checkoutSchema.parse(body);
+
+ const baseUrl = request.nextUrl.origin;
+
+ const checkoutSession = await createDFYCheckoutSession({
+ userId: session.user.id,
+ productId,
+ successUrl: `${baseUrl}/dfy/success?session_id={CHECKOUT_SESSION_ID}`,
+ cancelUrl: `${baseUrl}/dfy/cancel`,
+ });
+
+ return NextResponse.json({
+ checkoutUrl: checkoutSession.url,
+ sessionId: checkoutSession.id,
+ });
+ } catch (error) {
+ console.error('Checkout error:', error);
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
+ }
+ return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/stripe/webhook/route.ts b/app/api/v1/stripe/webhook/route.ts
new file mode 100644
index 0000000..36c7422
--- /dev/null
+++ b/app/api/v1/stripe/webhook/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getStripeClient } from '@/lib/stripe/client';
+import { handleCheckoutComplete } from '@/lib/stripe/checkout';
+import { settingsService } from '@/lib/settings/settings-service';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.text();
+ const signature = request.headers.get('stripe-signature');
+
+ if (!signature) {
+ return NextResponse.json({ error: 'No signature' }, { status: 400 });
+ }
+
+ const settings = await settingsService.getAll();
+ if (!settings.stripeWebhookSecret) {
+ console.error('Stripe webhook secret not configured');
+ return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 });
+ }
+
+ const stripe = await getStripeClient();
+ const event = stripe.webhooks.constructEvent(
+ body,
+ signature,
+ settings.stripeWebhookSecret
+ );
+
+ switch (event.type) {
+ case 'checkout.session.completed': {
+ const session = event.data.object;
+ if (session.metadata?.type === 'dfy_service') {
+ await handleCheckoutComplete(session.id);
+ }
+ break;
+ }
+
+ case 'payment_intent.payment_failed': {
+ const paymentIntent = event.data.object;
+ console.log('Payment failed:', paymentIntent.id);
+ break;
+ }
+ }
+
+ return NextResponse.json({ received: true });
+ } catch (error) {
+ console.error('Webhook error:', error);
+ return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/v1/users/route.ts b/app/api/v1/users/route.ts
new file mode 100644
index 0000000..ee25e27
--- /dev/null
+++ b/app/api/v1/users/route.ts
@@ -0,0 +1,83 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma } from '@/lib/db';
+import { getSession, isAdmin } from '@/lib/auth';
+import { Role } from '@/types';
+
+export async function GET(request: NextRequest) {
+ const session = await getSession();
+
+ if (!session || !isAdmin(session.user.role as Role)) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const page = parseInt(searchParams.get('page') || '1');
+ const limit = parseInt(searchParams.get('limit') || '20');
+ const search = searchParams.get('search') || '';
+ const filter = searchParams.get('filter'); // 'high-gci', 'incomplete', 'complete'
+
+ const where: any = {};
+
+ if (search) {
+ where.OR = [
+ { email: { contains: search, mode: 'insensitive' } },
+ { firstName: { contains: search, mode: 'insensitive' } },
+ { lastName: { contains: search, mode: 'insensitive' } },
+ { brokerage: { contains: search, mode: 'insensitive' } },
+ ];
+ }
+
+ const [users, total] = await Promise.all([
+ prisma.user.findMany({
+ where,
+ include: {
+ onboarding: true,
+ setupStatus: true,
+ },
+ skip: (page - 1) * limit,
+ take: limit,
+ orderBy: { createdAt: 'desc' },
+ }),
+ prisma.user.count({ where }),
+ ]);
+
+ // Apply post-filters
+ let filteredUsers = users;
+ if (filter === 'high-gci') {
+ filteredUsers = users.filter(u =>
+ u.onboarding?.gciLast12Months?.includes('100') ||
+ u.onboarding?.gciLast12Months?.includes('250')
+ );
+ } else if (filter === 'incomplete') {
+ filteredUsers = users.filter(u =>
+ !u.setupStatus?.smsConfigured ||
+ !u.setupStatus?.emailConfigured
+ );
+ } else if (filter === 'complete') {
+ filteredUsers = users.filter(u =>
+ u.setupStatus?.smsConfigured &&
+ u.setupStatus?.emailConfigured
+ );
+ }
+
+ return NextResponse.json({
+ users: filteredUsers.map(u => ({
+ id: u.id,
+ email: u.email,
+ firstName: u.firstName,
+ lastName: u.lastName,
+ brokerage: u.brokerage,
+ role: u.role,
+ ghlLocationId: u.ghlLocationId,
+ onboarding: u.onboarding,
+ setupStatus: u.setupStatus,
+ createdAt: u.createdAt,
+ })),
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ },
+ });
+}
diff --git a/app/api/v1/webhooks/ghl/route.ts b/app/api/v1/webhooks/ghl/route.ts
new file mode 100644
index 0000000..61cf519
--- /dev/null
+++ b/app/api/v1/webhooks/ghl/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { handleGHLWebhook, GHLWebhookPayload } from '@/lib/ghl/webhook-handler';
+import { settingsService } from '@/lib/settings';
+import crypto from 'crypto';
+
+// Verify webhook signature from GHL
+async function verifyWebhookSignature(
+ body: string,
+ signature: string | null,
+ secret: string
+): Promise {
+ if (!signature || !secret) return false;
+
+ const expectedSignature = crypto
+ .createHmac('sha256', secret)
+ .update(body)
+ .digest('hex');
+
+ return crypto.timingSafeEqual(
+ Buffer.from(signature),
+ Buffer.from(expectedSignature)
+ );
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.text();
+ const signature = request.headers.get('x-ghl-signature');
+
+ // Optional: Verify signature if webhook secret is configured
+ const ghlWebhookSecret = await settingsService.get('ghlWebhookSecret');
+ if (ghlWebhookSecret) {
+ const isValid = await verifyWebhookSignature(body, signature, ghlWebhookSecret);
+ if (!isValid) {
+ console.error('[Webhook] Invalid signature');
+ return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
+ }
+ }
+
+ const payload: GHLWebhookPayload = JSON.parse(body);
+
+ const result = await handleGHLWebhook(payload);
+
+ return NextResponse.json(result);
+ } catch (error) {
+ console.error('[Webhook] Error:', error);
+ return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
+ }
+}
+
+// GHL may send GET requests to verify webhook URL
+export async function GET(request: NextRequest) {
+ return NextResponse.json({ status: 'ok', message: 'Webhook endpoint active' });
+}
diff --git a/app/api/v1/webhooks/route.ts b/app/api/v1/webhooks/route.ts
new file mode 100644
index 0000000..7906ae3
--- /dev/null
+++ b/app/api/v1/webhooks/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from "next/server";
+
+export async function POST(request: Request) {
+ // TODO: Implement webhook handling
+ const body = await request.json();
+
+ console.log("Webhook received:", body);
+
+ return NextResponse.json({
+ received: true,
+ timestamp: new Date().toISOString(),
+ });
+}
+
+export async function GET() {
+ return NextResponse.json({
+ message: "Webhook endpoint active",
+ status: "ok",
+ });
+}
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..4162275
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,531 @@
+@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
+@import "tailwindcss";
+
+@plugin "tailwindcss-animate";
+
+@custom-variant dark (&:is(.dark *));
+
+/*
+ CRESync - Clay Minimal Light Theme
+ Clean, soft neumorphic design with subtle depth
+*/
+
+:root {
+ /* Light clay palette */
+ --background: #E8EAEF;
+ --foreground: #1a1a2e;
+
+ /* Cards - slightly lighter for raised effect */
+ --card: #E8EAEF;
+ --card-foreground: #1a1a2e;
+
+ /* Primary - clean blue */
+ --primary: #4F46E5;
+ --primary-foreground: #ffffff;
+
+ /* Secondary */
+ --secondary: #E0E3EA;
+ --secondary-foreground: #64748b;
+
+ --muted: #D8DBE2;
+ --muted-foreground: #64748b;
+
+ /* Accent */
+ --accent: #E0E3EA;
+ --accent-foreground: #4F46E5;
+
+ --destructive: #ef4444;
+ --destructive-foreground: #ffffff;
+
+ /* Borders and inputs */
+ --border: #D0D3DA;
+ --input: #E0E3EA;
+ --ring: #4F46E5;
+
+ /* Popover */
+ --popover: #E8EAEF;
+ --popover-foreground: #1a1a2e;
+
+ /* Sidebar */
+ --sidebar: #E0E3EA;
+ --sidebar-foreground: #1a1a2e;
+ --sidebar-primary: #4F46E5;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #D8DBE2;
+ --sidebar-accent-foreground: #4F46E5;
+ --sidebar-border: #D0D3DA;
+ --sidebar-ring: #4F46E5;
+
+ /* Chart colors */
+ --chart-1: #4F46E5;
+ --chart-2: #10b981;
+ --chart-3: #f59e0b;
+ --chart-4: #ef4444;
+ --chart-5: #8b5cf6;
+
+ --radius: 1rem;
+
+ /*
+ * Clay Elevation System - 5 levels for visual hierarchy
+ * Level 0: Flat (no shadow) - base elements, nested content
+ * Level 1: Subtle - section headers, secondary containers
+ * Level 2: Standard - main content cards
+ * Level 3: Raised - interactive hover states, prominent elements
+ * Level 4: Elevated - modals, popovers, floating elements
+ */
+ --shadow-clay-0: none;
+ --shadow-clay-1: 3px 3px 6px #c5c9d1, -3px -3px 6px #ffffff;
+ --shadow-clay-2: 6px 6px 12px #bfc3cc, -6px -6px 12px #ffffff;
+ --shadow-clay-3: 8px 8px 16px #b8bcc5, -8px -8px 16px #ffffff;
+ --shadow-clay-4: 12px 12px 24px #b0b4bd, -12px -12px 24px #ffffff;
+
+ /* Inset shadows for inputs and pressed states */
+ --shadow-clay-inset: inset 4px 4px 8px #b8bcc5, inset -4px -4px 8px #ffffff;
+ --shadow-clay-pressed: inset 3px 3px 6px #c0c4cc, inset -3px -3px 6px #ffffff;
+
+ /* Legacy aliases for backward compatibility */
+ --shadow-clay: var(--shadow-clay-2);
+ --shadow-clay-sm: var(--shadow-clay-1);
+
+ /* Spacing System */
+ --space-unit: 4px;
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 12px;
+ --space-lg: 16px;
+ --space-xl: 24px;
+ --space-2xl: 32px;
+ --space-3xl: 48px;
+
+ /* Component-specific spacing */
+ --sidebar-item-padding-y: 12px;
+ --sidebar-item-padding-x: 16px;
+ --sidebar-width: 260px;
+ --sidebar-collapsed-width: 80px;
+ --card-padding: 24px;
+ --card-padding-sm: 16px;
+ --form-field-gap: 20px;
+ --table-row-height: 64px;
+ --modal-padding: 24px;
+ --button-padding-x: 16px;
+ --button-padding-y: 10px;
+ --section-margin: 32px;
+ --icon-text-gap: 10px;
+ --badge-gap: 8px;
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
+ background-color: var(--background);
+ color: var(--foreground);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Custom Scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--background);
+}
+
+::-webkit-scrollbar-thumb {
+ background: #c5c8cf;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #b0b3ba;
+}
+
+/* Selection */
+::selection {
+ background: rgba(79, 70, 229, 0.2);
+ color: #1a1a2e;
+}
+
+/*
+ * Clay Card Hierarchy
+ * - clay-card-flat: No elevation (nested elements, inline containers)
+ * - clay-card-subtle: Level 1 (section headers, secondary cards)
+ * - clay-card: Level 2 (main content cards - default)
+ * - clay-card-prominent: Level 3 (emphasized cards, important content)
+ */
+
+/* Level 0 - Flat (no elevation) */
+.clay-card-flat {
+ background: var(--background);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-0);
+ padding: 1.5rem;
+}
+
+/* Level 1 - Subtle elevation (section headers, secondary) */
+.clay-card-subtle {
+ background: var(--background);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-1);
+ padding: 1.5rem;
+ transition: box-shadow 0.2s ease;
+}
+
+/* Level 2 - Standard elevation (main content cards) */
+.clay-card {
+ background: var(--background);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-2);
+ padding: 1.5rem;
+ transition: box-shadow 0.2s ease;
+}
+
+/* Level 3 - Prominent elevation (emphasized content) */
+.clay-card-prominent {
+ background: var(--background);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-3);
+ padding: 1.5rem;
+ transition: box-shadow 0.2s ease;
+}
+
+/* Interactive cards - subtle lift on hover */
+.clay-card-interactive {
+ background: var(--background);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-2);
+ padding: 1.5rem;
+ transition: box-shadow 0.2s ease, transform 0.2s ease;
+ cursor: pointer;
+}
+
+.clay-card-interactive:hover {
+ box-shadow: var(--shadow-clay-3);
+ transform: translateY(-2px);
+}
+
+/* Clay Card Pressed/Inset */
+.clay-card-pressed {
+ background: var(--background);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-inset);
+}
+
+/* Clay Button - Level 1 elevation */
+.clay-btn {
+ background: var(--background);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-1);
+ font-weight: 600;
+ transition: all 0.2s ease;
+ border: none;
+ cursor: pointer;
+}
+
+.clay-btn:hover {
+ box-shadow: var(--shadow-clay-2);
+}
+
+.clay-btn:active {
+ box-shadow: var(--shadow-clay-pressed);
+}
+
+/* Clay Button Primary - Level 2 elevation with gradient */
+.clay-btn-primary {
+ background: linear-gradient(135deg, #4F46E5 0%, #6366f1 100%);
+ color: white;
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-2), inset 0 1px 0 rgba(255,255,255,0.2);
+ font-weight: 600;
+ transition: all 0.2s ease;
+ border: none;
+ cursor: pointer;
+}
+
+.clay-btn-primary:hover {
+ background: linear-gradient(135deg, #4338ca 0%, #4F46E5 100%);
+ box-shadow: var(--shadow-clay-3), inset 0 1px 0 rgba(255,255,255,0.2);
+ transform: translateY(-1px);
+}
+
+.clay-btn-primary:active {
+ transform: translateY(0);
+ box-shadow: var(--shadow-clay-1);
+}
+
+/* Clay Input - Inset Effect */
+.clay-input {
+ background: var(--background);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-clay-inset);
+ border: none;
+ padding: 1rem 1.25rem;
+ font-size: 0.95rem;
+ color: var(--foreground);
+ transition: all 0.2s ease;
+ width: 100%;
+}
+
+.clay-input::placeholder {
+ color: var(--muted-foreground);
+}
+
+.clay-input:focus {
+ outline: none;
+ box-shadow: inset 5px 5px 10px #b0b4bd, inset -5px -5px 10px #ffffff, 0 0 0 3px rgba(79, 70, 229, 0.2);
+}
+
+/* Clay Input with Icon */
+.clay-input-icon {
+ padding-left: 2.75rem;
+}
+
+/* Animations */
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes fadeInScale {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes slideInFromLeft {
+ from {
+ opacity: 0;
+ transform: translateX(-100%);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.animate-fade-in-up {
+ animation: fadeInUp 0.5s ease-out forwards;
+}
+
+.animate-fade-in-scale {
+ animation: fadeInScale 0.4s ease-out forwards;
+}
+
+.animate-slide-in-from-left {
+ animation: slideInFromLeft 0.3s ease-out forwards;
+}
+
+/* Stagger children */
+.stagger-children > * {
+ opacity: 0;
+ animation: fadeInUp 0.4s ease-out forwards;
+}
+
+.stagger-children > *:nth-child(1) { animation-delay: 0.05s; }
+.stagger-children > *:nth-child(2) { animation-delay: 0.1s; }
+.stagger-children > *:nth-child(3) { animation-delay: 0.15s; }
+.stagger-children > *:nth-child(4) { animation-delay: 0.2s; }
+.stagger-children > *:nth-child(5) { animation-delay: 0.25s; }
+.stagger-children > *:nth-child(6) { animation-delay: 0.3s; }
+
+/* Status Colors */
+.text-success { color: #10b981; }
+.text-warning { color: #f59e0b; }
+.text-error { color: #ef4444; }
+
+.bg-success-soft { background: rgba(16, 185, 129, 0.1); }
+.bg-warning-soft { background: rgba(245, 158, 11, 0.1); }
+.bg-error-soft { background: rgba(239, 68, 68, 0.1); }
+
+/* Badge - Minimal elevation (decorative, not interactive) */
+.clay-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.75rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ background: var(--background);
+ box-shadow: 2px 2px 4px #c8ccd4, -2px -2px 4px #ffffff;
+}
+
+.clay-badge-primary {
+ background: rgba(79, 70, 229, 0.1);
+ color: #4F46E5;
+}
+
+.clay-badge-success {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+}
+
+/* Divider */
+.clay-divider {
+ height: 2px;
+ background: linear-gradient(90deg, transparent, #d0d3da, transparent);
+ border-radius: 1px;
+}
+
+/* Avatar */
+.clay-avatar {
+ border-radius: 50%;
+ box-shadow: var(--shadow-clay-sm);
+ background: linear-gradient(135deg, #4F46E5 0%, #6366f1 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: 600;
+}
+
+/* Icon Button - Level 1 elevation */
+.clay-icon-btn {
+ background: var(--background);
+ border-radius: 0.75rem;
+ box-shadow: var(--shadow-clay-1);
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ color: var(--muted-foreground);
+}
+
+.clay-icon-btn:hover {
+ color: var(--primary);
+ box-shadow: var(--shadow-clay-2);
+}
+
+.clay-icon-btn:active {
+ box-shadow: var(--shadow-clay-pressed);
+}
+
+/* Progress Bar */
+.clay-progress {
+ background: var(--background);
+ border-radius: 9999px;
+ box-shadow: var(--shadow-clay-inset);
+ height: 8px;
+ overflow: hidden;
+}
+
+.clay-progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, #4F46E5, #6366f1);
+ border-radius: 9999px;
+ transition: width 0.5s ease;
+}
+
+/* Nav Link */
+.clay-nav-link {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius);
+ color: var(--muted-foreground);
+ font-weight: 500;
+ transition: all 0.2s ease;
+}
+
+.clay-nav-link:hover {
+ color: var(--foreground);
+ background: rgba(255, 255, 255, 0.5);
+}
+
+.clay-nav-link.active {
+ color: var(--primary);
+ background: var(--background);
+ box-shadow: var(--shadow-clay-1);
+}
+
+/* Focus ring */
+*:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
+/* Base layer for shadcn */
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Override shadcn button */
+.bg-primary {
+ background: linear-gradient(135deg, #4F46E5 0%, #6366f1 100%) !important;
+}
+
+.text-primary {
+ color: #4F46E5 !important;
+}
+
+.border-primary {
+ border-color: #4F46E5 !important;
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..afdaa08
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from 'next';
+import { Inter } from 'next/font/google';
+import './globals.css';
+
+const inter = Inter({ subsets: ['latin'] });
+
+export const metadata: Metadata = {
+ title: 'CRESync - Commercial Real Estate CRM',
+ description: 'Streamline your commercial real estate business with CRESync',
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..aca1472
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,119 @@
+import Link from "next/link";
+import { ArrowRight, BarChart3, Users, Zap, Shield } from "lucide-react";
+
+export default function LandingPage() {
+ return (
+
+ {/* Navigation */}
+
+
+
+
+ CRESync
+
+
+
+ Sign In
+
+
+ Get Started
+
+
+
+
+
+
+
+ {/* Hero Section */}
+
+
+
+ The CRM Built for
+ Commercial Real Estate
+
+
+ Streamline your deals, automate follow-ups, and close more transactions
+ with the all-in-one platform designed specifically for CRE professionals.
+
+
+
+ Start Free Trial
+
+
+
+ Sign In to Dashboard
+
+
+
+
+
+ {/* Features Section */}
+
+
+
+ Everything You Need to Close More Deals
+
+
+ }
+ title="Contact Management"
+ description="Organize leads, clients, and prospects with smart tagging and segmentation."
+ />
+ }
+ title="Automation"
+ description="Set up automated follow-up sequences and never miss an opportunity."
+ />
+ }
+ title="Analytics"
+ description="Track your pipeline, conversion rates, and team performance."
+ />
+ }
+ title="Integrations"
+ description="Connect with your existing tools and data sources seamlessly."
+ />
+
+
+
+
+ {/* Footer */}
+
+
+ );
+}
+
+function FeatureCard({
+ icon,
+ title,
+ description,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+}) {
+ return (
+
+
{icon}
+
{title}
+
{description}
+
+ );
+}
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..6bb595e
--- /dev/null
+++ b/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/components/AdminView.tsx b/components/AdminView.tsx
new file mode 100644
index 0000000..ae79a01
--- /dev/null
+++ b/components/AdminView.tsx
@@ -0,0 +1,428 @@
+import React, { useState, useMemo } from 'react';
+import { ClayCard } from './ClayCard';
+import { Button } from './Button';
+import {
+ ChevronUp,
+ ChevronDown,
+ Search,
+ Filter,
+ Bell,
+ CheckCircle,
+ XCircle,
+ AlertTriangle,
+ DollarSign,
+ Users
+} from 'lucide-react';
+
+export interface UserOnboardingRecord {
+ id: string;
+ firstName: string;
+ lastName: string;
+ email: string;
+ brokerage: string;
+ yearsInBusiness: string;
+ gciLast12Months: string;
+ currentCRM: string | null;
+ goalsSelected: string[];
+ setupStatus: {
+ smsConfigured: boolean;
+ emailConfigured: boolean;
+ contactsImported: boolean;
+ campaignsSetup: boolean;
+ };
+ createdAt: Date;
+}
+
+interface AdminViewProps {
+ users: UserOnboardingRecord[];
+ onUserClick?: (userId: string) => void;
+ onNotifyUser?: (userId: string) => void;
+}
+
+type SortField = 'name' | 'email' | 'brokerage' | 'yearsInBusiness' | 'gci' | 'createdAt' | 'setupCompletion';
+type SortDirection = 'asc' | 'desc';
+type FilterType = 'all' | 'highGCI' | 'incompleteSetup' | 'completeSetup';
+
+const parseGCI = (gciString: string): number => {
+ const cleaned = gciString.replace(/[$,]/g, '');
+ return parseFloat(cleaned) || 0;
+};
+
+const getSetupCompletionCount = (status: UserOnboardingRecord['setupStatus']): number => {
+ return [
+ status.smsConfigured,
+ status.emailConfigured,
+ status.contactsImported,
+ status.campaignsSetup
+ ].filter(Boolean).length;
+};
+
+const isHighGCI = (gciString: string): boolean => {
+ return parseGCI(gciString) >= 100000;
+};
+
+const hasIncompleteSetup = (status: UserOnboardingRecord['setupStatus']): boolean => {
+ return getSetupCompletionCount(status) < 4;
+};
+
+export const AdminView: React.FC = ({
+ users,
+ onUserClick,
+ onNotifyUser
+}) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [sortField, setSortField] = useState('createdAt');
+ const [sortDirection, setSortDirection] = useState('desc');
+ const [filterType, setFilterType] = useState('all');
+
+ const handleSort = (field: SortField) => {
+ if (sortField === field) {
+ setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortField(field);
+ setSortDirection('asc');
+ }
+ };
+
+ const filteredAndSortedUsers = useMemo(() => {
+ let result = [...users];
+
+ // Apply search filter
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ result = result.filter(user =>
+ user.firstName.toLowerCase().includes(query) ||
+ user.lastName.toLowerCase().includes(query) ||
+ user.email.toLowerCase().includes(query) ||
+ user.brokerage.toLowerCase().includes(query)
+ );
+ }
+
+ // Apply type filter
+ switch (filterType) {
+ case 'highGCI':
+ result = result.filter(user => isHighGCI(user.gciLast12Months));
+ break;
+ case 'incompleteSetup':
+ result = result.filter(user => hasIncompleteSetup(user.setupStatus));
+ break;
+ case 'completeSetup':
+ result = result.filter(user => !hasIncompleteSetup(user.setupStatus));
+ break;
+ }
+
+ // Apply sorting
+ result.sort((a, b) => {
+ let comparison = 0;
+
+ switch (sortField) {
+ case 'name':
+ comparison = `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`);
+ break;
+ case 'email':
+ comparison = a.email.localeCompare(b.email);
+ break;
+ case 'brokerage':
+ comparison = a.brokerage.localeCompare(b.brokerage);
+ break;
+ case 'yearsInBusiness':
+ comparison = parseFloat(a.yearsInBusiness) - parseFloat(b.yearsInBusiness);
+ break;
+ case 'gci':
+ comparison = parseGCI(a.gciLast12Months) - parseGCI(b.gciLast12Months);
+ break;
+ case 'createdAt':
+ comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
+ break;
+ case 'setupCompletion':
+ comparison = getSetupCompletionCount(a.setupStatus) - getSetupCompletionCount(b.setupStatus);
+ break;
+ }
+
+ return sortDirection === 'asc' ? comparison : -comparison;
+ });
+
+ return result;
+ }, [users, searchQuery, sortField, sortDirection, filterType]);
+
+ const SortIcon: React.FC<{ field: SortField }> = ({ field }) => {
+ if (sortField !== field) return null;
+ return sortDirection === 'asc'
+ ?
+ : ;
+ };
+
+ const StatusBadge: React.FC<{ configured: boolean; label: string }> = ({ configured, label }) => (
+
+ {configured ? : }
+ {label}
+
+ );
+
+ const highGCICount = users.filter(u => isHighGCI(u.gciLast12Months)).length;
+ const incompleteSetupCount = users.filter(u => hasIncompleteSetup(u.setupStatus)).length;
+
+ return (
+
+ {/* Header */}
+
+
Admin Dashboard
+
Manage and monitor user onboarding progress
+
+
+ {/* Stats Cards */}
+
+
+
+
+
+
+
Total Users
+
{users.length}
+
+
+
+
+
+
+
+
+
High GCI Users ($100K+)
+
{highGCICount}
+
+
+
+
+
+
+
Incomplete Setup
+
{incompleteSetupCount}
+
+
+
+
+ {/* Filters and Search */}
+
+
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-10 pr-4 py-3 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
+ />
+
+
+ {/* Filter Buttons */}
+
+
setFilterType('all')}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
+ filterType === 'all'
+ ? 'bg-indigo-600 text-white'
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
+ }`}
+ >
+ All
+
+
setFilterType('highGCI')}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
+ filterType === 'highGCI'
+ ? 'bg-yellow-500 text-white'
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
+ }`}
+ >
+ High GCI
+
+
setFilterType('incompleteSetup')}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
+ filterType === 'incompleteSetup'
+ ? 'bg-red-500 text-white'
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
+ }`}
+ >
+ Incomplete
+
+
setFilterType('completeSetup')}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
+ filterType === 'completeSetup'
+ ? 'bg-green-500 text-white'
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
+ }`}
+ >
+ Complete
+
+
+
+
+
+ {/* Users Table */}
+
+
+
+
+
+ handleSort('name')}
+ >
+ Name
+
+ handleSort('email')}
+ >
+ Email
+
+ handleSort('brokerage')}
+ >
+ Brokerage
+
+ handleSort('yearsInBusiness')}
+ >
+ Years
+
+ handleSort('gci')}
+ >
+ GCI (12mo)
+
+
+ CRM
+
+
+ Goals
+
+ handleSort('setupCompletion')}
+ >
+ Setup Status
+
+
+ Actions
+
+
+
+
+ {filteredAndSortedUsers.map((user) => {
+ const userIsHighGCI = isHighGCI(user.gciLast12Months);
+ const userHasIncompleteSetup = hasIncompleteSetup(user.setupStatus);
+
+ return (
+
+
+ onUserClick?.(user.id)}
+ >
+
+ {user.firstName} {user.lastName}
+
+ {userIsHighGCI && (
+
+ High GCI
+
+ )}
+
+
+ {user.email}
+ {user.brokerage}
+ {user.yearsInBusiness}
+
+
+ {user.gciLast12Months}
+
+
+
+ {user.currentCRM || None }
+
+
+
+ {user.goalsSelected.length > 0 ? (
+ user.goalsSelected.map((goal, idx) => (
+
+ {goal}
+
+ ))
+ ) : (
+ No goals
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ onUserClick?.(user.id)}
+ className="!p-2"
+ >
+ View
+
+ {userHasIncompleteSetup && onNotifyUser && (
+ onNotifyUser(user.id)}
+ className="p-2 text-orange-500 hover:bg-orange-50 rounded-lg transition-colors"
+ title="Send reminder notification"
+ >
+
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+ {filteredAndSortedUsers.length === 0 && (
+
+
+
No users found
+
Try adjusting your search or filter criteria
+
+ )}
+
+
+
+ {/* Results Summary */}
+
+ Showing {filteredAndSortedUsers.length} of {users.length} users
+
+
+ );
+};
diff --git a/components/Button.tsx b/components/Button.tsx
new file mode 100644
index 0000000..fe940db
--- /dev/null
+++ b/components/Button.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ variant?: 'primary' | 'secondary' | 'ghost';
+ fullWidth?: boolean;
+}
+
+export const Button: React.FC = ({
+ children,
+ variant = 'primary',
+ fullWidth = false,
+ className = '',
+ disabled,
+ ...props
+}) => {
+ const baseStyles = "px-6 py-3 rounded-2xl font-semibold transition-all duration-200 active:scale-95 flex items-center justify-center gap-2";
+
+ const variants = {
+ primary: "bg-indigo-600 text-white shadow-[6px_6px_12px_rgba(79,70,229,0.3),-4px_-4px_8px_rgba(255,255,255,0.2)] hover:bg-indigo-700 hover:shadow-[4px_4px_8px_rgba(79,70,229,0.3),-2px_-2px_4px_rgba(255,255,255,0.2)] disabled:bg-gray-300 disabled:shadow-none disabled:text-gray-500 disabled:cursor-not-allowed",
+ secondary: "bg-white text-gray-700 shadow-[6px_6px_12px_#d1d5db,-6px_-6px_12px_#ffffff] hover:shadow-[4px_4px_8px_#d1d5db,-4px_-4px_8px_#ffffff] hover:text-indigo-600 disabled:shadow-none disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed",
+ ghost: "bg-transparent text-gray-500 hover:text-indigo-600 hover:bg-indigo-50"
+ };
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/components/ClayCard.tsx b/components/ClayCard.tsx
new file mode 100644
index 0000000..8ea5876
--- /dev/null
+++ b/components/ClayCard.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+
+type ElevationLevel = 'flat' | 'subtle' | 'standard' | 'prominent';
+
+interface ClayCardProps {
+ children: React.ReactNode;
+ className?: string;
+ onClick?: () => void;
+ selected?: boolean;
+ /**
+ * Elevation level for visual hierarchy:
+ * - flat: No shadow (nested content)
+ * - subtle: Light shadow (section headers, secondary elements)
+ * - standard: Medium shadow (main content cards) - default
+ * - prominent: Strong shadow (emphasized content)
+ */
+ elevation?: ElevationLevel;
+}
+
+const elevationClasses: Record = {
+ flat: '',
+ subtle: 'shadow-[3px_3px_6px_#c5c9d1,-3px_-3px_6px_#ffffff]',
+ standard: 'shadow-[6px_6px_12px_#bfc3cc,-6px_-6px_12px_#ffffff]',
+ prominent: 'shadow-[8px_8px_16px_#b8bcc5,-8px_-8px_16px_#ffffff]',
+};
+
+export const ClayCard: React.FC = ({
+ children,
+ className = '',
+ onClick,
+ selected,
+ elevation = 'standard'
+}) => {
+ const elevationClass = selected
+ ? 'shadow-[inset_4px_4px_8px_rgba(0,0,0,0.05),inset_-4px_-4px_8px_rgba(255,255,255,0.8)]'
+ : elevationClasses[elevation];
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/components/DFYForm.tsx b/components/DFYForm.tsx
new file mode 100644
index 0000000..40d1ab2
--- /dev/null
+++ b/components/DFYForm.tsx
@@ -0,0 +1,124 @@
+import React, { useState } from 'react';
+import { DFYType } from '../types';
+import { ClayCard } from './ClayCard';
+import { Button } from './Button';
+import { ArrowLeft, Check } from 'lucide-react';
+
+interface Props {
+ type: DFYType;
+ onBack: () => void;
+ onComplete: () => void;
+}
+
+export const DFYForm: React.FC = ({ type, onBack, onComplete }) => {
+ const [submitted, setSubmitted] = useState(false);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ // Simulate API call
+ setTimeout(() => {
+ setSubmitted(true);
+ setTimeout(() => {
+ onComplete();
+ }, 1500);
+ }, 1000);
+ };
+
+ const renderFields = () => {
+ if (type === 'SMS') {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+ if (type === 'EMAIL') {
+ return (
+ <>
+
+
+ I have access to DNS
+ I need to find a technician
+
+
+
+ >
+ );
+ }
+ return (
+ <>
+
+ This is a paid service ($199). You will be billed after intake.
+
+
+
+
+
+ >
+ );
+ };
+
+ if (submitted) {
+ return (
+
+
+
+
+
Request Received!
+
We will start setting this up for you.
+
+ );
+ }
+
+ return (
+
+
+ Back to Dashboard
+
+
+
+
+
+ {type === 'SMS' && 'Done-For-You SMS Setup'}
+ {type === 'EMAIL' && 'Done-For-You Email Setup'}
+ {type === 'CAMPAIGN' && 'Campaign Setup Service'}
+
+
Fill out the details below and our team will handle the technical configuration.
+
+
+
+
+
+ {/* Quick style for inputs inside this component scope */}
+
+
+ );
+};
\ No newline at end of file
diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx
new file mode 100644
index 0000000..949f4fe
--- /dev/null
+++ b/components/Dashboard.tsx
@@ -0,0 +1,410 @@
+import React, { useMemo } from 'react';
+import { OnboardingDataLegacy, SystemState, GoalPrimary, Channel, ExternalSystem } from '../types';
+import { ClayCard } from './ClayCard';
+import { Button } from './Button';
+
+// Use legacy interface which has snake_case properties
+type OnboardingData = OnboardingDataLegacy;
+import {
+ Upload,
+ MessageSquare,
+ Mail,
+ Rocket,
+ CheckCircle,
+ Smartphone,
+ Users,
+ Target,
+ BarChart3,
+ Layers,
+ Link2,
+ Sparkles,
+ BookOpen,
+ ClipboardList
+} from 'lucide-react';
+
+interface DashboardProps {
+ onboardingData: OnboardingData;
+ systemState: SystemState;
+ onSetupClick: (setupType: string) => void;
+ onQuizClick: () => void;
+}
+
+interface TodoItem {
+ id: string;
+ title: string;
+ description: string;
+ icon: React.ReactNode;
+ iconBgColor: string;
+ iconTextColor: string;
+ setupType: string;
+ isCompleted: boolean;
+}
+
+export const Dashboard: React.FC = ({
+ onboardingData,
+ systemState,
+ onSetupClick,
+ onQuizClick
+}) => {
+ const {
+ user_first_name,
+ goals_selected,
+ channels_selected,
+ systems_to_connect,
+ has_lead_source
+ } = onboardingData;
+
+ const { sms_configured, email_configured, contacts_imported } = systemState;
+
+ // Calculate setup progress
+ const progressData = useMemo(() => {
+ const items: { label: string; completed: boolean }[] = [];
+
+ if (channels_selected.includes(Channel.SMS)) {
+ items.push({ label: 'SMS', completed: sms_configured });
+ }
+ if (channels_selected.includes(Channel.EMAIL)) {
+ items.push({ label: 'Email', completed: email_configured });
+ }
+ if (has_lead_source) {
+ items.push({ label: 'Contacts', completed: contacts_imported });
+ }
+ if (systems_to_connect.length > 0) {
+ items.push({ label: 'Integrations', completed: false }); // Assuming integrations tracking
+ }
+
+ const completedCount = items.filter(i => i.completed).length;
+ const percentage = items.length > 0 ? Math.round((completedCount / items.length) * 100) : 0;
+
+ return { items, completedCount, percentage };
+ }, [channels_selected, systems_to_connect, has_lead_source, sms_configured, email_configured, contacts_imported]);
+
+ // Generate subtitle based on goals
+ const subtitle = useMemo(() => {
+ if (goals_selected.length === 0) {
+ return "Let's get your CRM set up!";
+ }
+
+ const goalDescriptions: Record = {
+ [GoalPrimary.NEW_SELLER_LEADS]: 'seller leads',
+ [GoalPrimary.NEW_BUYER_LEADS]: 'buyer leads',
+ [GoalPrimary.GET_ORGANIZED]: 'organization',
+ [GoalPrimary.FOLLOW_UP_CAMPAIGNS]: 'follow-up campaigns',
+ [GoalPrimary.TRACK_METRICS]: 'tracking metrics'
+ };
+
+ const goalSummary = goals_selected
+ .slice(0, 2)
+ .map(g => goalDescriptions[g] || g)
+ .join(' and ');
+
+ return `Let's help you with ${goalSummary}${goals_selected.length > 2 ? ' and more' : ''}!`;
+ }, [goals_selected]);
+
+ // Build dynamic to-do items
+ const todoItems: TodoItem[] = useMemo(() => {
+ const items: TodoItem[] = [];
+
+ // Goal-based to-dos
+ if (goals_selected.includes(GoalPrimary.NEW_SELLER_LEADS)) {
+ items.push({
+ id: 'seller-leads-campaign',
+ title: 'Start campaign to get new seller leads',
+ description: 'Launch an automated campaign targeting potential sellers in your market.',
+ icon: ,
+ iconBgColor: 'bg-emerald-100',
+ iconTextColor: 'text-emerald-600',
+ setupType: 'SELLER_CAMPAIGN',
+ isCompleted: false
+ });
+ }
+
+ if (goals_selected.includes(GoalPrimary.NEW_BUYER_LEADS)) {
+ items.push({
+ id: 'buyer-leads-campaign',
+ title: 'Start campaign to get new buyer leads',
+ description: 'Reach potential buyers with targeted messaging and automation.',
+ icon: ,
+ iconBgColor: 'bg-blue-100',
+ iconTextColor: 'text-blue-600',
+ setupType: 'BUYER_CAMPAIGN',
+ isCompleted: false
+ });
+ }
+
+ if (goals_selected.includes(GoalPrimary.FOLLOW_UP_CAMPAIGNS)) {
+ items.push({
+ id: 'follow-up-campaigns',
+ title: 'Set up follow-up campaigns',
+ description: 'Create automated sequences to nurture your leads over time.',
+ icon: ,
+ iconBgColor: 'bg-violet-100',
+ iconTextColor: 'text-violet-600',
+ setupType: 'FOLLOW_UP_CAMPAIGN',
+ isCompleted: false
+ });
+ }
+
+ if (goals_selected.includes(GoalPrimary.GET_ORGANIZED)) {
+ items.push({
+ id: 'configure-pipeline',
+ title: 'Configure pipeline stages',
+ description: 'Customize your pipeline to match your sales process.',
+ icon: ,
+ iconBgColor: 'bg-amber-100',
+ iconTextColor: 'text-amber-600',
+ setupType: 'PIPELINE_CONFIG',
+ isCompleted: false
+ });
+ }
+
+ if (goals_selected.includes(GoalPrimary.TRACK_METRICS)) {
+ items.push({
+ id: 'reporting-dashboard',
+ title: 'Set up reporting dashboard',
+ description: 'Track your key metrics and performance indicators.',
+ icon: ,
+ iconBgColor: 'bg-rose-100',
+ iconTextColor: 'text-rose-600',
+ setupType: 'REPORTING_SETUP',
+ isCompleted: false
+ });
+ }
+
+ // Channel-based to-dos
+ if (channels_selected.includes(Channel.SMS) && !sms_configured) {
+ items.push({
+ id: 'sms-setup',
+ title: 'Set up SMS (A2P registration)',
+ description: 'Configure your phone number and complete carrier registration to start texting.',
+ icon: ,
+ iconBgColor: 'bg-purple-100',
+ iconTextColor: 'text-purple-600',
+ setupType: 'SMS_SETUP',
+ isCompleted: false
+ });
+ }
+
+ if (channels_selected.includes(Channel.EMAIL) && !email_configured) {
+ items.push({
+ id: 'email-setup',
+ title: 'Configure email settings',
+ description: 'Connect your domain and set up authentication for deliverability.',
+ icon: ,
+ iconBgColor: 'bg-orange-100',
+ iconTextColor: 'text-orange-600',
+ setupType: 'EMAIL_SETUP',
+ isCompleted: false
+ });
+ }
+
+ // Systems integration to-do
+ if (systems_to_connect.length > 0) {
+ const systemNames = systems_to_connect.map(s => {
+ const names: Record = {
+ [ExternalSystem.DIALER]: 'Dialer',
+ [ExternalSystem.TRANSACTION_MANAGEMENT]: 'Transaction Management',
+ [ExternalSystem.OTHER_CRM]: 'CRM',
+ [ExternalSystem.MARKETING_PLATFORM]: 'Marketing Platform'
+ };
+ return names[s] || s;
+ });
+
+ items.push({
+ id: 'connect-systems',
+ title: 'Connect your systems',
+ description: `Integrate with ${systemNames.join(', ')} for seamless workflow.`,
+ icon: ,
+ iconBgColor: 'bg-cyan-100',
+ iconTextColor: 'text-cyan-600',
+ setupType: 'INTEGRATIONS',
+ isCompleted: false
+ });
+ }
+
+ // Lead upload to-do
+ if (has_lead_source && !contacts_imported) {
+ items.push({
+ id: 'upload-leads',
+ title: 'Upload your lead list',
+ description: 'Import your existing contacts to start automating your outreach.',
+ icon: ,
+ iconBgColor: 'bg-teal-100',
+ iconTextColor: 'text-teal-600',
+ setupType: 'UPLOAD_LEADS',
+ isCompleted: false
+ });
+ }
+
+ return items;
+ }, [goals_selected, channels_selected, systems_to_connect, has_lead_source, sms_configured, email_configured, contacts_imported]);
+
+ // Completed items (for showing completion status)
+ const completedItems = useMemo(() => {
+ const items: { id: string; label: string }[] = [];
+
+ if (channels_selected.includes(Channel.SMS) && sms_configured) {
+ items.push({ id: 'sms-done', label: 'SMS configured' });
+ }
+ if (channels_selected.includes(Channel.EMAIL) && email_configured) {
+ items.push({ id: 'email-done', label: 'Email configured' });
+ }
+ if (has_lead_source && contacts_imported) {
+ items.push({ id: 'leads-done', label: 'Leads uploaded' });
+ }
+
+ return items;
+ }, [channels_selected, has_lead_source, sms_configured, email_configured, contacts_imported]);
+
+ return (
+
+ {/* Welcome Header */}
+
+
+ Welcome, {user_first_name || 'there'}!
+
+
{subtitle}
+
+
+ {/* Setup Progress Card */}
+
+
+
+
Setup Progress
+
+ {progressData.completedCount} of {progressData.items.length} steps completed
+
+
+
+
+
{progressData.percentage}%
+
+
+
+ {/* Progress indicators */}
+
+ {progressData.items.map((item, index) => (
+
+ {item.completed && }
+ {item.label}
+
+ ))}
+
+
+
+ {/* Completed Items */}
+ {completedItems.length > 0 && (
+
+ {completedItems.map(item => (
+
+
+ {item.label}
+
+ ))}
+
+ )}
+
+ {/* Dynamic To-Do Items */}
+
+
+
+ Your To-Do List
+
+
+ {todoItems.length === 0 ? (
+
+ 🎉
+ All caught up!
+ You've completed all your setup tasks.
+
+ ) : (
+
+ {todoItems.map((item) => (
+
+
+
+ {item.icon}
+
+
+
{item.title}
+
{item.description}
+
+
+
+ onSetupClick(`${item.setupType}_DIY`)}
+ >
+ DIY Setup
+
+ onSetupClick(`${item.setupType}_DFY`)}
+ className="gap-2"
+ >
+
+ Done For You
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Quick Links Section */}
+
+
Quick Links
+
+ {/* Performance Quiz Card */}
+
+
+
+
+
+
+
Take the Performance Quiz
+
See how you compare to your peers
+
+
+
+
+ {/* Browse Resources Card */}
+
onSetupClick('BROWSE_RESOURCES')}
+ className="cursor-pointer hover:transform hover:-translate-y-1 transition-all duration-300"
+ >
+
+
+
+
+
+
Browse Resources
+
Tutorials, guides, and best practices
+
+
+
+
+
+
+ );
+};
diff --git a/components/ExternalTools.tsx b/components/ExternalTools.tsx
new file mode 100644
index 0000000..6abed28
--- /dev/null
+++ b/components/ExternalTools.tsx
@@ -0,0 +1,229 @@
+import React from 'react';
+import { ClayCard } from './ClayCard';
+import { Button } from './Button';
+import {
+ FileText,
+ Calculator,
+ ArrowRight,
+ CheckCircle2,
+ Sparkles,
+ FileSignature,
+ BarChart3,
+ Clock,
+ Zap,
+ Shield,
+ TrendingUp,
+ PieChart,
+ Target,
+ Layers,
+ Award
+} from 'lucide-react';
+
+interface ExternalToolsProps {
+ loiToolUrl?: string;
+ underwritingToolUrl?: string;
+ onToolClick?: (toolName: string) => void;
+}
+
+interface ToolFeature {
+ icon: React.ReactNode;
+ text: string;
+}
+
+interface Tool {
+ id: string;
+ name: string;
+ fullName: string;
+ description: string;
+ longDescription: string;
+ icon: React.ReactNode;
+ iconBg: string;
+ gradientFrom: string;
+ gradientTo: string;
+ features: ToolFeature[];
+ capabilities: string[];
+ badge?: string;
+ badgeColor?: string;
+}
+
+export const ExternalTools: React.FC = ({
+ loiToolUrl = '#',
+ underwritingToolUrl = '#',
+ onToolClick,
+}) => {
+ const handleToolAccess = (toolName: string, url: string) => {
+ if (onToolClick) {
+ onToolClick(toolName);
+ }
+ if (url && url !== '#') {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ }
+ };
+
+ const tools: Tool[] = [
+ {
+ id: 'loi',
+ name: 'LOI Drafting Tool',
+ fullName: 'Letter of Intent (LOI) Drafting Tool',
+ description: 'Create professional letters of intent in minutes',
+ longDescription: 'Streamline your deal-making process with our intelligent LOI generator. Input your deal terms and receive a professionally formatted letter of intent ready for submission.',
+ icon: ,
+ iconBg: 'bg-gradient-to-br from-emerald-500 to-green-600',
+ gradientFrom: 'from-emerald-100/50',
+ gradientTo: 'to-green-100/50',
+ badge: 'Time Saver',
+ badgeColor: 'bg-emerald-100 text-emerald-700',
+ features: [
+ { icon: , text: 'Auto-generate from deal data' },
+ { icon: , text: 'Industry-standard templates' },
+ { icon: , text: 'Create LOIs in under 5 minutes' },
+ ],
+ capabilities: [
+ 'Multiple property type templates',
+ 'Custom clause library',
+ 'Export to PDF/Word',
+ 'Save drafts for later',
+ 'Track sent LOIs',
+ ],
+ },
+ {
+ id: 'underwriting',
+ name: 'Underwriting Tool',
+ fullName: 'Advanced Underwriting Analysis Tool',
+ description: 'Gamified approach to property analysis',
+ longDescription: 'Make smarter investment decisions with our comprehensive underwriting tool. Analyze deals with confidence using our intuitive, gamified interface that makes complex financial modeling accessible.',
+ icon: ,
+ iconBg: 'bg-gradient-to-br from-amber-500 to-orange-600',
+ gradientFrom: 'from-amber-100/50',
+ gradientTo: 'to-orange-100/50',
+ badge: 'Pro Analysis',
+ badgeColor: 'bg-amber-100 text-amber-700',
+ features: [
+ { icon: , text: 'Interactive financial modeling' },
+ { icon: , text: 'Scenario comparison' },
+ { icon: , text: 'ROI & IRR calculations' },
+ ],
+ capabilities: [
+ 'Multi-year projections',
+ 'Sensitivity analysis',
+ 'Cap rate calculations',
+ 'Cash flow modeling',
+ 'Investment scoring',
+ ],
+ },
+ ];
+
+ return (
+
+ {/* Header - Level 1 (subtle) since it's a page header */}
+
+
+
+ Productivity Suite
+
+ External Tools
+
+ Access specialized tools designed to streamline your workflow and help you close deals faster.
+ Each tool is built specifically for commercial real estate professionals.
+
+
+
+ {/* Tools Grid */}
+
+ {tools.map((tool) => (
+
+ {/* Background Decoration */}
+
+
+
+ {/* Header Section */}
+
+
+ {tool.icon}
+
+
+
+
+
{tool.fullName}
+ {tool.badge && (
+
+ {tool.badge}
+
+ )}
+
+
{tool.longDescription}
+
+ {/* Features Row */}
+
+ {tool.features.map((feature, index) => (
+
+ {feature.icon}
+ {feature.text}
+
+ ))}
+
+
+
+
handleToolAccess(tool.id, tool.id === 'loi' ? loiToolUrl : underwritingToolUrl)}
+ className="flex-shrink-0"
+ >
+ Access Tool
+
+
+
+
+ {/* Capabilities Section */}
+
+
+
+
Key Capabilities
+
+
+ {tool.capabilities.map((capability, index) => (
+
+
+ {capability}
+
+ ))}
+
+
+
+
+ ))}
+
+
+ {/* Coming Soon Section */}
+
+
+
+ Coming Soon
+
+
+ {[
+ { name: 'Comp Analysis Tool', description: 'Market comparison insights', icon:
},
+ { name: 'Document Generator', description: 'Custom CRE documents', icon:
},
+ { name: 'Deal Calculator', description: 'Quick deal metrics', icon:
},
+ ].map((item, index) => (
+
+
+ {item.icon}
+
+
{item.name}
+
{item.description}
+
+ In Development
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/components/Marketplace.tsx b/components/Marketplace.tsx
new file mode 100644
index 0000000..e677413
--- /dev/null
+++ b/components/Marketplace.tsx
@@ -0,0 +1,280 @@
+import React from 'react';
+import { ClayCard } from './ClayCard';
+import { Button } from './Button';
+import {
+ GraduationCap,
+ Users,
+ Calendar,
+ FileText,
+ ClipboardCheck,
+ ExternalLink,
+ Sparkles,
+ Star,
+ ArrowRight,
+ Download,
+ Video,
+ BookOpen,
+ Mic,
+ Trophy,
+ Target,
+ TrendingUp,
+ CalendarDays,
+ Clock,
+ MapPin
+} from 'lucide-react';
+
+interface MarketplaceProps {
+ onQuizClick: () => void;
+ calendlyCoachingLink?: string;
+ calendlyTeamLink?: string;
+}
+
+export const Marketplace: React.FC = ({
+ onQuizClick,
+ calendlyCoachingLink = 'https://calendly.com',
+ calendlyTeamLink = 'https://calendly.com',
+}) => {
+ const handleExternalLink = (url: string) => {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ };
+
+ return (
+
+ {/* Header - Level 1 (subtle) since it's a page header */}
+
+
+
+ Your Success Hub
+
+ Town Hall
+
+ Resources, coaching, and opportunities to accelerate your commercial real estate success.
+
+
+
+ {/* Main Action Cards */}
+
+ {/* Get Coaching Card */}
+
+
+
+
+
+
+
+
+
Get Coaching
+ Popular
+
+
+ Work one-on-one with an expert coach to optimize your outreach strategy,
+ improve your scripts, and close more deals faster.
+
+
+
+ Strategy Sessions
+
+
+ Script Review
+
+
+ Deal Analysis
+
+
+
+
handleExternalLink(calendlyCoachingLink)}>
+ Book a Session
+
+
+
+
+
+ {/* Take the Quiz Card */}
+
+
+
+
+
+
+
+
+
Take the Performance Quiz
+ Free
+
+
+ Discover where you stand among your peers. Get personalized insights and
+ actionable recommendations to level up your performance.
+
+
+
+ Benchmarking
+
+
+ Personalized Tips
+
+
+ 5 Minutes
+
+
+
+
+ Start Quiz
+
+
+
+
+
+ {/* Join Our Team Card */}
+
+
+
+
+
+
+
+
Join Our Team
+
+ Interested in becoming part of our growing team? We are always looking
+ for talented individuals who share our passion for real estate success.
+
+
+
+ Growth Opportunities
+
+
+ Collaborative Culture
+
+
+
+
handleExternalLink(calendlyTeamLink)}
+ >
+ Learn More
+
+
+
+
+
+
+ {/* Two Column Layout for Events and Resources */}
+
+ {/* Upcoming Events Section */}
+
+
+
+
Upcoming Events
+
+
+
+
+
+
+
+
Workshops & Live Events
+
Join our community events to learn and network.
+
+
+
+ {/* Empty State with Illustration */}
+
+
+
+
+
No Events Scheduled
+
+ Exciting workshops and networking events are coming soon!
+
+
+
+ Webinars
+
+
+ Q&A Sessions
+
+
+ Masterclasses
+
+
+
+
+ {/* Preview Event Card (Coming Soon) */}
+
+
+
+
+
+
+
Coming Soon
+
Market Analysis Workshop
+
+ Virtual Event
+
+
+
+
+
+
+
+ {/* Free Resources Section */}
+
+
+
+
Free Resources
+
+
+
+
+
+
+
+
Scripts, Guides & Templates
+
Download free resources to boost your productivity.
+
+
+
+ {/* Empty State with Illustration */}
+
+
+
+
+
Resources Coming Soon
+
+ We are preparing valuable templates and guides for you.
+
+
+
+ Cold Call Scripts
+
+
+ Email Templates
+
+
+ Deal Checklists
+
+
+
+
+ {/* Preview Resource Cards (Coming Soon) */}
+
+
+
+
+
+
LOI Template
+
PDF
+
+
+
+
+
+
Call Scripts
+
DOC
+
+
+
+
+
+
+ );
+};
diff --git a/components/OnboardingFlow.tsx b/components/OnboardingFlow.tsx
new file mode 100644
index 0000000..88b2744
--- /dev/null
+++ b/components/OnboardingFlow.tsx
@@ -0,0 +1,506 @@
+import React, { useState } from 'react';
+import {
+ OnboardingDataLegacy,
+ GoalPrimary,
+ LeadType,
+ Channel,
+ ExperienceLevel,
+ GCIRange,
+ CRMPainPoint,
+ ExternalSystem
+} from '../types';
+import { Button } from './Button';
+import { ClayCard } from './ClayCard';
+import { ArrowRight, Check } from 'lucide-react';
+
+// Use legacy interface which has snake_case properties
+type OnboardingData = OnboardingDataLegacy;
+
+interface Props {
+ onComplete: (data: OnboardingData) => void;
+}
+
+const INITIAL_DATA: OnboardingData = {
+ // Experience questions
+ years_in_business: null,
+ gci_last_12_months: null,
+
+ // CRM questions
+ using_other_crm: null,
+ current_crm_name: '',
+ crm_pain_points: [],
+
+ // Goals
+ goals_selected: [],
+
+ // Lead questions
+ has_lead_source: null,
+ lead_source_name: '',
+ wants_more_leads: null,
+ lead_type_desired: [],
+ leads_per_month_target: '',
+
+ // Channels
+ channels_selected: [],
+
+ // Systems to connect
+ systems_to_connect: [],
+
+ // Custom values for personalization
+ user_first_name: '',
+ user_last_name: '',
+ user_email: '',
+ brokerage_name: '',
+};
+
+const TOTAL_STEPS = 7;
+
+export const OnboardingFlow: React.FC = ({ onComplete }) => {
+ const [step, setStep] = useState(1);
+ const [data, setData] = useState(INITIAL_DATA);
+
+ const updateData = (key: keyof OnboardingData, value: any) => {
+ setData(prev => ({ ...prev, [key]: value }));
+ };
+
+ const toggleArrayItem = (key: keyof OnboardingData, item: T) => {
+ setData(prev => {
+ const arr = prev[key] as T[];
+ if (arr.includes(item)) {
+ return { ...prev, [key]: arr.filter(i => i !== item) };
+ }
+ return { ...prev, [key]: [...arr, item] };
+ });
+ };
+
+ const nextStep = () => setStep(prev => prev + 1);
+
+ // Step 1: Experience Questions
+ const renderStep1 = () => (
+
+
+
Tell us about your experience
+
This helps us personalize your setup
+
+
+
+
+
How long have you been in the business?
+
+ {Object.values(ExperienceLevel).map((level) => (
+ updateData('years_in_business', level)}
+ className="flex items-center justify-between"
+ >
+ {level}
+ {data.years_in_business === level && }
+
+ ))}
+
+
+
+
+
How much GCI have you done in the last 12 months?
+
+ {Object.values(GCIRange).map((range) => (
+ updateData('gci_last_12_months', range)}
+ className="flex items-center justify-between"
+ >
+ {range}
+ {data.gci_last_12_months === range && }
+
+ ))}
+
+
+
+
+
+ Continue
+
+
+ );
+
+ // Step 2: CRM Questions
+ const renderStep2 = () => (
+
+
+
Your Current CRM
+
Tell us about your current setup
+
+
+
+
Are you currently using a CRM?
+
+ updateData('using_other_crm', true)}
+ >
+ Yes
+
+ updateData('using_other_crm', false)}
+ >
+ No
+
+
+
+
+ {data.using_other_crm === true && (
+ <>
+
+
Which CRM are you using?
+
updateData('current_crm_name', e.target.value)}
+ placeholder="Enter your CRM name..."
+ className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
+ />
+
+ {['Follow Up Boss', 'KvCore', 'Salesforce', 'HubSpot', 'Other'].map(crm => (
+ updateData('current_crm_name', crm)}
+ className="text-xs px-3 py-1 bg-gray-200 rounded-full text-gray-600 hover:bg-gray-300"
+ >
+ {crm}
+
+ ))}
+
+
+
+
+
What are you looking for that it doesn't do?
+
Select all that apply
+
+ {Object.values(CRMPainPoint).map((painPoint) => (
+ toggleArrayItem('crm_pain_points', painPoint)}
+ className="py-3 px-4 text-center text-sm"
+ >
+ {painPoint}
+ {data.crm_pain_points.includes(painPoint) && (
+
+ )}
+
+ ))}
+
+
+ >
+ )}
+
+
+ Continue
+
+
+ );
+
+ // Step 3: Goals
+ const renderStep3 = () => (
+
+
+
What are you looking to accomplish?
+
Select all that apply
+
+
+
+ {Object.values(GoalPrimary).map((goal) => (
+ toggleArrayItem('goals_selected', goal)}
+ className="flex items-center justify-between"
+ >
+ {goal}
+ {data.goals_selected.includes(goal) && }
+
+ ))}
+
+
+
+ Continue
+
+
+ );
+
+ // Step 4: Lead Source
+ const renderStep4 = () => (
+
+
+
Lead Sources
+
Tell us about your lead generation
+
+
+
+
Do you currently have a lead source?
+
+ updateData('has_lead_source', true)}
+ >
+ Yes
+
+ updateData('has_lead_source', false)}
+ >
+ No
+
+
+
+
+ {data.has_lead_source === true && (
+
+ Where are your leads coming from?
+ updateData('lead_source_name', e.target.value)}
+ placeholder="e.g. Zillow, Referrals, Meta..."
+ className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
+ />
+
+ )}
+
+
+
Do you want more leads?
+
+ updateData('wants_more_leads', true)}
+ >
+ Yes
+
+ updateData('wants_more_leads', false)}
+ >
+ No
+
+
+
+
+ {data.wants_more_leads === true && (
+
+
What type of leads?
+
Select all that apply
+
+ {[LeadType.SELLER, LeadType.BUYER, LeadType.COMMERCIAL].map((type) => (
+ toggleArrayItem('lead_type_desired', type)}
+ className="py-3 px-4 text-center text-sm"
+ >
+ {type}
+ {data.lead_type_desired.includes(type) && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ Continue
+
+
+ );
+
+ // Step 5: Systems to Connect
+ const renderStep5 = () => (
+
+
+
Systems to Connect
+
What other systems would you like to connect?
+
+
+
+ {Object.values(ExternalSystem).map((system) => (
+ toggleArrayItem('systems_to_connect', system)}
+ className="flex items-center justify-between"
+ >
+ {system}
+ {data.systems_to_connect.includes(system) && }
+
+ ))}
+
+
+
+ Continue
+
+
+ );
+
+ // Step 6: Contact Channels
+ const renderStep6 = () => (
+
+
+
Contact Channels
+
How do you want to reach leads?
+
+
+
+ {Object.values(Channel).map((channel) => (
+ toggleArrayItem('channels_selected', channel)}
+ className="flex items-center justify-between"
+ >
+ {channel}
+ {data.channels_selected.includes(channel) && }
+
+ ))}
+
+
+
+ Continue
+
+
+ );
+
+ // Step 7: Custom Values (Profile Info)
+ const renderStep7 = () => {
+ const isStep7Valid =
+ data.user_first_name.trim() !== '' &&
+ data.user_last_name.trim() !== '' &&
+ data.user_email.trim() !== '' &&
+ data.brokerage_name.trim() !== '';
+
+ return (
+
+
+
Let's personalize your experience
+
Tell us a bit about yourself
+
+
+
+
+
onComplete(data)}
+ className="mt-8"
+ >
+ Finish Setup
+
+
+ );
+ };
+
+ return (
+
+
+ {Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map(s => (
+
+ ))}
+
+
+ {step === 1 && renderStep1()}
+ {step === 2 && renderStep2()}
+ {step === 3 && renderStep3()}
+ {step === 4 && renderStep4()}
+ {step === 5 && renderStep5()}
+ {step === 6 && renderStep6()}
+ {step === 7 && renderStep7()}
+
+ );
+};
diff --git a/components/Quiz.tsx b/components/Quiz.tsx
new file mode 100644
index 0000000..e19f4f0
--- /dev/null
+++ b/components/Quiz.tsx
@@ -0,0 +1,376 @@
+import React, { useState } from 'react';
+import { ClayCard } from './ClayCard';
+import { Button } from './Button';
+import { ArrowRight, ArrowLeft, TrendingUp, Users, BookOpen } from 'lucide-react';
+
+export interface QuizResult {
+ yearsInBusiness: string;
+ dealsLast12Months: number;
+ leadsPerMonth: number;
+ hoursProspecting: number;
+ performanceLevel: 'below' | 'at' | 'above';
+ recommendedAction: 'coaching' | 'team' | 'none';
+}
+
+interface QuizProps {
+ onComplete: (result: QuizResult) => void;
+ calendlyCoachingLink?: string;
+ calendlyTeamLink?: string;
+}
+
+interface Question {
+ id: keyof Pick;
+ question: string;
+ type: 'select' | 'number';
+ options?: { value: string; label: string }[];
+ placeholder?: string;
+}
+
+const questions: Question[] = [
+ {
+ id: 'yearsInBusiness',
+ question: 'How long have you been in real estate?',
+ type: 'select',
+ options: [
+ { value: '0-1', label: 'Less than 1 year' },
+ { value: '1-3', label: '1-3 years' },
+ { value: '3-5', label: '3-5 years' },
+ { value: '5-10', label: '5-10 years' },
+ { value: '10+', label: '10+ years' },
+ ],
+ },
+ {
+ id: 'dealsLast12Months',
+ question: 'How many deals did you close in the last 12 months?',
+ type: 'number',
+ placeholder: 'Enter number of deals',
+ },
+ {
+ id: 'leadsPerMonth',
+ question: 'How many leads do you generate per month?',
+ type: 'number',
+ placeholder: 'Enter number of leads',
+ },
+ {
+ id: 'hoursProspecting',
+ question: 'How many hours per week do you spend prospecting?',
+ type: 'number',
+ placeholder: 'Enter hours per week',
+ },
+];
+
+// Peer benchmarks based on years of experience
+const getPeerBenchmarks = (years: string) => {
+ const benchmarks: Record = {
+ '0-1': { deals: 3, leads: 10, hours: 15 },
+ '1-3': { deals: 8, leads: 20, hours: 12 },
+ '3-5': { deals: 12, leads: 30, hours: 10 },
+ '5-10': { deals: 18, leads: 40, hours: 10 },
+ '10+': { deals: 24, leads: 50, hours: 8 },
+ };
+ return benchmarks[years] || benchmarks['1-3'];
+};
+
+const calculatePerformanceLevel = (
+ answers: Partial
+): { level: 'below' | 'at' | 'above'; action: 'coaching' | 'team' | 'none' } => {
+ const benchmarks = getPeerBenchmarks(answers.yearsInBusiness || '1-3');
+
+ let score = 0;
+
+ // Compare deals (weight: 40%)
+ const dealsRatio = (answers.dealsLast12Months || 0) / benchmarks.deals;
+ if (dealsRatio >= 1.2) score += 40;
+ else if (dealsRatio >= 0.8) score += 25;
+ else score += 10;
+
+ // Compare leads (weight: 35%)
+ const leadsRatio = (answers.leadsPerMonth || 0) / benchmarks.leads;
+ if (leadsRatio >= 1.2) score += 35;
+ else if (leadsRatio >= 0.8) score += 22;
+ else score += 8;
+
+ // Compare prospecting hours (weight: 25%)
+ const hoursRatio = (answers.hoursProspecting || 0) / benchmarks.hours;
+ if (hoursRatio >= 1.2) score += 25;
+ else if (hoursRatio >= 0.8) score += 16;
+ else score += 6;
+
+ // Determine performance level
+ let level: 'below' | 'at' | 'above';
+ let action: 'coaching' | 'team' | 'none';
+
+ if (score >= 80) {
+ level = 'above';
+ action = 'none';
+ } else if (score >= 55) {
+ level = 'at';
+ action = 'coaching';
+ } else {
+ level = 'below';
+ action = 'team';
+ }
+
+ return { level, action };
+};
+
+export const Quiz: React.FC = ({
+ onComplete,
+ calendlyCoachingLink = 'https://calendly.com/coaching',
+ calendlyTeamLink = 'https://calendly.com/team',
+}) => {
+ const [currentStep, setCurrentStep] = useState(0);
+ const [answers, setAnswers] = useState>({});
+ const [showResults, setShowResults] = useState(false);
+ const [result, setResult] = useState(null);
+
+ const currentQuestion = questions[currentStep];
+ const isLastQuestion = currentStep === questions.length - 1;
+ const progress = ((currentStep + 1) / questions.length) * 100;
+
+ const handleAnswer = (value: string | number) => {
+ setAnswers((prev) => ({
+ ...prev,
+ [currentQuestion.id]: value,
+ }));
+ };
+
+ const getCurrentAnswer = () => {
+ return answers[currentQuestion.id];
+ };
+
+ const canProceed = () => {
+ const answer = getCurrentAnswer();
+ return answer !== undefined && answer !== '';
+ };
+
+ const handleNext = () => {
+ if (!canProceed()) return;
+
+ if (isLastQuestion) {
+ const { level, action } = calculatePerformanceLevel(answers);
+ const finalResult: QuizResult = {
+ yearsInBusiness: answers.yearsInBusiness || '',
+ dealsLast12Months: Number(answers.dealsLast12Months) || 0,
+ leadsPerMonth: Number(answers.leadsPerMonth) || 0,
+ hoursProspecting: Number(answers.hoursProspecting) || 0,
+ performanceLevel: level,
+ recommendedAction: action,
+ };
+ setResult(finalResult);
+ setShowResults(true);
+ onComplete(finalResult);
+ } else {
+ setCurrentStep((prev) => prev + 1);
+ }
+ };
+
+ const handleBack = () => {
+ if (currentStep > 0) {
+ setCurrentStep((prev) => prev - 1);
+ }
+ };
+
+ const handleCalendlyClick = (link: string) => {
+ window.open(link, '_blank', 'noopener,noreferrer');
+ };
+
+ if (showResults && result) {
+ return (
+
+
+
+
+
+
+
+
Your Results
+
+ {result.performanceLevel === 'above' && (
+
+
+ Great work! Based on your responses, you're performing above your peers.
+ Keep up the excellent momentum!
+
+
+ )}
+
+ {result.performanceLevel === 'at' && (
+
+
+ You're performing at peer level. With some additional coaching,
+ you could take your business to the next level.
+
+
+ )}
+
+ {result.performanceLevel === 'below' && (
+
+
+ Based on your peers, you may benefit from additional support.
+ Would you like to learn more about coaching or joining our team?
+
+
+ )}
+
+ {result.performanceLevel !== 'above' && (
+
+ handleCalendlyClick(calendlyCoachingLink)}
+ className="gap-3"
+ >
+
+ Learn about coaching
+
+ handleCalendlyClick(calendlyTeamLink)}
+ className="gap-3"
+ >
+
+ Learn about joining the team
+
+
+ )}
+
+
+
+ Your Responses
+
+
+
+
Experience
+
{result.yearsInBusiness} years
+
+
+
Deals (12 mo)
+
{result.dealsLast12Months}
+
+
+
Leads/Month
+
{result.leadsPerMonth}
+
+
+
Prospecting Hrs/Wk
+
{result.hoursProspecting}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Progress Bar */}
+
+
+ Question {currentStep + 1} of {questions.length}
+ {Math.round(progress)}% complete
+
+
+
+
+
+
+
+ {currentQuestion.question}
+
+
+
+
+ {currentQuestion.type === 'select' && currentQuestion.options && (
+
+ {currentQuestion.options.map((option) => (
+ handleAnswer(option.value)}
+ selected={getCurrentAnswer() === option.value}
+ className="!p-4 !rounded-xl"
+ >
+ {option.label}
+
+ ))}
+
+ )}
+
+ {currentQuestion.type === 'number' && (
+
handleAnswer(e.target.value ? Number(e.target.value) : '')}
+ />
+ )}
+
+
+
+ {currentStep > 0 && (
+
+
+ Back
+
+ )}
+
+ {isLastQuestion ? 'See Results' : 'Continue'}
+
+
+
+
+
+ {/* Input field styles */}
+
+
+ );
+};
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
new file mode 100644
index 0000000..da5ce18
--- /dev/null
+++ b/components/Sidebar.tsx
@@ -0,0 +1,190 @@
+import React, { useState } from 'react';
+import {
+ LayoutDashboard,
+ MessageSquare,
+ Users,
+ TrendingUp,
+ Target,
+ Zap,
+ CheckSquare,
+ BarChart2,
+ ShoppingBag,
+ Wrench,
+ Award,
+ ClipboardCheck,
+ Shield,
+ Menu,
+ X,
+ ChevronRight,
+ User,
+ Sparkles,
+} from 'lucide-react';
+import { ViewState } from '../types';
+
+interface SidebarProps {
+ currentView: ViewState;
+ onNavigate: (view: ViewState) => void;
+ isAdmin: boolean;
+ userName?: string;
+ userEmail?: string;
+}
+
+interface NavItem {
+ id: ViewState;
+ label: string;
+ icon: React.ReactNode;
+ disabled?: boolean;
+ comingSoon?: boolean;
+ adminOnly?: boolean;
+}
+
+const navItems: NavItem[] = [
+ { id: ViewState.DASHBOARD, label: 'Dashboard', icon: },
+ { id: ViewState.CONTROL_CENTER, label: 'Control Center', icon: },
+ { id: ViewState.CONVERSATIONS, label: 'Conversations', icon: },
+ { id: ViewState.CONTACTS, label: 'Contacts', icon: },
+ { id: ViewState.OPPORTUNITIES, label: 'Opportunities', icon: },
+ { id: ViewState.GET_LEADS, label: 'Get Leads', icon: },
+ { id: ViewState.AUTOMATIONS, label: 'Automations', icon: },
+ { id: ViewState.TODO_LIST, label: 'To-Do List', icon: },
+ { id: ViewState.REPORTING, label: 'Reporting', icon: },
+ { id: ViewState.MARKETPLACE, label: 'Town Hall', icon: },
+ { id: ViewState.EXTERNAL_TOOLS, label: 'External Tools', icon: },
+ { id: ViewState.LEADERBOARD, label: 'Leaderboard', icon: , disabled: true, comingSoon: true },
+ { id: ViewState.QUIZ, label: 'Performance Quiz', icon: },
+ { id: ViewState.ADMIN, label: 'Admin', icon: , adminOnly: true },
+];
+
+export const Sidebar: React.FC = ({
+ currentView,
+ onNavigate,
+ isAdmin,
+ userName,
+ userEmail,
+}) => {
+ const [isMobileOpen, setIsMobileOpen] = useState(false);
+
+ const toggleMobile = () => setIsMobileOpen(!isMobileOpen);
+
+ const handleNavClick = (itemId: ViewState, disabled?: boolean) => {
+ if (disabled) return;
+ onNavigate(itemId);
+ setIsMobileOpen(false);
+ };
+
+ // Filter out admin-only items for non-admin users
+ const visibleNavItems = navItems.filter(item => !item.adminOnly || isAdmin);
+
+ const renderNavItem = (item: NavItem) => {
+ const isActive = currentView === item.id;
+ const isDisabled = item.disabled;
+
+ return (
+ handleNavClick(item.id, item.disabled)}
+ disabled={isDisabled}
+ className={`
+ w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left
+ transition-all duration-200 group relative
+ ${isActive
+ ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/30'
+ : isDisabled
+ ? 'text-slate-500 cursor-not-allowed'
+ : 'text-slate-300 hover:bg-slate-800 hover:text-white'
+ }
+ `}
+ >
+
+ {item.icon}
+
+ {item.label}
+ {item.comingSoon && (
+
+ Soon
+
+ )}
+ {isActive && (
+
+ )}
+
+ );
+ };
+
+ const sidebarContent = (
+ <>
+ {/* Logo */}
+
+
+ {/* Navigation */}
+
+ {visibleNavItems.map(renderNavItem)}
+
+
+ {/* User Profile Section */}
+
+
+
+
+
+
+ {userName ? (
+ <>
+
{userName}
+ {userEmail && (
+
{userEmail}
+ )}
+ >
+ ) : (
+
Not signed in
+ )}
+
+
+
+ >
+ );
+
+ return (
+ <>
+ {/* Mobile Hamburger Button */}
+
+ {isMobileOpen ? : }
+
+
+ {/* Mobile Overlay */}
+ {isMobileOpen && (
+ setIsMobileOpen(false)}
+ />
+ )}
+
+ {/* Sidebar - Desktop */}
+
+
+ {/* Sidebar - Mobile */}
+
+ >
+ );
+};
diff --git a/components/TourSimulation.tsx b/components/TourSimulation.tsx
new file mode 100644
index 0000000..353f86d
--- /dev/null
+++ b/components/TourSimulation.tsx
@@ -0,0 +1,72 @@
+import React, { useEffect, useState } from 'react';
+import { Button } from './Button';
+import { MousePointer2, Loader2, Check } from 'lucide-react';
+
+interface Props {
+ type: string;
+ onComplete: () => void;
+}
+
+export const TourSimulation: React.FC
= ({ type, onComplete }) => {
+ const [step, setStep] = useState(0);
+
+ const getSteps = () => {
+ if (type === 'UPLOAD') return ['Navigating to Contacts...', 'Clicking Import CSV...', 'Mapping Fields...', 'Import Confirmed!'];
+ if (type === 'SMS_DIY') return ['Opening Settings...', 'Buying Phone Number...', 'Verifying A2P Brand...', 'SMS Active!'];
+ if (type === 'EMAIL_DIY') return ['Opening Domain Settings...', 'Adding DNS Records...', 'Verifying DKIM/SPF...', 'Email Warmup Started!'];
+ if (type === 'CAMPAIGN_DIY') return ['Selecting Audience...', 'Choosing Template...', 'Setting Schedule...', 'Campaign Launched!'];
+ if (type === 'LEAD_STORE') return ['Accessing Lead Database...', 'Filtering by Criteria...', 'Selecting 50 Leads...', 'Leads Acquired!'];
+ return ['Processing...'];
+ };
+
+ const steps = getSteps();
+
+ useEffect(() => {
+ if (step < steps.length) {
+ const timer = setTimeout(() => {
+ setStep(prev => prev + 1);
+ }, 1500); // 1.5s per step
+ return () => clearTimeout(timer);
+ } else {
+ const timer = setTimeout(() => {
+ onComplete();
+ }, 1000);
+ return () => clearTimeout(timer);
+ }
+ }, [step, steps.length, onComplete]);
+
+ return (
+
+
+
+ {step < steps.length ? (
+
+ ) : (
+
+ )}
+
+
+
+ {step < steps.length ? 'Guided Tour in Progress' : 'Setup Complete!'}
+
+
+
+ {steps.map((text, index) => (
+
step ? 'text-gray-300 opacity-30 blur-[1px]' : ''}
+ `}
+ >
+ {index < step && }
+ {index === step && }
+ {text}
+
+ ))}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/components/admin/SettingsForm.tsx b/components/admin/SettingsForm.tsx
new file mode 100644
index 0000000..2bb073b
--- /dev/null
+++ b/components/admin/SettingsForm.tsx
@@ -0,0 +1,216 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Button } from '@/components/Button';
+import { ClayCard } from '@/components/ClayCard';
+import { Eye, EyeOff, Check, X, Loader2, Save } from 'lucide-react';
+
+interface SettingsFormProps {
+ initialSettings: Record;
+ onSave: (settings: Record) => Promise;
+ onTestGHL: () => Promise<{ success: boolean; error?: string }>;
+ onTestStripe: () => Promise<{ success: boolean; error?: string }>;
+}
+
+export function SettingsForm({
+ initialSettings,
+ onSave,
+ onTestGHL,
+ onTestStripe
+}: SettingsFormProps) {
+ const [settings, setSettings] = useState(initialSettings);
+ const [showSecrets, setShowSecrets] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const [testingGHL, setTestingGHL] = useState(false);
+ const [testingStripe, setTestingStripe] = useState(false);
+ const [ghlStatus, setGhlStatus] = useState<'idle' | 'success' | 'error'>('idle');
+ const [stripeStatus, setStripeStatus] = useState<'idle' | 'success' | 'error'>('idle');
+
+ const handleChange = (key: string, value: string) => {
+ setSettings(prev => ({ ...prev, [key]: value }));
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await onSave(settings);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleTestGHL = async () => {
+ setTestingGHL(true);
+ setGhlStatus('idle');
+ try {
+ const result = await onTestGHL();
+ setGhlStatus(result.success ? 'success' : 'error');
+ } finally {
+ setTestingGHL(false);
+ }
+ };
+
+ const handleTestStripe = async () => {
+ setTestingStripe(true);
+ setStripeStatus('idle');
+ try {
+ const result = await onTestStripe();
+ setStripeStatus(result.success ? 'success' : 'error');
+ } finally {
+ setTestingStripe(false);
+ }
+ };
+
+ const toggleSecret = (key: string) => {
+ setShowSecrets(prev => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ const renderSecretInput = (key: string, label: string, placeholder: string) => (
+
+
{label}
+
+ handleChange(key, e.target.value)}
+ placeholder={placeholder}
+ className="w-full p-3 pr-10 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
+ />
+ toggleSecret(key)}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
+ >
+ {showSecrets[key] ? : }
+
+
+
+ );
+
+ const renderTextInput = (key: string, label: string, placeholder: string) => (
+
+ {label}
+ handleChange(key, e.target.value)}
+ placeholder={placeholder}
+ className="w-full p-3 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
+ />
+
+ );
+
+ return (
+
+ {/* GHL Configuration */}
+
+
+
GoHighLevel Configuration
+
+ {testingGHL ? (
+
+ ) : ghlStatus === 'success' ? (
+
+ ) : ghlStatus === 'error' ? (
+
+ ) : null}
+ Test Connection
+
+
+
+ {renderSecretInput('ghlAgencyApiKey', 'Agency API Key', 'Enter your GHL Agency API key')}
+ {renderTextInput('ghlAgencyId', 'Agency ID', 'Enter your GHL Agency ID')}
+ {renderSecretInput('ghlPrivateToken', 'Private Integration Token', 'Optional: Private integration token')}
+ {renderTextInput('ghlOwnerLocationId', 'Owner Location ID', 'Location ID for tagging (Henry\'s account)')}
+ {renderSecretInput('ghlWebhookSecret', 'Webhook Secret', 'Secret for validating webhooks')}
+
+
+
+ {/* Tag Configuration */}
+
+ Tag Configuration
+
+ These tags will be applied to contacts in the owner's GHL account to trigger automations.
+
+
+ {renderTextInput('tagHighGCI', 'High GCI Tag', 'e.g., high-gci-lead')}
+ {renderTextInput('tagOnboardingComplete', 'Onboarding Complete Tag', 'e.g., onboarding-done')}
+ {renderTextInput('tagDFYRequested', 'DFY Requested Tag', 'e.g., dfy-requested')}
+
+
+
+ {/* Stripe Configuration */}
+
+
+
Stripe Configuration
+
+ {testingStripe ? (
+
+ ) : stripeStatus === 'success' ? (
+
+ ) : stripeStatus === 'error' ? (
+
+ ) : null}
+ Test Connection
+
+
+
+ {renderSecretInput('stripeSecretKey', 'Stripe Secret Key', 'sk_live_... or sk_test_...')}
+ {renderSecretInput('stripeWebhookSecret', 'Stripe Webhook Secret', 'whsec_...')}
+
+
+
+ {/* Pricing Configuration */}
+
+ DFY Pricing (in cents)
+
+ {renderTextInput('dfyPriceFullSetup', 'Full Setup Price', 'e.g., 29700 for $297')}
+ {renderTextInput('dfyPriceSmsSetup', 'SMS Setup Price', 'e.g., 9900 for $99')}
+ {renderTextInput('dfyPriceEmailSetup', 'Email Setup Price', 'e.g., 9900 for $99')}
+
+
+
+ {/* Calendly Links */}
+
+ Calendly Links
+
+ {renderTextInput('calendlyCoachingLink', 'Coaching Calendly Link', 'https://calendly.com/...')}
+ {renderTextInput('calendlyTeamLink', 'Join Team Calendly Link', 'https://calendly.com/...')}
+
+
+
+ {/* Notifications */}
+
+ Notifications
+
+ {renderTextInput('notificationEmail', 'Notification Email', 'Email for high-GCI alerts')}
+
+
+
+ {/* ClickUp Integration */}
+
+ ClickUp Integration (for DFY tasks)
+
+ {renderSecretInput('clickupApiKey', 'ClickUp API Key', 'Enter your ClickUp API key')}
+ {renderTextInput('clickupListId', 'ClickUp List ID', 'List ID for DFY tasks')}
+
+
+
+ {/* Save Button */}
+
+
+ {saving ? : }
+ Save Settings
+
+
+
+ );
+}
diff --git a/components/auth/PermissionButton.tsx b/components/auth/PermissionButton.tsx
new file mode 100644
index 0000000..3d6fcf7
--- /dev/null
+++ b/components/auth/PermissionButton.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { useAuth } from '@/lib/hooks/useAuth';
+import { Permission, hasPermission, hasAnyPermission } from '@/lib/auth/roles';
+import { Role } from '@/types/auth';
+
+interface PermissionButtonProps extends React.ButtonHTMLAttributes {
+ requiredPermissions?: Permission[];
+ anyPermission?: Permission[];
+ hideWhenDisabled?: boolean;
+}
+
+export function PermissionButton({
+ children,
+ requiredPermissions,
+ anyPermission,
+ hideWhenDisabled = false,
+ disabled,
+ ...props
+}: PermissionButtonProps) {
+ const { user } = useAuth();
+
+ const hasAccess = (): boolean => {
+ if (!user) return false;
+
+ const userRole = user.role as Role;
+
+ if (requiredPermissions?.length) {
+ const hasAll = requiredPermissions.every(p => hasPermission(userRole, p));
+ if (!hasAll) return false;
+ }
+
+ if (anyPermission?.length) {
+ if (!hasAnyPermission(userRole, anyPermission)) return false;
+ }
+
+ return true;
+ };
+
+ const canAccess = hasAccess();
+
+ if (!canAccess && hideWhenDisabled) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/auth/ProtectedRoute.tsx b/components/auth/ProtectedRoute.tsx
new file mode 100644
index 0000000..bd2fe71
--- /dev/null
+++ b/components/auth/ProtectedRoute.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { useAuth } from '@/lib/hooks/useAuth';
+import { Permission, hasPermission, hasAnyPermission, isAdmin, isSuperAdmin } from '@/lib/auth/roles';
+import { Role } from '@/types/auth';
+
+interface ProtectedRouteProps {
+ children: React.ReactNode;
+ requiredPermissions?: Permission[];
+ anyPermission?: Permission[];
+ requireAdmin?: boolean;
+ requireSuperAdmin?: boolean;
+ fallback?: React.ReactNode;
+ redirectTo?: string;
+}
+
+export function ProtectedRoute({
+ children,
+ requiredPermissions,
+ anyPermission,
+ requireAdmin,
+ requireSuperAdmin,
+ fallback,
+ redirectTo = '/dashboard',
+}: ProtectedRouteProps) {
+ const { user, loading } = useAuth();
+ const router = useRouter();
+
+ const hasAccess = (): boolean => {
+ if (!user) return false;
+
+ const userRole = user.role as Role;
+
+ if (requireSuperAdmin && !isSuperAdmin(userRole)) return false;
+ if (requireAdmin && !isAdmin(userRole)) return false;
+
+ if (requiredPermissions?.length) {
+ const hasAll = requiredPermissions.every(p => hasPermission(userRole, p));
+ if (!hasAll) return false;
+ }
+
+ if (anyPermission?.length) {
+ if (!hasAnyPermission(userRole, anyPermission)) return false;
+ }
+
+ return true;
+ };
+
+ useEffect(() => {
+ if (!loading && !hasAccess()) {
+ router.push(redirectTo);
+ }
+ }, [user, loading, redirectTo, router]);
+
+ if (loading) {
+ return fallback || ;
+ }
+
+ if (!hasAccess()) {
+ return fallback || null;
+ }
+
+ return <>{children}>;
+}
+
+function LoadingSpinner() {
+ return (
+
+ );
+}
diff --git a/components/auth/RoleGate.tsx b/components/auth/RoleGate.tsx
new file mode 100644
index 0000000..f4629e3
--- /dev/null
+++ b/components/auth/RoleGate.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useAuth } from '@/lib/hooks/useAuth';
+import { Permission, hasPermission, hasAnyPermission, isAdmin, isSuperAdmin } from '@/lib/auth/roles';
+import { Role } from '@/types/auth';
+
+interface RoleGateProps {
+ children: React.ReactNode;
+ requiredPermissions?: Permission[];
+ anyPermission?: Permission[];
+ requireAdmin?: boolean;
+ requireSuperAdmin?: boolean;
+ fallback?: React.ReactNode;
+}
+
+export function RoleGate({
+ children,
+ requiredPermissions,
+ anyPermission,
+ requireAdmin,
+ requireSuperAdmin,
+ fallback = null,
+}: RoleGateProps) {
+ const { user } = useAuth();
+
+ if (!user) return <>{fallback}>;
+
+ const userRole = user.role as Role;
+
+ if (requireSuperAdmin && !isSuperAdmin(userRole)) return <>{fallback}>;
+ if (requireAdmin && !isAdmin(userRole)) return <>{fallback}>;
+
+ if (requiredPermissions?.length) {
+ const hasAll = requiredPermissions.every(p => hasPermission(userRole, p));
+ if (!hasAll) return <>{fallback}>;
+ }
+
+ if (anyPermission?.length) {
+ if (!hasAnyPermission(userRole, anyPermission)) return <>{fallback}>;
+ }
+
+ return <>{children}>;
+}
+
+// Convenience components
+export function AdminOnly({ children, fallback }: { children: React.ReactNode; fallback?: React.ReactNode }) {
+ return {children} ;
+}
+
+export function SuperAdminOnly({ children, fallback }: { children: React.ReactNode; fallback?: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/components/auth/index.ts b/components/auth/index.ts
new file mode 100644
index 0000000..52d5086
--- /dev/null
+++ b/components/auth/index.ts
@@ -0,0 +1,3 @@
+export { ProtectedRoute } from './ProtectedRoute';
+export { RoleGate, AdminOnly, SuperAdminOnly } from './RoleGate';
+export { PermissionButton } from './PermissionButton';
diff --git a/components/control-center/AIMessageBubble.tsx b/components/control-center/AIMessageBubble.tsx
new file mode 100644
index 0000000..bf85a9b
--- /dev/null
+++ b/components/control-center/AIMessageBubble.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import React from 'react';
+import { Bot } from 'lucide-react';
+import type { ControlCenterMessage } from '@/types/control-center';
+import { cn } from '@/lib/utils';
+import { ToolCallCard } from './ToolCallCard';
+import { ToolResultCard } from './ToolResultCard';
+
+interface AIMessageBubbleProps {
+ message: ControlCenterMessage;
+ isStreaming?: boolean;
+}
+
+export const AIMessageBubble: React.FC = ({
+ message,
+ isStreaming = false,
+}) => {
+ const hasToolCalls = message.toolCalls && message.toolCalls.length > 0;
+ const hasToolResults = message.toolResults && message.toolResults.length > 0;
+ const hasContent = message.content && message.content.trim().length > 0;
+
+ return (
+
+ {/* AI Avatar */}
+
+
+
+
+ {/* Message Content */}
+
+ {/* Text Content Bubble */}
+ {(hasContent || isStreaming) && (
+
+ {/* Message Text */}
+
+ {message.content}
+ {isStreaming && (
+
+ |
+
+ )}
+
+
+ {/* Streaming "Thinking" Animation */}
+ {isStreaming && !hasContent && (
+
+
+
+
+
+
+
Thinking...
+
+ )}
+
+ )}
+
+ {/* Tool Calls */}
+ {hasToolCalls && (
+
+ {message.toolCalls!.map((toolCall) => (
+
+ ))}
+
+ )}
+
+ {/* Tool Results */}
+ {hasToolResults && (
+
+ {message.toolResults!.map((toolResult) => (
+
+ ))}
+
+ )}
+
+ {/* Timestamp */}
+
+ {new Date(message.createdAt).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+ );
+};
+
+export default AIMessageBubble;
diff --git a/components/control-center/ChatComposer.tsx b/components/control-center/ChatComposer.tsx
new file mode 100644
index 0000000..b4bba1c
--- /dev/null
+++ b/components/control-center/ChatComposer.tsx
@@ -0,0 +1,214 @@
+'use client';
+
+import React, { useState, useRef, useCallback, useEffect } from 'react';
+import { Send } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+
+type AIProvider = 'claude' | 'openai';
+
+interface ChatComposerProps {
+ /** Callback when a message is sent */
+ onSend: (message: string, provider?: AIProvider) => void;
+ /** Whether the input is disabled */
+ disabled?: boolean;
+ /** Whether a response is currently streaming */
+ isStreaming?: boolean;
+ /** Show provider selector dropdown */
+ showProviderSelector?: boolean;
+}
+
+/**
+ * ChatComposer - Message input area for the Control Center
+ *
+ * Features auto-resizing textarea, send button, optional provider selector,
+ * and keyboard shortcuts for sending messages.
+ */
+export const ChatComposer: React.FC = ({
+ onSend,
+ disabled = false,
+ isStreaming = false,
+ showProviderSelector = false,
+}) => {
+ const [message, setMessage] = useState('');
+ const [provider, setProvider] = useState('claude');
+ const textareaRef = useRef(null);
+
+ const isDisabled = disabled || isStreaming;
+ const canSend = message.trim().length > 0 && !isDisabled;
+
+ // Auto-resize textarea based on content
+ const adjustTextareaHeight = useCallback(() => {
+ const textarea = textareaRef.current;
+ if (textarea) {
+ // Reset height to auto to get the correct scrollHeight
+ textarea.style.height = 'auto';
+ // Set height to scrollHeight, with min and max constraints
+ const newHeight = Math.min(Math.max(textarea.scrollHeight, 44), 200);
+ textarea.style.height = `${newHeight}px`;
+ }
+ }, []);
+
+ useEffect(() => {
+ adjustTextareaHeight();
+ }, [message, adjustTextareaHeight]);
+
+ const handleSend = useCallback(() => {
+ if (!canSend) return;
+
+ const trimmedMessage = message.trim();
+ onSend(trimmedMessage, showProviderSelector ? provider : undefined);
+ setMessage('');
+
+ // Reset textarea height after clearing
+ if (textareaRef.current) {
+ textareaRef.current.style.height = '44px';
+ }
+ }, [canSend, message, onSend, provider, showProviderSelector]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ // Ctrl/Cmd + Enter to send
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+ e.preventDefault();
+ handleSend();
+ }
+ },
+ [handleSend]
+ );
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ setMessage(e.target.value);
+ },
+ []
+ );
+
+ return (
+
+
+ {/* Provider selector (optional) */}
+ {showProviderSelector && (
+
+ Provider:
+ setProvider(value)}
+ disabled={isDisabled}
+ >
+
+
+
+
+ Claude
+ OpenAI
+
+
+
+ )}
+
+ {/* Main input area */}
+
+
+
+
+
+ {/* Send button */}
+
+
+
+
+
+ {/* Keyboard shortcut hint */}
+
+
+ Press Ctrl
+ {' + '}
+ Enter
+ {' to send'}
+
+
+
+
+ );
+};
+
+export default ChatComposer;
diff --git a/components/control-center/ChatInterface.tsx b/components/control-center/ChatInterface.tsx
new file mode 100644
index 0000000..5da3a54
--- /dev/null
+++ b/components/control-center/ChatInterface.tsx
@@ -0,0 +1,206 @@
+'use client';
+
+import React, { useCallback } from 'react';
+import { cn } from '@/lib/utils';
+import { useChatContext } from './ChatProvider';
+import { MessageList } from './MessageList';
+import { ChatComposer } from './ChatComposer';
+import { Sparkles, AlertCircle, X } from 'lucide-react';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+interface ChatInterfaceProps {
+ className?: string;
+}
+
+// =============================================================================
+// Status Indicator Component
+// =============================================================================
+
+interface StatusIndicatorProps {
+ status: 'idle' | 'loading' | 'streaming' | 'error';
+}
+
+function StatusIndicator({ status }: StatusIndicatorProps) {
+ const statusConfig = {
+ idle: {
+ color: 'bg-emerald-400',
+ pulse: false,
+ label: 'Ready',
+ },
+ loading: {
+ color: 'bg-amber-400',
+ pulse: true,
+ label: 'Loading',
+ },
+ streaming: {
+ color: 'bg-indigo-400',
+ pulse: true,
+ label: 'Responding',
+ },
+ error: {
+ color: 'bg-red-400',
+ pulse: false,
+ label: 'Error',
+ },
+ };
+
+ const config = statusConfig[status];
+
+ return (
+
+
+ {config.label}
+
+ );
+}
+
+// =============================================================================
+// Error Banner Component
+// =============================================================================
+
+interface ErrorBannerProps {
+ message: string;
+ onDismiss: () => void;
+}
+
+function ErrorBanner({ message, onDismiss }: ErrorBannerProps) {
+ return (
+
+ );
+}
+
+// =============================================================================
+// Header Component
+// =============================================================================
+
+interface HeaderProps {
+ status: 'idle' | 'loading' | 'streaming' | 'error';
+}
+
+function Header({ status }: HeaderProps) {
+ return (
+
+
+
+
+
+
+
Control Center
+
AI-powered assistant
+
+
+
+
+ );
+}
+
+
+// =============================================================================
+// Main Component
+// =============================================================================
+
+export function ChatInterface({ className }: ChatInterfaceProps) {
+ const {
+ isLoading,
+ isStreaming,
+ error,
+ clearError,
+ sendMessage,
+ } = useChatContext();
+
+ // Determine current status
+ const getStatus = (): 'idle' | 'loading' | 'streaming' | 'error' => {
+ if (error) return 'error';
+ if (isStreaming) return 'streaming';
+ if (isLoading) return 'loading';
+ return 'idle';
+ };
+
+ // Handle message sending from ChatComposer
+ const handleSend = useCallback((message: string, provider?: string) => {
+ sendMessage(message, provider);
+ }, [sendMessage]);
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Error Banner */}
+ {error && (
+
+ )}
+
+ {/* Message List */}
+
+
+
+
+ {/* Chat Composer */}
+
+
+
+
+ );
+}
+
+export default ChatInterface;
diff --git a/components/control-center/ChatProvider.tsx b/components/control-center/ChatProvider.tsx
new file mode 100644
index 0000000..fdbc9ef
--- /dev/null
+++ b/components/control-center/ChatProvider.tsx
@@ -0,0 +1,436 @@
+'use client';
+
+import React, {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ useRef,
+ ReactNode,
+} from 'react';
+import {
+ ControlCenterConversation,
+ ControlCenterMessage,
+ StreamEvent,
+ ToolCall,
+ ToolResult,
+} from '@/types/control-center';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/**
+ * Summary of a conversation for the list view
+ */
+export interface ConversationSummary {
+ id: string;
+ title: string;
+ createdAt: string;
+ updatedAt: string;
+ messageCount: number;
+}
+
+/**
+ * Context state interface
+ */
+interface ChatContextState {
+ /** List of conversation summaries */
+ conversations: ConversationSummary[];
+ /** Currently loaded conversation with full messages */
+ currentConversation: ControlCenterConversation | null;
+ /** Loading state for API calls */
+ isLoading: boolean;
+ /** Whether a response is currently streaming */
+ isStreaming: boolean;
+ /** Current streaming text content */
+ streamingContent: string;
+ /** Error message if any */
+ error: string | null;
+}
+
+/**
+ * Context actions interface
+ */
+interface ChatContextActions {
+ /** Load all conversations from the API */
+ loadConversations: () => Promise;
+ /** Select and load a conversation by ID */
+ selectConversation: (id: string) => Promise;
+ /** Send a message to the current conversation */
+ sendMessage: (message: string, provider?: string) => Promise;
+ /** Start a new conversation */
+ newConversation: () => void;
+ /** Clear the current error */
+ clearError: () => void;
+}
+
+type ChatContextValue = ChatContextState & ChatContextActions;
+
+// =============================================================================
+// Context
+// =============================================================================
+
+const ChatContext = createContext(null);
+
+// =============================================================================
+// Provider Component
+// =============================================================================
+
+interface ChatProviderProps {
+ children: ReactNode;
+}
+
+export function ChatProvider({ children }: ChatProviderProps) {
+ // State
+ const [conversations, setConversations] = useState([]);
+ const [currentConversation, setCurrentConversation] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [streamingContent, setStreamingContent] = useState('');
+ const [error, setError] = useState(null);
+
+ // Abort controller for cancelling streams
+ const abortControllerRef = useRef(null);
+
+ /**
+ * Load all conversations from the API
+ */
+ const loadConversations = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/v1/control-center/conversations');
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || `Failed to load conversations: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setConversations(data.conversations || []);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to load conversations';
+ setError(message);
+ console.error('Error loading conversations:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ /**
+ * Select and load a conversation by ID
+ */
+ const selectConversation = useCallback(async (id: string) => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/v1/control-center/history/${id}`);
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || `Failed to load conversation: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setCurrentConversation(data.conversation);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to load conversation';
+ setError(message);
+ console.error('Error loading conversation:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ /**
+ * Parse SSE data from a chunk
+ */
+ const parseSSEEvents = (chunk: string): StreamEvent[] => {
+ const events: StreamEvent[] = [];
+ const lines = chunk.split('\n');
+
+ for (const line of lines) {
+ const trimmedLine = line.trim();
+ if (trimmedLine.startsWith('data:')) {
+ const jsonStr = trimmedLine.slice(5).trim();
+ if (jsonStr && jsonStr !== '[DONE]') {
+ try {
+ const parsed = JSON.parse(jsonStr);
+ events.push(parsed);
+ } catch (e) {
+ console.warn('Failed to parse SSE event:', jsonStr);
+ }
+ }
+ }
+ }
+
+ return events;
+ };
+
+ /**
+ * Send a message to the current conversation with SSE streaming
+ */
+ const sendMessage = useCallback(async (message: string, provider?: string) => {
+ // Cancel any existing stream
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ setIsLoading(true);
+ setIsStreaming(true);
+ setStreamingContent('');
+ setError(null);
+
+ // Create abort controller for this request
+ abortControllerRef.current = new AbortController();
+
+ // Add user message to conversation optimistically
+ const userMessage: ControlCenterMessage = {
+ id: `temp-${Date.now()}`,
+ role: 'user',
+ content: message,
+ createdAt: new Date().toISOString(),
+ };
+
+ setCurrentConversation((prev) => {
+ if (prev) {
+ return {
+ ...prev,
+ messages: [...prev.messages, userMessage],
+ updatedAt: new Date().toISOString(),
+ };
+ }
+ // Create new conversation if none exists
+ return {
+ id: '',
+ title: message.slice(0, 50) + (message.length > 50 ? '...' : ''),
+ messages: [userMessage],
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ });
+
+ try {
+ const response = await fetch('/api/v1/control-center/chat', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'text/event-stream',
+ },
+ body: JSON.stringify({
+ conversationId: currentConversation?.id || undefined,
+ message,
+ provider: provider || 'openai',
+ }),
+ signal: abortControllerRef.current.signal,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || `Chat request failed: ${response.status}`);
+ }
+
+ if (!response.body) {
+ throw new Error('No response body received');
+ }
+
+ // Process the stream
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let accumulatedContent = '';
+ let conversationId = currentConversation?.id || '';
+ let currentMessageId = '';
+ const toolCalls: ToolCall[] = [];
+ const toolResults: ToolResult[] = [];
+
+ setIsLoading(false);
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ // Decode the chunk and add to buffer
+ buffer += decoder.decode(value, { stream: true });
+
+ // Split by double newline to get complete events
+ const eventChunks = buffer.split('\n\n');
+ buffer = eventChunks.pop() || ''; // Keep incomplete chunk in buffer
+
+ for (const chunk of eventChunks) {
+ const events = parseSSEEvents(chunk);
+
+ for (const event of events) {
+ switch (event.type) {
+ case 'message_start':
+ conversationId = event.conversationId;
+ currentMessageId = event.messageId;
+ // Update conversation ID if it was empty
+ if (!currentConversation?.id) {
+ setCurrentConversation((prev) => prev ? { ...prev, id: conversationId } : prev);
+ }
+ break;
+
+ case 'content_delta':
+ accumulatedContent += event.delta;
+ setStreamingContent(accumulatedContent);
+ break;
+
+ case 'tool_call_start':
+ toolCalls.push(event.toolCall);
+ break;
+
+ case 'tool_result':
+ toolResults.push(event.toolResult);
+ break;
+
+ case 'message_complete':
+ // Replace streaming content with final message
+ setCurrentConversation((prev) => {
+ if (!prev) return prev;
+
+ // Update the user message ID if it was temporary
+ const updatedMessages = prev.messages.map((msg) => {
+ if (msg.id.startsWith('temp-')) {
+ return { ...msg, id: `user-${Date.now()}` };
+ }
+ return msg;
+ });
+
+ // Add the assistant message
+ return {
+ ...prev,
+ id: conversationId || prev.id,
+ messages: [...updatedMessages, event.message],
+ updatedAt: new Date().toISOString(),
+ };
+ });
+ setStreamingContent('');
+ setIsStreaming(false);
+ break;
+
+ case 'error':
+ throw new Error(event.error);
+ }
+ }
+ }
+ }
+
+ // Handle case where stream ends without message_complete
+ if (isStreaming && accumulatedContent) {
+ const assistantMessage: ControlCenterMessage = {
+ id: currentMessageId || `assistant-${Date.now()}`,
+ role: 'assistant',
+ content: accumulatedContent,
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+ toolResults: toolResults.length > 0 ? toolResults : undefined,
+ createdAt: new Date().toISOString(),
+ };
+
+ setCurrentConversation((prev) => {
+ if (!prev) return prev;
+ return {
+ ...prev,
+ id: conversationId || prev.id,
+ messages: [...prev.messages, assistantMessage],
+ updatedAt: new Date().toISOString(),
+ };
+ });
+ }
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') {
+ // Request was aborted, don't treat as error
+ return;
+ }
+
+ const message = err instanceof Error ? err.message : 'Failed to send message';
+ setError(message);
+ console.error('Error sending message:', err);
+
+ // Remove the optimistically added user message on error
+ setCurrentConversation((prev) => {
+ if (!prev) return prev;
+ return {
+ ...prev,
+ messages: prev.messages.filter((msg) => !msg.id.startsWith('temp-')),
+ };
+ });
+ } finally {
+ setIsLoading(false);
+ setIsStreaming(false);
+ setStreamingContent('');
+ abortControllerRef.current = null;
+ }
+ }, [currentConversation?.id]);
+
+ /**
+ * Start a new conversation
+ */
+ const newConversation = useCallback(() => {
+ // Cancel any existing stream
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ setCurrentConversation(null);
+ setStreamingContent('');
+ setIsStreaming(false);
+ setError(null);
+ }, []);
+
+ /**
+ * Clear the current error
+ */
+ const clearError = useCallback(() => {
+ setError(null);
+ }, []);
+
+ // Context value
+ const value: ChatContextValue = {
+ // State
+ conversations,
+ currentConversation,
+ isLoading,
+ isStreaming,
+ streamingContent,
+ error,
+ // Actions
+ loadConversations,
+ selectConversation,
+ sendMessage,
+ newConversation,
+ clearError,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+// =============================================================================
+// Hook
+// =============================================================================
+
+/**
+ * Hook to access the chat context
+ * Must be used within a ChatProvider
+ */
+export function useChatContext(): ChatContextValue {
+ const context = useContext(ChatContext);
+
+ if (!context) {
+ throw new Error('useChatContext must be used within a ChatProvider');
+ }
+
+ return context;
+}
+
+export default ChatProvider;
diff --git a/components/control-center/ConversationSidebar.tsx b/components/control-center/ConversationSidebar.tsx
new file mode 100644
index 0000000..fffd574
--- /dev/null
+++ b/components/control-center/ConversationSidebar.tsx
@@ -0,0 +1,192 @@
+'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 = ({
+ conversations,
+ currentId,
+ onSelect,
+ onNew,
+}) => {
+ return (
+
+ {/* Header with New Chat button */}
+
+
+ {/* Conversation list */}
+
+
+ {conversations.length === 0 ? (
+ // Empty state
+
+
+
+
+
+ No conversations yet
+
+
+ Start a new chat to begin exploring your GHL account
+
+
+ ) : (
+ // Conversation items
+
+ {conversations.map((conversation) => {
+ const isSelected = conversation.id === currentId;
+ const displayTitle = conversation.title || 'New conversation';
+
+ return (
+
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',
+ ]
+ )}
+ >
+
+
+ {displayTitle}
+
+
+ {formatRelativeTime(conversation.createdAt)}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+};
+
+export default ConversationSidebar;
diff --git a/components/control-center/MessageList.tsx b/components/control-center/MessageList.tsx
new file mode 100644
index 0000000..f244205
--- /dev/null
+++ b/components/control-center/MessageList.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import React, { useEffect, useRef } from 'react';
+import { useChatContext } from './ChatProvider';
+import { AIMessageBubble } from './AIMessageBubble';
+import { UserMessageBubble } from './UserMessageBubble';
+import { cn } from '@/lib/utils';
+import type { ControlCenterMessage } from '@/types/control-center';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+interface MessageListProps {
+ className?: string;
+}
+
+// =============================================================================
+// System Message Component
+// =============================================================================
+
+interface SystemMessageProps {
+ message: ControlCenterMessage;
+}
+
+function SystemMessage({ message }: SystemMessageProps) {
+ return (
+
+
+ {message.content}
+
+
+ );
+}
+
+// =============================================================================
+// Empty State Component
+// =============================================================================
+
+function EmptyState() {
+ return (
+
+
+
+ Start a Conversation
+
+
+ Ask me anything about your CRM data, create automations, or get help
+ managing your commercial real estate business.
+
+
+ );
+}
+
+// =============================================================================
+// Loading Indicator
+// =============================================================================
+
+function LoadingIndicator() {
+ return (
+
+ );
+}
+
+// =============================================================================
+// Main Component
+// =============================================================================
+
+export function MessageList({ className }: MessageListProps) {
+ const {
+ currentConversation,
+ isLoading,
+ isStreaming,
+ streamingContent,
+ } = useChatContext();
+
+ const scrollRef = useRef(null);
+ const messagesEndRef = useRef(null);
+
+ // Auto-scroll to bottom when new messages arrive or streaming updates
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [currentConversation?.messages, streamingContent]);
+
+ const messages = currentConversation?.messages || [];
+ const hasMessages = messages.length > 0 || isStreaming || streamingContent;
+
+ return (
+
+
+ {!hasMessages && !isLoading ? (
+
+ ) : (
+
+ {messages.map((message) => {
+ switch (message.role) {
+ case 'user':
+ return (
+
+ );
+ case 'assistant':
+ return (
+
+ );
+ case 'system':
+ return (
+
+ );
+ default:
+ return null;
+ }
+ })}
+
+ {/* Show streaming content */}
+ {isStreaming && streamingContent && (
+
+ )}
+
+ {/* Show loading indicator when waiting for response but not yet streaming */}
+ {isLoading && !isStreaming && !streamingContent && (
+
+ )}
+
+ {/* Scroll anchor */}
+
+
+ )}
+
+
+ );
+}
+
+export default MessageList;
diff --git a/components/control-center/StatusIndicator.tsx b/components/control-center/StatusIndicator.tsx
new file mode 100644
index 0000000..4fe0cdf
--- /dev/null
+++ b/components/control-center/StatusIndicator.tsx
@@ -0,0 +1,225 @@
+'use client';
+
+import React from 'react';
+import { AlertTriangle, Wifi, WifiOff, Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+
+type ConnectionStatus = 'connected' | 'connecting' | 'error' | 'idle';
+
+interface StatusIndicatorProps {
+ /** Current connection status */
+ status: ConnectionStatus;
+ /** Whether MCP (Model Context Protocol) is connected */
+ mcpConnected?: boolean;
+ /** Optional custom label to display */
+ label?: string;
+}
+
+/**
+ * Status configuration for each connection state
+ */
+const STATUS_CONFIG: Record<
+ ConnectionStatus,
+ {
+ color: string;
+ bgColor: string;
+ pulseColor?: string;
+ text: string;
+ icon: React.ReactNode;
+ }
+> = {
+ connected: {
+ color: 'bg-green-500',
+ bgColor: 'bg-green-100',
+ pulseColor: 'bg-green-400',
+ text: 'Connected',
+ icon: ,
+ },
+ connecting: {
+ color: 'bg-yellow-500',
+ bgColor: 'bg-yellow-100',
+ pulseColor: 'bg-yellow-400',
+ text: 'Connecting...',
+ icon: ,
+ },
+ error: {
+ color: 'bg-red-500',
+ bgColor: 'bg-red-100',
+ text: 'Connection Error',
+ icon: ,
+ },
+ idle: {
+ color: 'bg-gray-400',
+ bgColor: 'bg-gray-100',
+ text: 'Idle',
+ icon: ,
+ },
+};
+
+/**
+ * StatusIndicator - Shows connection status for the Control Center
+ *
+ * Displays a colored dot indicator with tooltip showing detailed status.
+ * Also shows warning if MCP is not connected.
+ */
+export const StatusIndicator: React.FC = ({
+ status,
+ mcpConnected = true,
+ label,
+}) => {
+ const config = STATUS_CONFIG[status];
+ const showMcpWarning = !mcpConnected;
+
+ return (
+
+
+ {/* Main status indicator */}
+
+
+
+ {/* Status dot with pulse animation for active states */}
+
+
+ {config.pulseColor && (
+
+ )}
+
+
+ {/* Status text/label */}
+
+ {label || config.text}
+
+
+
+
+
+ {config.icon}
+ {config.text}
+
+
+
+
+ {/* MCP Warning indicator */}
+ {showMcpWarning && (
+
+
+
+
+
+
+
+ MCP Not Connected
+
+
+ Some features may be unavailable. Tool execution will be limited.
+
+
+
+
+ )}
+
+
+ );
+};
+
+/**
+ * Compact version of StatusIndicator - just shows the dot
+ */
+export const StatusDot: React.FC<{
+ status: ConnectionStatus;
+ className?: string;
+}> = ({ status, className }) => {
+ const config = STATUS_CONFIG[status];
+
+ return (
+
+
+
+
+
+ {config.pulseColor && (
+
+ )}
+
+
+
+
+ {config.icon}
+ {config.text}
+
+
+
+
+ );
+};
+
+export default StatusIndicator;
diff --git a/components/control-center/ToolCallCard.tsx b/components/control-center/ToolCallCard.tsx
new file mode 100644
index 0000000..ab66e90
--- /dev/null
+++ b/components/control-center/ToolCallCard.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Wrench, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
+import type { ToolCall } from '@/types/control-center';
+import { cn } from '@/lib/utils';
+
+interface ToolCallCardProps {
+ toolCall: ToolCall;
+ isExecuting?: boolean;
+}
+
+export const ToolCallCard: React.FC = ({
+ toolCall,
+ isExecuting = false,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const hasInput = toolCall.input && Object.keys(toolCall.input).length > 0;
+
+ return (
+
+ {/* Header */}
+
hasInput && setIsExpanded(!isExpanded)}
+ >
+ {/* Tool Icon */}
+
+ {isExecuting ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Tool Name */}
+
+
+
+ {toolCall.name}
+
+ {isExecuting && (
+
+ Executing...
+
+ )}
+
+ {!isExpanded && hasInput && (
+
+ {Object.keys(toolCall.input).length} parameter
+ {Object.keys(toolCall.input).length !== 1 ? 's' : ''}
+
+ )}
+
+
+ {/* Expand/Collapse Toggle */}
+ {hasInput && (
+
+ {isExpanded ? : }
+
+ )}
+
+
+ {/* Collapsible Parameters */}
+ {isExpanded && hasInput && (
+
+
+
+ Parameters
+
+
+ {JSON.stringify(toolCall.input, null, 2)}
+
+
+
+ )}
+
+ );
+};
+
+export default ToolCallCard;
diff --git a/components/control-center/ToolResultCard.tsx b/components/control-center/ToolResultCard.tsx
new file mode 100644
index 0000000..0750540
--- /dev/null
+++ b/components/control-center/ToolResultCard.tsx
@@ -0,0 +1,136 @@
+'use client';
+
+import React, { useState } from 'react';
+import { CheckCircle, XCircle, ChevronDown, ChevronUp, FileCode } from 'lucide-react';
+import type { ToolResult } from '@/types/control-center';
+import { cn } from '@/lib/utils';
+
+interface ToolResultCardProps {
+ toolResult: ToolResult;
+}
+
+// Threshold for auto-collapsing large results
+const LARGE_RESULT_THRESHOLD = 500;
+
+export const ToolResultCard: React.FC = ({ toolResult }) => {
+ const isError = !toolResult.success;
+ const resultData = isError ? toolResult.error : toolResult.result;
+
+ // Determine if result is large and should be collapsed by default
+ const resultString = typeof resultData === 'string'
+ ? resultData
+ : JSON.stringify(resultData, null, 2);
+ const isLargeResult = resultString.length > LARGE_RESULT_THRESHOLD;
+
+ const [isExpanded, setIsExpanded] = useState(!isLargeResult);
+
+ const formatResult = (data: any): string => {
+ if (data === null || data === undefined) {
+ return 'No result data';
+ }
+ if (typeof data === 'string') {
+ return data;
+ }
+ return JSON.stringify(data, null, 2);
+ };
+
+ return (
+
+ {/* Header */}
+
setIsExpanded(!isExpanded)}
+ >
+ {/* Status Icon */}
+
+ {isError ? : }
+
+
+ {/* Result Info */}
+
+
+
+
+ Tool Result
+
+
+
+
+ {isError ? 'Error' : 'Success'}
+
+ {!isExpanded && (
+
+ {resultString.substring(0, 50)}
+ {resultString.length > 50 ? '...' : ''}
+
+ )}
+
+
+
+ {/* Expand/Collapse Toggle */}
+
+ {isExpanded ? : }
+
+
+
+ {/* Collapsible Result Content */}
+ {isExpanded && (
+
+
+
+ {isError ? 'Error Details' : 'Result Data'}
+
+
+ {formatResult(resultData)}
+
+
+
+ )}
+
+ );
+};
+
+export default ToolResultCard;
diff --git a/components/control-center/UserMessageBubble.tsx b/components/control-center/UserMessageBubble.tsx
new file mode 100644
index 0000000..58dc092
--- /dev/null
+++ b/components/control-center/UserMessageBubble.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import React from 'react';
+import type { ControlCenterMessage } from '@/types/control-center';
+import { cn } from '@/lib/utils';
+
+interface UserMessageBubbleProps {
+ message: ControlCenterMessage;
+}
+
+export const UserMessageBubble: React.FC = ({
+ message,
+}) => {
+ return (
+
+
+ {/* Message Bubble */}
+
+
+ {message.content}
+
+
+
+ {/* Timestamp */}
+
+ {new Date(message.createdAt).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+ );
+};
+
+export default UserMessageBubble;
diff --git a/components/control-center/index.ts b/components/control-center/index.ts
new file mode 100644
index 0000000..579c482
--- /dev/null
+++ b/components/control-center/index.ts
@@ -0,0 +1,26 @@
+/**
+ * Control Center Components
+ * CRESyncFlow - Commercial Real Estate CRM
+ *
+ * UI components for the AI-powered Control Center feature.
+ * Includes message display, input, sidebar, and status components.
+ */
+
+// Context and state management
+export { ChatProvider, useChatContext } from './ChatProvider';
+export type { ConversationSummary } from './ChatProvider';
+
+// Main container components
+export { ChatInterface } from './ChatInterface';
+export { MessageList } from './MessageList';
+
+// Message display components
+export { AIMessageBubble } from './AIMessageBubble';
+export { UserMessageBubble } from './UserMessageBubble';
+export { ToolCallCard } from './ToolCallCard';
+export { ToolResultCard } from './ToolResultCard';
+
+// Input and control components
+export { ChatComposer } from './ChatComposer';
+export { ConversationSidebar } from './ConversationSidebar';
+export { StatusIndicator, StatusDot } from './StatusIndicator';
diff --git a/components/realtime/RealtimeProvider.tsx b/components/realtime/RealtimeProvider.tsx
new file mode 100644
index 0000000..d9f4065
--- /dev/null
+++ b/components/realtime/RealtimeProvider.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { createContext, useContext, useEffect, useState } from 'react';
+import { RealtimeEvent, RealtimeMessage, REALTIME_EVENTS } from '@/lib/realtime/events';
+
+interface RealtimeContextValue {
+ isConnected: boolean;
+ subscribe: (event: RealtimeEvent, handler: (data: T) => void) => () => void;
+ lastEvent: RealtimeMessage | null;
+}
+
+const RealtimeContext = createContext(null);
+
+export function RealtimeProvider({ children }: { children: React.ReactNode }) {
+ const [isConnected, setIsConnected] = useState(false);
+ const [lastEvent, setLastEvent] = useState(null);
+ const [handlers, setHandlers] = useState void>>>(new Map());
+
+ useEffect(() => {
+ const eventSource = new EventSource('/api/v1/realtime/events');
+
+ eventSource.onopen = () => {
+ setIsConnected(true);
+ };
+
+ eventSource.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ if (message.type === 'connected') return;
+
+ setLastEvent(message);
+
+ const eventHandlers = handlers.get(message.event);
+ if (eventHandlers) {
+ eventHandlers.forEach(handler => handler(message.data));
+ }
+ } catch (e) {
+ console.error('Parse error:', e);
+ }
+ };
+
+ eventSource.onerror = () => {
+ setIsConnected(false);
+ };
+
+ return () => eventSource.close();
+ }, [handlers]);
+
+ const subscribe = (event: RealtimeEvent, handler: (data: T) => void) => {
+ setHandlers(prev => {
+ const newHandlers = new Map(prev);
+ if (!newHandlers.has(event)) {
+ newHandlers.set(event, new Set());
+ }
+ newHandlers.get(event)!.add(handler);
+ return newHandlers;
+ });
+
+ return () => {
+ setHandlers(prev => {
+ const newHandlers = new Map(prev);
+ newHandlers.get(event)?.delete(handler);
+ return newHandlers;
+ });
+ };
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useRealtimeContext() {
+ const context = useContext(RealtimeContext);
+ if (!context) {
+ throw new Error('useRealtimeContext must be used within RealtimeProvider');
+ }
+ return context;
+}
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 0000000..5afd41d
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..51e507b
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..e87d62b
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..65d4fcd
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,57 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..cabfbfc
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx
new file mode 100644
index 0000000..5d01cd0
--- /dev/null
+++ b/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 0000000..1647513
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..5a20503
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+ svg]:size-4 [&>svg]:shrink-0",
+ inset && "pl-8",
+ className
+ )}
+ {...props}
+ />
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/components/ui/form.tsx b/components/ui/form.tsx
new file mode 100644
index 0000000..96e26c8
--- /dev/null
+++ b/components/ui/form.tsx
@@ -0,0 +1,178 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(null)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ if (!itemContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(null)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..69b64fb
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx
new file mode 100644
index 0000000..4fc3b47
--- /dev/null
+++ b/components/ui/progress.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+
+import { cn } from "@/lib/utils"
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+))
+Progress.displayName = ProgressPrimitive.Root.displayName
+
+export { Progress }
diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..0b4a48d
--- /dev/null
+++ b/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+))
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+))
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+
+export { ScrollArea, ScrollBar }
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..6e637f7
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000..12d81c4
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }
diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx
new file mode 100644
index 0000000..272cb72
--- /dev/null
+++ b/components/ui/sheet.tsx
@@ -0,0 +1,140 @@
+"use client"
+
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ }
+)
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+
+
+ Close
+
+ {children}
+
+
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx
new file mode 100644
index 0000000..5158ed5
--- /dev/null
+++ b/components/ui/sidebar.tsx
@@ -0,0 +1,773 @@
+"use client"
+
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+import { PanelLeft } from "lucide-react"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state"
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+const SIDEBAR_WIDTH = "16rem"
+const SIDEBAR_WIDTH_MOBILE = "18rem"
+const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+}
+
+const SidebarContext = React.createContext(null)
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+const SidebarProvider = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ defaultOpen?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }
+>(
+ (
+ {
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const isMobile = useIsMobile()
+ const [openMobile, setOpenMobile] = React.useState(false)
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen)
+ const open = openProp ?? _open
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value
+ if (setOpenProp) {
+ setOpenProp(openState)
+ } else {
+ _setOpen(openState)
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
+ },
+ [setOpenProp, open]
+ )
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile
+ ? setOpenMobile((open) => !open)
+ : setOpen((open) => !open)
+ }, [isMobile, setOpen, setOpenMobile])
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault()
+ toggleSidebar()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [toggleSidebar])
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed"
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ )
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+ }
+)
+SidebarProvider.displayName = "SidebarProvider"
+
+const Sidebar = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ side?: "left" | "right"
+ variant?: "sidebar" | "floating" | "inset"
+ collapsible?: "offcanvas" | "icon" | "none"
+ }
+>(
+ (
+ {
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ )
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ )
+ }
+)
+Sidebar.displayName = "Sidebar"
+
+const SidebarTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, onClick, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+ {
+ onClick?.(event)
+ toggleSidebar()
+ }}
+ {...props}
+ >
+
+ Toggle Sidebar
+
+ )
+})
+SidebarTrigger.displayName = "SidebarTrigger"
+
+const SidebarRail = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button">
+>(({ className, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+})
+SidebarRail.displayName = "SidebarRail"
+
+const SidebarInset = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"main">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+SidebarInset.displayName = "SidebarInset"
+
+const SidebarInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+SidebarInput.displayName = "SidebarInput"
+
+const SidebarHeader = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+SidebarHeader.displayName = "SidebarHeader"
+
+const SidebarFooter = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+SidebarFooter.displayName = "SidebarFooter"
+
+const SidebarSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+SidebarSeparator.displayName = "SidebarSeparator"
+
+const SidebarContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+SidebarContent.displayName = "SidebarContent"
+
+const SidebarGroup = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+SidebarGroup.displayName = "SidebarGroup"
+
+const SidebarGroupLabel = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "div"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ )
+})
+SidebarGroupLabel.displayName = "SidebarGroupLabel"
+
+const SidebarGroupAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 after:md:hidden",
+ "group-data-[collapsible=icon]:hidden",
+ className
+ )}
+ {...props}
+ />
+ )
+})
+SidebarGroupAction.displayName = "SidebarGroupAction"
+
+const SidebarGroupContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => (
+
+))
+SidebarGroupContent.displayName = "SidebarGroupContent"
+
+const SidebarMenu = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+))
+SidebarMenu.displayName = "SidebarMenu"
+
+const SidebarMenuItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+))
+SidebarMenuItem.displayName = "SidebarMenuItem"
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+const SidebarMenuButton = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & {
+ asChild?: boolean
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps
+ } & VariantProps
+>(
+ (
+ {
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const Comp = asChild ? Slot : "button"
+ const { isMobile, state } = useSidebar()
+
+ const button = (
+
+ )
+
+ if (!tooltip) {
+ return button
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+
+ {button}
+
+
+ )
+ }
+)
+SidebarMenuButton.displayName = "SidebarMenuButton"
+
+const SidebarMenuAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & {
+ asChild?: boolean
+ showOnHover?: boolean
+ }
+>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 after:md:hidden",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ showOnHover &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ )
+})
+SidebarMenuAction.displayName = "SidebarMenuAction"
+
+const SidebarMenuBadge = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => (
+
+))
+SidebarMenuBadge.displayName = "SidebarMenuBadge"
+
+const SidebarMenuSkeleton = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ showIcon?: boolean
+ }
+>(({ className, showIcon = false, ...props }, ref) => {
+ // Random width between 50 to 90%.
+ const width = React.useMemo(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ }, [])
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+})
+SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
+
+const SidebarMenuSub = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+))
+SidebarMenuSub.displayName = "SidebarMenuSub"
+
+const SidebarMenuSubItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ ...props }, ref) => )
+SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
+
+const SidebarMenuSubButton = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentProps<"a"> & {
+ asChild?: boolean
+ size?: "sm" | "md"
+ isActive?: boolean
+ }
+>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+ span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ size === "sm" && "text-xs",
+ size === "md" && "text-sm",
+ "group-data-[collapsible=icon]:hidden",
+ className
+ )}
+ {...props}
+ />
+ )
+})
+SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+}
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..d7e45f7
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+