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 ( + + ); + })} +
+ ); +} + +// ============================================================================= +// 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 ? ( +
+ +

No recent signups

+
+ ) : ( +
+ {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 */} +
+ + + + + +
+
+
+ + {/* Users Table */} +
+ {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : ( +
+ + + + + + + + + + + + + {filteredAndSortedUsers.map((user) => { + const userIsHighGCI = isHighGCI(user.gciRange); + const userHasIncompleteSetup = hasIncompleteSetup(user.setupStatus); + + return ( + + + + + + + + + ); + })} + +
handleSort('name')} + > + Name + handleSort('email')} + > + Email + handleSort('brokerage')} + > + Brokerage + handleSort('gci')} + > + GCI Range + handleSort('setupCompletion')} + > + Setup Status + handleSort('createdAt')} + > + Joined +
+
+
+ {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) => ( +
+ +
+ 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" + /> + +
+
+ ); + + const renderTextInput = (key: string, label: string, placeholder: string) => ( +
+ + 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 +

+ +
+

+ 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

+ +
+
+ {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 */} +
+ +
+
+ ); +} + +// ============================================================================= +// 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 ? ( +
+ setEmail(e.target.value)} + className="flex-1 px-4 py-3 rounded-xl border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" + required + /> + +
+ ) : ( +
+ + 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. +

+ + + ) : ( + <> +

No contacts yet

+

+ Get started by adding your first contact or import contacts from a CSV file. +

+ + + )} +
+); + +// ============================================================================= +// Error State Component +// ============================================================================= + +const ErrorState: React.FC<{ error: string; onRetry: () => void }> = ({ error, onRetry }) => ( +
+
+ +
+

Failed to load contacts

+

{error}

+ +
+); + +// ============================================================================= +// 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 */} + + ); +}; + +// ============================================================================= +// 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 */} + + ); +}; + +// ============================================================================= +// 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 */} + + ); +}; + +// ============================================================================= +// 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 ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} aria-hidden="true" /> +
+ + + +
+ + )} +
+ ); +}; + +// ============================================================================= +// 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 ( + + + + ); +}; + +// ============================================================================= +// 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.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, +}) => ( +
+ + + + + + + + + + + + + {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)}`} + > + + + + + + + + ))} + +
+ Tags + Actions
+
+
+ +
+
+

+ {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 ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} aria-hidden="true" /> +
+ {FILTER_OPTIONS.map((option) => ( + + ))} +
+ + )} +
+ ); +}; + +// ============================================================================= +// 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

+
+ +
+
+ + {/* 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 && ( + + )} +
+ +
+ + {/* Active filter indicator */} + {(filterValue !== 'all' || searchQuery) && ( +
+ Active filters: + {filterValue !== 'all' && ( + + {FILTER_OPTIONS.find(opt => opt.value === filterValue)?.label} + + + )} + {searchQuery && ( + + Search: "{searchQuery}" + + + )} + +
+ )} + + {/* 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 */} +
+ + + +
+ + {/* 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

+ +
+ + {/* 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 */} +
+
+

Messages

+ +
+ + {/* 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 && ( + + )} +
+ + {/* Filter Tabs */} +
+ {filters.map((filter) => ( + + ))} +
+
+ + {/* 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' && ( + + )} +
+ ) : ( + 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: +
+ + +
+
+ + {/* Composer */} +
+
+