Add CRESync CRM application with Setup page

- Build complete Next.js CRM for commercial real estate
- Add authentication with JWT sessions and role-based access
- Add GoHighLevel API integration for contacts, conversations, opportunities
- Add AI-powered Control Center with tool calling
- Add Setup page with onboarding checklist (/setup)
- Add sidebar navigation with Setup menu item
- Fix type errors in onboarding API, GHL services, and control center tools
- Add Prisma schema with SQLite for local development
- Add UI components with clay morphism design system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
BusyBee3333 2026-01-14 17:30:55 -05:00
parent cda952f5f7
commit 4e6467ffb0
190 changed files with 34117 additions and 174 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cresync?schema=public"

13
.gitignore vendored
View File

@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel
@ -39,3 +40,15 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma
# local database
dev.db
dev.db-journal
# claude code
.claude/
# playwright mcp
.playwright-mcp/

View File

@ -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);
});
});
});

View File

@ -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'
);
});
});
});

View File

@ -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();
});
});
});

46
__tests__/setup.ts Normal file
View File

@ -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();

View File

@ -0,0 +1,19 @@
'use client';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute
requireAdmin
redirectTo="/dashboard"
fallback={
<div className="p-8 text-center">
<p className="text-gray-500">Checking permissions...</p>
</div>
}
>
{children}
</ProtectedRoute>
);
}

1127
app/(app)/admin/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<div className="space-y-8">
{/* Hero Section */}
<div className="clay-card shadow-lg border border-border/50 max-w-4xl mx-auto text-center p-8 lg:p-12 mb-8">
{/* Animated Icon Stack */}
<div className="relative w-24 h-24 mx-auto mb-8">
<div className="absolute inset-0 clay-icon-btn w-24 h-24 flex items-center justify-center">
<Zap className="w-12 h-12 text-primary" />
</div>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-amber-100 rounded-lg flex items-center justify-center border border-amber-200 shadow-sm">
<Sparkles className="w-4 h-4 text-amber-600" />
</div>
<div className="absolute -bottom-2 -left-2 w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center border border-emerald-200 shadow-sm">
<Play className="w-4 h-4 text-emerald-600" />
</div>
</div>
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/10 text-primary rounded-full text-sm font-medium mb-6">
<Clock className="w-4 h-4" />
Coming Q2 2026
</div>
<h1 className="text-3xl lg:text-4xl font-bold text-foreground mb-4">
Workflow Automations
</h1>
<p className="text-lg text-slate-500 mb-4 max-w-2xl mx-auto leading-relaxed">
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.
</p>
<p className="text-slate-500 mb-8 max-w-xl mx-auto">
From simple email sequences to complex multi-channel workflows with conditional logic,
automations will help you scale your business while maintaining a personal touch.
</p>
{/* Email Signup */}
{!submitted ? (
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 max-w-md mx-auto mb-4">
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => 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
/>
<button type="submit" className="clay-btn-primary px-6 py-3 font-medium inline-flex items-center justify-center gap-2">
<Bell className="w-4 h-4" />
Notify Me
</button>
</form>
) : (
<div className="flex items-center justify-center gap-2 text-emerald-600 bg-emerald-50 px-6 py-3 rounded-xl max-w-md mx-auto mb-4">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">You&apos;re on the list! We&apos;ll notify you when it&apos;s ready.</span>
</div>
)}
<p className="text-sm text-slate-500">
Be the first to know when automations launch. No spam, just updates.
</p>
</div>
{/* Features Preview */}
<div className="max-w-4xl mx-auto">
<h2 className="text-xl font-semibold text-foreground text-center mb-6">
What&apos;s Coming
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcomingFeatures.map((feature, index) => (
<div
key={index}
className="clay-card p-5 opacity-75 border border-dashed border-border/70"
>
<div className="w-10 h-10 bg-muted rounded-lg flex items-center justify-center mb-3">
<feature.icon className="w-5 h-5 text-muted-foreground" />
</div>
<h3 className="font-medium text-foreground mb-1">{feature.title}</h3>
<p className="text-sm text-slate-500">{feature.description}</p>
</div>
))}
</div>
{/* Workflow Preview (Disabled/Grayed) */}
<div className="clay-card p-6 mt-8 opacity-50 pointer-events-none select-none border border-dashed border-border">
<div className="flex items-center gap-2 mb-4 text-muted-foreground">
<GitBranch className="w-5 h-5" />
<span className="font-medium">Workflow Builder Preview</span>
<span className="ml-auto text-xs bg-muted px-2 py-1 rounded">Coming Soon</span>
</div>
<div className="flex items-center justify-center gap-4 py-8">
{/* Trigger */}
<div className="w-32 h-20 bg-muted/50 rounded-xl border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center">
<Target className="w-6 h-6 text-muted-foreground/50 mb-1" />
<span className="text-xs text-muted-foreground/50">Trigger</span>
</div>
<ArrowRight className="w-6 h-6 text-muted-foreground/30" />
{/* Condition */}
<div className="w-32 h-20 bg-muted/50 rounded-xl border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center">
<GitBranch className="w-6 h-6 text-muted-foreground/50 mb-1" />
<span className="text-xs text-muted-foreground/50">Condition</span>
</div>
<ArrowRight className="w-6 h-6 text-muted-foreground/30" />
{/* Action */}
<div className="w-32 h-20 bg-muted/50 rounded-xl border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center">
<Mail className="w-6 h-6 text-muted-foreground/50 mb-1" />
<span className="text-xs text-muted-foreground/50">Action</span>
</div>
</div>
</div>
</div>
</div>
);
}

1665
app/(app)/contacts/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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 (
<div className="relative flex-shrink-0">
<div
className={`
${sizeClasses[size]} ${getAvatarColor(name)}
rounded-full flex items-center justify-center font-semibold text-white
shadow-md
`}
>
{getInitials(name)}
</div>
{showOnlineIndicator && (
<div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-emerald-500 border-2 border-white rounded-full" />
)}
</div>
);
}
// Conversation List Item
const ConversationItem = React.memo(function ConversationItem({
conversation,
isSelected,
onClick
}: {
conversation: Conversation;
isSelected: boolean;
onClick: () => void;
}) {
const isUnread = conversation.unreadCount > 0;
return (
<div
onClick={onClick}
className={`
group mx-2 mb-2 p-4 cursor-pointer transition-all duration-200 rounded-xl
border border-transparent
${isSelected
? 'bg-primary/15 border-primary/30 shadow-sm'
: 'hover:bg-muted/70 hover:border-border/50'
}
`}
>
<div className="flex items-center gap-4">
{/* Avatar with channel indicator */}
<div className="relative">
<Avatar name={conversation.contact.name} />
<div
className={`
absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
${conversation.channel === 'sms' ? 'bg-emerald-500' : 'bg-blue-500'}
shadow-sm ring-2 ring-background
`}
>
{conversation.channel === 'sms' ? (
<MessageSquare className="w-2.5 h-2.5 text-white" />
) : (
<Mail className="w-2.5 h-2.5 text-white" />
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`
truncate max-w-[140px]
${isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'}
`}
>
{conversation.contact.name}
</span>
{conversation.isStarred && (
<Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500 flex-shrink-0" />
)}
</div>
<span
className={`
text-xs flex-shrink-0 ml-4
${isUnread ? 'text-primary font-medium' : 'text-slate-500'}
`}
>
{formatMessageTime(conversation.lastMessageTime)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<p
className={`
text-sm line-clamp-1
${isUnread ? 'text-foreground/90' : 'text-slate-500'}
`}
style={{
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{conversation.lastMessage}
</p>
{isUnread && (
<span className="flex-shrink-0 min-w-[20px] h-5 px-1.5 bg-primary text-primary-foreground text-xs font-semibold rounded-full flex items-center justify-center">
{conversation.unreadCount}
</span>
)}
</div>
</div>
</div>
</div>
);
});
// Conversation List (Left Panel)
function ConversationList({
conversations,
selectedId,
onSelect,
searchQuery,
onSearchChange,
activeFilter,
onFilterChange,
isLoading,
onNewMessage
}: {
conversations: Conversation[];
selectedId: string | null;
onSelect: (id: string) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
activeFilter: FilterTab;
onFilterChange: (filter: FilterTab) => void;
isLoading: boolean;
onNewMessage: () => void;
}) {
const filters: { id: FilterTab; label: string; count?: number }[] = [
{ id: 'all', label: 'All' },
{ id: 'unread', label: 'Unread', count: conversations.filter(c => c.unreadCount > 0).length },
{ id: 'starred', label: 'Starred', count: conversations.filter(c => c.isStarred).length },
];
return (
<div className="flex flex-col h-full bg-background rounded-2xl border border-border/50 shadow-sm overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-border/50">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground">Messages</h2>
<button
onClick={onNewMessage}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-xl hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={16} />
New
</button>
</div>
{/* Search */}
<div className="relative mb-5">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-500 pointer-events-none" size={18} />
<input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className={`w-full pl-12 ${searchQuery ? 'pr-12' : 'pr-4'} py-3 bg-muted/50 border border-border/50 rounded-xl text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all`}
/>
{searchQuery && (
<button
onClick={() => onSearchChange('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X size={14} />
</button>
)}
</div>
{/* Filter Tabs */}
<div className="flex gap-1 p-1 bg-muted/50 rounded-xl">
{filters.map((filter) => (
<button
key={filter.id}
onClick={() => onFilterChange(filter.id)}
className={`
flex-1 py-2.5 px-4 text-sm font-medium rounded-lg transition-all duration-200
flex items-center justify-center gap-2
${activeFilter === filter.id
? 'bg-background text-primary shadow-sm'
: 'text-slate-500 hover:text-foreground hover:bg-background/50'
}
`}
>
{filter.label}
{filter.count !== undefined && filter.count > 0 && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
activeFilter === filter.id
? 'bg-primary/10 text-primary'
: 'bg-slate-200 text-slate-600'
}`}>
{filter.count}
</span>
)}
</button>
))}
</div>
</div>
{/* Conversation List */}
<div className="flex-1 overflow-y-auto pt-3 pb-2">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
<p className="text-sm text-slate-500">Loading conversations...</p>
</div>
</div>
) : conversations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center mb-5">
{searchQuery ? (
<Search className="text-slate-500" size={28} />
) : activeFilter === 'unread' ? (
<Inbox className="text-slate-500" size={28} />
) : activeFilter === 'starred' ? (
<Star className="text-slate-500" size={28} />
) : (
<MessageSquare className="text-slate-500" size={28} />
)}
</div>
<p className="text-foreground font-medium mb-2">
{searchQuery
? 'No results found'
: activeFilter === 'unread'
? 'All caught up!'
: activeFilter === 'starred'
? 'No starred conversations'
: 'No conversations yet'
}
</p>
<p className="text-slate-500 text-sm mb-6">
{searchQuery
? 'Try a different search term'
: activeFilter === 'unread'
? 'You have no unread messages'
: activeFilter === 'starred'
? 'Star important conversations to find them here'
: 'Start a conversation to get going'
}
</p>
{!searchQuery && activeFilter === 'all' && (
<button
onClick={onNewMessage}
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
New Message
</button>
)}
</div>
) : (
conversations.map((conversation) => (
<ConversationItem
key={conversation.id}
conversation={conversation}
isSelected={selectedId === conversation.id}
onClick={() => onSelect(conversation.id)}
/>
))
)}
</div>
</div>
);
}
// Message Bubble
function MessageBubble({ message }: { message: Message }) {
const isOutbound = message.direction === 'outbound';
const StatusIcon = () => {
switch (message.status) {
case 'pending':
return <Clock className="w-3.5 h-3.5 text-white/60" />;
case 'sent':
return <Check className="w-3.5 h-3.5 text-white/60" />;
case 'delivered':
return <CheckCheck className="w-3.5 h-3.5 text-white/60" />;
case 'read':
return <CheckCheck className="w-3.5 h-3.5 text-emerald-300" />;
default:
return null;
}
};
return (
<div className={`flex ${isOutbound ? 'justify-end' : 'justify-start'} mb-3`}>
<div
className={`
max-w-[80%] px-4 py-3 rounded-2xl
${isOutbound
? 'bg-primary text-primary-foreground rounded-br-lg'
: 'bg-muted text-foreground rounded-bl-lg'
}
`}
>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
<div className={`flex items-center gap-1.5 mt-1.5 ${isOutbound ? 'justify-end' : 'justify-start'}`}>
<span className={`text-xs ${isOutbound ? 'text-white/70' : 'text-slate-400'}`}>
{formatFullTime(message.timestamp)}
</span>
{isOutbound && <StatusIcon />}
</div>
</div>
</div>
);
}
// Message Composer
function MessageComposer({
channel,
onChannelChange,
message,
onMessageChange,
onSend,
isSending
}: {
channel: 'sms' | 'email';
onChannelChange: (channel: 'sms' | 'email') => void;
message: string;
onMessageChange: (message: string) => void;
onSend: () => void;
isSending: boolean;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (message.trim()) {
onSend();
}
}
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 150) + 'px';
}
}, [message]);
return (
<div className="p-5 border-t border-border/50 bg-background">
{/* Channel Toggle */}
<div className="flex items-center gap-3 mb-4">
<span className="text-sm text-slate-500">Send via:</span>
<div className="flex gap-1 p-1 bg-muted/50 rounded-xl">
<button
onClick={() => onChannelChange('sms')}
className={`
flex items-center gap-2 py-2 px-4 text-sm font-medium rounded-lg transition-all
${channel === 'sms'
? 'bg-background text-emerald-600 shadow-sm'
: 'text-slate-500 hover:text-foreground hover:bg-background/50'
}
`}
>
<MessageSquare size={16} />
SMS
</button>
<button
onClick={() => onChannelChange('email')}
className={`
flex items-center gap-2 py-2 px-4 text-sm font-medium rounded-lg transition-all
${channel === 'email'
? 'bg-background text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-foreground hover:bg-background/50'
}
`}
>
<Mail size={16} />
Email
</button>
</div>
</div>
{/* Composer */}
<div className="flex items-end gap-3">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => onMessageChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={channel === 'sms' ? 'Type a message...' : 'Compose your email...'}
rows={1}
className="w-full pr-24 py-3.5 px-4 bg-muted/50 border border-border/50 rounded-2xl text-sm resize-none placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all"
/>
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors">
<Paperclip size={18} />
</button>
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors">
<Smile size={18} />
</button>
</div>
</div>
<button
onClick={onSend}
disabled={!message.trim() || isSending}
className="p-3.5 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 disabled:opacity-40 disabled:bg-primary/70 disabled:cursor-not-allowed disabled:shadow-none transition-all shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2"
>
{isSending ? (
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
) : (
<Send size={20} />
)}
</button>
</div>
<p className="text-xs text-slate-400 mt-3">
Press <kbd className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-500 font-mono text-[11px]">Enter</kbd> to send, <kbd className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-500 font-mono text-[11px]">Shift + Enter</kbd> for new line
</p>
</div>
);
}
// Empty State Illustration
function EmptyStateIllustration() {
return (
<div className="relative w-48 h-48 mx-auto mb-8">
{/* Background circles */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-48 h-48 rounded-full bg-primary/5 animate-pulse" />
</div>
<div className="absolute inset-4 flex items-center justify-center">
<div className="w-40 h-40 rounded-full bg-primary/10" />
</div>
{/* Message bubbles illustration */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative">
{/* Main message icon */}
<div className="w-20 h-20 bg-primary/20 rounded-2xl flex items-center justify-center shadow-lg">
<MessageSquare className="w-10 h-10 text-primary" />
</div>
{/* Floating elements */}
<div className="absolute -top-3 -right-3 w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center shadow-md animate-bounce">
<MessageSquare className="w-4 h-4 text-white" />
</div>
<div className="absolute -bottom-2 -left-4 w-7 h-7 bg-blue-500 rounded-full flex items-center justify-center shadow-md" style={{ animationDelay: '0.2s' }}>
<Mail className="w-3.5 h-3.5 text-white" />
</div>
<div className="absolute top-1/2 -right-6 w-6 h-6 bg-amber-500 rounded-full flex items-center justify-center shadow-md">
<Star className="w-3 h-3 text-white" />
</div>
</div>
</div>
</div>
);
}
// Message Thread (Right Panel)
function MessageThread({
conversation,
onBack,
showBackButton,
onNewMessage
}: {
conversation: Conversation | null;
onBack: () => void;
showBackButton: boolean;
onNewMessage: () => void;
}) {
const [message, setMessage] = useState('');
const [channel, setChannel] = useState<'sms' | 'email'>('sms');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (conversation) {
setChannel(conversation.channel);
}
}, [conversation]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [conversation?.messages]);
const handleSend = async () => {
if (!message.trim() || !conversation) return;
setIsSending(true);
try {
// Send message via GHL API
if (conversation.channel === 'sms') {
await api.conversations.sendSMS(conversation.contact.id, message);
} else {
await api.conversations.sendEmail(conversation.contact.id, 'Reply', message);
}
setMessage('');
// Refresh messages would happen here via realtime or refetch
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setIsSending(false);
}
};
if (!conversation) {
return (
<div className="flex flex-col h-full bg-background rounded-2xl border border-border/50 shadow-sm overflow-hidden">
<div className="flex items-center justify-center h-full">
<div className="text-center p-10 max-w-md">
<EmptyStateIllustration />
<h3 className="text-2xl font-semibold text-foreground mb-4">Your Messages</h3>
<p className="text-slate-500 mb-8 leading-relaxed">
Select a conversation from the list to view your messages, or start a new conversation to connect with clients and leads.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={onNewMessage}
className="flex items-center justify-center gap-2 px-6 py-3 bg-primary text-primary-foreground font-medium rounded-xl hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={18} />
New Message
</button>
<button className="flex items-center justify-center gap-2 px-6 py-3 bg-muted text-foreground font-medium rounded-xl hover:bg-muted/80 transition-colors">
<Users size={18} />
View Contacts
</button>
</div>
{/* Quick tips */}
<div className="mt-10 pt-8 border-t border-border/50">
<p className="text-xs text-slate-500 mb-4">Quick tips</p>
<div className="flex flex-wrap justify-center gap-3">
<span className="px-3 py-1.5 bg-muted/50 text-slate-500 text-xs rounded-full">
Press Enter to send
</span>
<span className="px-3 py-1.5 bg-muted/50 text-slate-500 text-xs rounded-full">
Star important chats
</span>
<span className="px-3 py-1.5 bg-muted/50 text-slate-500 text-xs rounded-full">
Search to filter
</span>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-background rounded-2xl border border-border/50 shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border/50">
{showBackButton && (
<button
onClick={onBack}
className="p-2 -ml-2 text-slate-500 hover:text-foreground hover:bg-muted rounded-lg transition-colors lg:hidden"
>
<ArrowLeft size={20} />
</button>
)}
{/* Contact Avatar */}
<Avatar name={conversation.contact.name} />
{/* Contact Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground truncate">{conversation.contact.name}</h3>
{conversation.isStarred && (
<Star className="w-4 h-4 text-amber-500 fill-amber-500 flex-shrink-0" />
)}
</div>
<p className="text-sm text-slate-500 truncate">
{conversation.channel === 'sms' ? conversation.contact.phone : conversation.contact.email}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<button className="p-2 text-slate-500 hover:text-foreground hover:bg-muted rounded-lg transition-colors">
<Phone size={18} />
</button>
<button className="p-2 text-slate-500 hover:text-foreground hover:bg-muted rounded-lg transition-colors">
<Star size={18} className={conversation.isStarred ? 'text-amber-500 fill-amber-500' : ''} />
</button>
<button className="p-2 text-slate-500 hover:text-foreground hover:bg-muted rounded-lg transition-colors">
<MoreVertical size={18} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{/* Date separator example */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-border/50" />
<span className="text-xs text-slate-500 px-2">Today</span>
<div className="flex-1 h-px bg-border/50" />
</div>
{conversation.messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
{/* Composer */}
<MessageComposer
channel={channel}
onChannelChange={setChannel}
message={message}
onMessageChange={setMessage}
onSend={handleSend}
isSending={isSending}
/>
</div>
);
}
// Main Page Component
export default function ConversationsPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeFilter, setActiveFilter] = useState<FilterTab>('all');
const [isLoading, setIsLoading] = useState(true);
const [isMobileThreadOpen, setIsMobileThreadOpen] = useState(false);
// Load conversations from GHL
useEffect(() => {
let isMounted = true;
const loadConversations = async () => {
setIsLoading(true);
try {
const response = await api.conversations.getAll({ limit: 50 });
if (isMounted && response.data) {
// Map GHL conversations to our format
const ghlConversations: Conversation[] = response.data.map((conv: any) => ({
id: conv.id,
contact: {
id: conv.contactId || conv.contact?.id || '',
name: conv.contactName || conv.contact?.name || conv.contact?.firstName + ' ' + (conv.contact?.lastName || '') || 'Unknown',
email: conv.contact?.email || '',
phone: conv.contact?.phone || '',
},
lastMessage: conv.lastMessageBody || conv.snippet || '',
lastMessageTime: new Date(conv.lastMessageDate || conv.dateUpdated || Date.now()),
unreadCount: conv.unreadCount || 0,
isStarred: conv.starred || false,
channel: conv.type === 'SMS' ? 'sms' : 'email',
messages: [], // Messages loaded separately when conversation is selected
}));
setConversations(ghlConversations);
}
} catch (err: any) {
console.error('Failed to fetch conversations:', err);
// Fall back to empty state - don't use mock data in production
if (isMounted) {
setConversations([]);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadConversations();
return () => {
isMounted = false;
};
}, []);
// Filter conversations with memoization to prevent unnecessary recalculations
const filteredConversations = useMemo(() => {
return conversations.filter((conv) => {
// Search filter
const searchLower = searchQuery.toLowerCase();
const matchesSearch = searchQuery === '' ||
conv.contact.name.toLowerCase().includes(searchLower) ||
conv.contact.email.toLowerCase().includes(searchLower) ||
conv.lastMessage.toLowerCase().includes(searchLower);
// Tab filter
let matchesFilter = true;
if (activeFilter === 'unread') {
matchesFilter = conv.unreadCount > 0;
} else if (activeFilter === 'starred') {
matchesFilter = conv.isStarred;
}
return matchesSearch && matchesFilter;
});
}, [conversations, searchQuery, activeFilter]);
const selectedConversation = useMemo(() => {
return conversations.find(c => c.id === selectedConversationId) || null;
}, [conversations, selectedConversationId]);
const handleSelectConversation = useCallback((id: string) => {
setSelectedConversationId(id);
setIsMobileThreadOpen(true);
}, []);
const handleBackToList = useCallback(() => {
setIsMobileThreadOpen(false);
}, []);
const handleNewMessage = useCallback(() => {
// Placeholder for new message functionality
console.log('New message clicked');
}, []);
return (
<div className="h-[calc(100vh-8rem)]">
{/* Desktop Layout */}
<div className="hidden lg:grid lg:grid-cols-[380px_1fr] gap-4 h-full">
{/* Left Panel - Conversation List */}
<ConversationList
conversations={filteredConversations}
selectedId={selectedConversationId}
onSelect={handleSelectConversation}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
isLoading={isLoading}
onNewMessage={handleNewMessage}
/>
{/* Right Panel - Message Thread */}
<MessageThread
conversation={selectedConversation}
onBack={handleBackToList}
showBackButton={false}
onNewMessage={handleNewMessage}
/>
</div>
{/* Mobile Layout */}
<div className="lg:hidden h-full">
{/* List View */}
<div className={`h-full ${isMobileThreadOpen ? 'hidden' : 'block'}`}>
<ConversationList
conversations={filteredConversations}
selectedId={selectedConversationId}
onSelect={handleSelectConversation}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
isLoading={isLoading}
onNewMessage={handleNewMessage}
/>
</div>
{/* Thread View (Full Screen) */}
<div className={`h-full ${isMobileThreadOpen ? 'block' : 'hidden'}`}>
<MessageThread
conversation={selectedConversation}
onBack={handleBackToList}
showBackButton={true}
onNewMessage={handleNewMessage}
/>
</div>
</div>
</div>
);
}

View File

@ -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<DashboardStats>({
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 (
<div className="space-y-12 pb-8">
{/* Header Section - Level 1 (subtle) since it's a page header, not main content */}
<div className="clay-card-subtle p-6 border border-border/50">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-3xl lg:text-4xl font-bold text-foreground mb-2">
Welcome back{user?.firstName && <span className="text-primary">, {user.firstName}</span>}
</h1>
<p className="text-slate-500 text-lg">Here&apos;s what&apos;s happening with your portfolio today</p>
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-full border transition-all ${
isConnected
? 'bg-emerald-50 border-emerald-200'
: 'bg-amber-50 border-amber-200'
}`}>
{isConnected ? (
<>
<Wifi className="w-4 h-4 text-emerald-600" />
<span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />
<span className="text-sm font-semibold text-emerald-700">Connected</span>
</>
) : (
<>
<WifiOff className="w-4 h-4 text-amber-600" />
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
<span className="text-sm font-semibold text-amber-700">Connecting...</span>
</>
)}
</div>
</div>
</div>
{/* Stats Grid */}
<div>
{/* Section header - Level 1 (subtle) */}
<div className="clay-card-subtle p-5 border border-border/50 mb-6">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-slate-100 flex items-center justify-center">
<BarChart3 className="w-5 h-5 text-slate-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-foreground">Overview</h2>
<p className="text-sm text-slate-500">Your key metrics at a glance</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
<StatCard
title="Total Contacts"
value={stats.totalContacts}
icon={Users}
loading={loading}
trend={stats.totalContacts > 0 ? "+12%" : undefined}
trendUp
emptyMessage="No contacts yet"
emptyAction={{ label: "Import contacts", href: "/contacts" }}
/>
<StatCard
title="Active Conversations"
value={stats.activeConversations}
icon={MessageSquare}
loading={loading}
trend={stats.activeConversations > 0 ? "+5%" : undefined}
trendUp
emptyMessage="No active chats"
emptyAction={{ label: "Start a conversation", href: "/conversations" }}
/>
<StatCard
title="Open Opportunities"
value={stats.openOpportunities}
icon={Target}
loading={loading}
trend={stats.openOpportunities > 0 ? "+8%" : undefined}
trendUp
emptyMessage="No opportunities"
emptyAction={{ label: "Create opportunity", href: "/opportunities" }}
/>
<StatCard
title="Pipeline Value"
value={stats.pipelineValue}
icon={DollarSign}
loading={loading}
trend={stats.pipelineValue > 0 ? "+23%" : undefined}
trendUp
highlight
isCurrency
emptyMessage="No pipeline value"
emptyAction={{ label: "Add a deal", href: "/opportunities" }}
/>
</div>
</div>
{/* Quick Actions */}
<div>
{/* Section header - Level 1 (subtle) */}
<div className="clay-card-subtle p-5 border border-border/50 mb-6">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-primary/10 flex items-center justify-center">
<Rocket className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="text-2xl font-bold text-foreground">Quick Actions</h2>
<p className="text-sm text-slate-500">Jump right into common tasks</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
{quickActions.map((action) => {
const Icon = action.icon;
return (
<Link key={action.name} href={action.href}>
<div className="clay-card-interactive p-6 group border border-border/50 hover:border-primary/30 h-full">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Icon className="w-6 h-6 text-primary" />
</div>
<ArrowUpRight className="w-5 h-5 text-muted-foreground opacity-0 group-hover:opacity-100 group-hover:text-primary transition-all" />
</div>
<h3 className="text-base font-bold text-foreground mb-2 group-hover:text-primary transition-colors">
{action.name}
</h3>
<p className="text-sm text-slate-500">{action.description}</p>
</div>
</Link>
);
})}
</div>
</div>
{/* Bottom Section */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<SetupProgress />
<RecommendedTasks />
</div>
</div>
);
}
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 (
<div className={`clay-card p-6 border border-border/50 ${
highlight ? 'bg-gradient-to-br from-primary/5 to-primary/10 border-primary/20' : ''
}`}>
<div className="flex items-start justify-between mb-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
highlight ? 'bg-primary/20' : 'bg-slate-100'
}`}>
<Icon className={`w-6 h-6 ${highlight ? 'text-primary' : 'text-slate-600'}`} />
</div>
{trend ? (
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${
trendUp
? 'bg-emerald-50 text-emerald-600 border border-emerald-200'
: 'bg-red-50 text-red-600 border border-red-200'
}`}>
{trend}
</span>
) : !loading && isEmpty ? (
<span className="text-xs font-medium px-2.5 py-1 rounded-full bg-slate-100 text-slate-500 border border-slate-200">
--
</span>
) : null}
</div>
<div>
{loading ? (
<div className="h-9 w-24 bg-slate-100 rounded-lg animate-pulse" />
) : isEmpty ? (
<div className="space-y-1.5">
<p className={`text-3xl font-bold ${highlight ? 'text-primary/40' : 'text-muted-foreground/50'}`}>
{isCurrency ? '$0' : '0'}
</p>
{emptyMessage && (
<p className="text-sm text-slate-500">{emptyMessage}</p>
)}
{emptyAction && (
<Link
href={emptyAction.href}
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
>
{emptyAction.label}
<ArrowUpRight className="w-3.5 h-3.5" />
</Link>
)}
</div>
) : (
<p className={`text-3xl font-bold ${highlight ? 'text-primary' : 'text-foreground'}`}>
{displayValue}
</p>
)}
<p className="text-slate-500 font-medium mt-2 text-sm">{title}</p>
</div>
</div>
);
}
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 (
<div className="clay-card p-6 border border-border/50 h-full">
{/* Header */}
<div className="flex items-center justify-between mb-5 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${
isComplete ? 'bg-emerald-100' : 'bg-primary/10'
}`}>
{isComplete ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
) : (
<Settings className="w-5 h-5 text-primary" />
)}
</div>
<div>
<h2 className="text-lg font-bold text-foreground">Setup Progress</h2>
<p className="text-sm text-slate-500">
{isComplete ? 'All set! You\'re ready to go' : 'Complete these steps to unlock all features'}
</p>
</div>
</div>
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full font-bold text-sm ${
isComplete
? 'bg-emerald-100 text-emerald-700'
: 'bg-primary/10 text-primary'
}`}>
<span>{completedCount}</span>
<span className="text-foreground/40 font-normal">/</span>
<span>{steps.length}</span>
<span className="font-medium ml-0.5">complete</span>
</div>
</div>
{/* Progress Bar */}
<div className="mb-5 p-3 rounded-lg bg-slate-50 border border-slate-200">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-medium text-foreground">
{isComplete ? 'Setup Complete!' : `${completedCount} of ${steps.length} steps completed`}
</span>
<span className={`text-sm font-bold ${isComplete ? 'text-emerald-600' : 'text-primary'}`}>
{Math.round(progressPercent)}%
</span>
</div>
<div className="h-2.5 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${
isComplete
? 'bg-gradient-to-r from-emerald-500 to-emerald-400'
: 'bg-gradient-to-r from-primary via-indigo-500 to-violet-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Steps List */}
<div className="space-y-3">
{steps.map((step, index) => {
const StepIcon = step.icon;
return (
<div
key={step.key}
className={`flex items-center gap-3 p-3 rounded-lg transition-all ${
step.done
? 'bg-emerald-50 border border-emerald-200'
: 'bg-white border border-slate-200 hover:border-primary/30'
}`}
>
{/* Step Number/Check */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
step.done
? 'bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-500 border border-slate-300'
}`}>
{step.done ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<span className="font-bold text-xs">{index + 1}</span>
)}
</div>
{/* Step Icon */}
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${
step.done ? 'bg-emerald-100' : 'bg-slate-100'
}`}>
<StepIcon className={`w-4 h-4 ${step.done ? 'text-emerald-600' : 'text-slate-500'}`} />
</div>
{/* Step Content */}
<div className="flex-1 min-w-0">
<p className={`font-semibold text-sm ${step.done ? 'text-emerald-700' : 'text-foreground'}`}>
{step.label}
</p>
<p className="text-xs text-slate-500">{step.description}</p>
</div>
{/* Action Button */}
{step.done ? (
<span className="text-xs font-medium text-emerald-600 px-2 py-1 shrink-0">
Done
</span>
) : (
<Link
href={step.href}
className="px-3 py-1.5 text-xs font-semibold text-white bg-primary rounded-md hover:bg-primary/90 transition-colors shrink-0 flex items-center gap-1"
>
Start
<ArrowUpRight className="w-3 h-3" />
</Link>
)}
</div>
);
})}
</div>
</div>
);
}
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<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="clay-card p-6 border border-border/50 h-full flex flex-col">
{/* Header */}
<div className="flex items-center gap-3 mb-5">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center">
<Zap className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h2 className="text-lg font-bold text-foreground">Recommended Tasks</h2>
<p className="text-sm text-slate-500">AI-powered suggestions based on your CRM</p>
</div>
</div>
{loading ? (
<div className="space-y-3 flex-1">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg bg-slate-50 border border-slate-200">
<div className="w-10 h-10 rounded-lg bg-slate-200 animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 bg-slate-200 rounded animate-pulse" />
<div className="h-3 w-1/2 bg-slate-200 rounded animate-pulse" />
</div>
<div className="w-20 h-7 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
) : error ? (
<div className="text-center py-6 flex-1 flex flex-col justify-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-base font-semibold text-foreground mb-2">
Unable to load recommendations
</h3>
<p className="text-sm text-slate-500 mb-4">
Make sure your MCP server is running
</p>
<button
onClick={() => {
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
</button>
</div>
) : recommendations.length > 0 ? (
<div className="space-y-3 flex-1 overflow-y-auto">
{recommendations.map((rec) => {
const Icon = getIconForType(rec.type);
const colors = getColorForPriority(rec.priority);
return (
<div
key={rec.id}
className={`flex items-start gap-3 p-3 rounded-lg bg-slate-50 border ${colors.border} transition-colors`}
>
<div className={`w-10 h-10 rounded-lg ${colors.bg} flex items-center justify-center shrink-0`}>
<Icon className={`w-5 h-5 ${colors.icon}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-foreground text-sm font-medium">{rec.title}</p>
<p className="text-xs text-slate-500 mt-1">{rec.description}</p>
</div>
<Link
href={rec.actionUrl}
className="shrink-0 inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-primary bg-primary/10 rounded-md hover:bg-primary/20 transition-colors"
>
{rec.actionLabel}
<ArrowUpRight className="w-3 h-3" />
</Link>
</div>
);
})}
</div>
) : (
/* Empty State - All caught up */
<div className="text-center py-6 flex-1 flex flex-col justify-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
<h3 className="text-base font-semibold text-foreground mb-2">
You're all caught up!
</h3>
<p className="text-sm text-slate-500 mb-5 max-w-xs mx-auto">
No urgent tasks right now. Keep up the great work with your leads and deals.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-2">
<Link
href="/control-center"
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"
>
<Sparkles className="w-3.5 h-3.5" />
Ask AI for Insights
</Link>
<Link
href="/contacts"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-primary bg-primary/10 rounded-md hover:bg-primary/20 transition-colors"
>
<UserPlus className="w-3.5 h-3.5" />
Add More Contacts
</Link>
</div>
</div>
)}
{/* Footer link to Control Center for more insights */}
{recommendations.length > 0 && (
<div className="border-t border-slate-200 pt-3 mt-3">
<Link
href="/control-center"
className="text-xs text-primary hover:text-primary/80 font-medium flex items-center justify-center gap-1 transition-colors"
>
Get more insights with AI
<Sparkles className="w-3 h-3" />
</Link>
</div>
)}
</div>
);
}

114
app/(app)/dfy/page.tsx Normal file
View File

@ -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<string | null>(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 (
<div className="space-y-8">
<div>
<div className="flex items-center gap-3 mb-2">
<Sparkles className="text-primary-600" size={28} />
<h2 className="text-2xl font-bold text-slate-900">Done-For-You Services</h2>
</div>
<p className="text-slate-600">
Let our experts handle the setup while you focus on closing deals
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Object.entries(DFY_PRODUCTS).map(([key, product]) => (
<div
key={key}
className="clay-card flex flex-col"
>
<div className="flex-1">
<h3 className="text-xl font-bold text-slate-900">{product.name}</h3>
<p className="text-slate-600 mt-2 text-sm">{product.description}</p>
<div className="mt-4">
<span className="text-3xl font-bold text-primary-600">
{formatPrice(product.priceInCents)}
</span>
<span className="text-slate-500 ml-2">one-time</span>
</div>
<ul className="mt-4 space-y-2">
{product.features.map((feature, i) => (
<li key={i} className="flex items-center text-sm text-slate-600">
<Check className="w-4 h-4 mr-2 text-success-500 flex-shrink-0" />
{feature}
</li>
))}
</ul>
<div className="flex items-center text-xs text-slate-500 mt-4">
<Clock className="w-3 h-3 mr-1" />
Delivery: {product.deliveryDays} business days
</div>
</div>
<button
onClick={() => 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 ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : (
'Get Started'
)}
</button>
</div>
))}
</div>
<div className="clay-card bg-gradient-to-r from-primary-50 to-primary-100/50">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">Need a custom solution?</h3>
<p className="text-sm text-slate-600 mt-1">
Contact us for enterprise packages and custom integrations
</p>
</div>
<button className="btn-secondary whitespace-nowrap">
Contact Sales
</button>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center max-w-md">
<div className="w-20 h-20 bg-gradient-to-br from-success-100 to-success-200 rounded-full flex items-center justify-center mx-auto mb-6 shadow-clay">
<CheckCircle className="w-10 h-10 text-success-600" />
</div>
<h1 className="text-2xl font-bold text-slate-900 mb-4">Payment Successful!</h1>
<p className="text-slate-600 mb-6">
Thank you for your purchase. Our team will begin working on your setup immediately.
You&apos;ll receive an email with next steps within 24 hours.
</p>
<div className="clay-card bg-gradient-to-r from-primary-50 to-primary-100/50 mb-6">
<p className="text-sm text-slate-700">
<span className="font-semibold">What happens next?</span>
<br />
Our team will review your account and reach out to schedule a kickoff call to understand your specific needs.
</p>
</div>
<Link
href="/dashboard"
className="btn-primary inline-flex items-center gap-2"
>
Return to Dashboard
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
);
}

685
app/(app)/layout.tsx Normal file
View File

@ -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 (
<div className="relative group">
{children}
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-2 py-1 bg-foreground text-background text-xs rounded-md whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg">
{content}
<div className="absolute right-full top-1/2 -translate-y-1/2 border-4 border-transparent border-r-foreground" />
</div>
</div>
);
}
// Search Modal Component
function SearchModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [searchQuery, setSearchQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(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 (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
<div
className="relative w-full max-w-lg mx-4 bg-background rounded-2xl shadow-[8px_8px_16px_#b8bcc5,-8px_-8px_16px_#ffffff] overflow-hidden"
role="dialog"
aria-modal="true"
aria-label="Search"
>
<div className="flex items-center gap-3 p-4 border-b border-border/50">
<Search className="w-5 h-5 text-muted-foreground" />
<input
ref={inputRef}
type="text"
placeholder="Search contacts, conversations, opportunities..."
className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Search input"
/>
<kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground bg-muted rounded">
<span>Esc</span>
</kbd>
</div>
<div className="p-2">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Quick Search
</div>
<nav role="menu">
{searchCategories.map(({ label, icon: Icon, shortcut }) => (
<button
key={label}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left text-muted-foreground hover:text-foreground hover:bg-white/50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
role="menuitem"
onClick={() => {
onClose();
}}
>
<Icon className="w-4 h-4" />
<span className="flex-1">{label}</span>
<kbd className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{shortcut}</kbd>
</button>
))}
</nav>
</div>
<div className="px-4 py-3 border-t border-border/50 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-2">
<kbd className="px-1.5 py-0.5 bg-muted rounded">Tab</kbd> to navigate
<kbd className="px-1.5 py-0.5 bg-muted rounded">Enter</kbd> to select
</span>
</div>
</div>
</div>
);
}
// 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 = (
<Link
href={item.href}
className={cn(
'group relative flex items-center gap-3.5 px-4 py-3.5 rounded-xl font-medium transition-all duration-200',
'text-muted-foreground',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
isActive
? 'bg-background text-primary shadow-[3px_3px_6px_#c5c8cf,-3px_-3px_6px_#ffffff] border-l-3 border-primary'
: 'hover:bg-white/60 hover:text-foreground hover:shadow-[2px_2px_4px_#c5c8cf,-2px_-2px_4px_#ffffff] active:shadow-[inset_2px_2px_4px_#c5c8cf,inset_-2px_-2px_4px_#ffffff]',
collapsed && 'justify-center px-3'
)}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
>
<Icon className={cn(
'w-5 h-5 shrink-0 transition-all duration-200',
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground group-hover:scale-110'
)} />
{!collapsed && (
<span className={cn(
'transition-colors duration-200 overflow-hidden whitespace-nowrap',
isActive ? 'text-primary font-semibold' : ''
)}>
{item.label}
</span>
)}
</Link>
);
return (
<Tooltip content={item.label} show={collapsed}>
{linkContent}
</Tooltip>
);
}
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 (
<div className="flex flex-col h-full bg-background border-r border-border/50">
{/* Logo */}
<div className={cn(
'flex items-center px-4 py-3 border-b border-border/50 transition-all duration-300',
collapsed ? 'justify-center' : 'justify-between'
)}>
<Link
href="/dashboard"
className="flex items-center gap-3 group focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
aria-label="CRESync Dashboard Home"
>
<div className="w-9 h-9 clay-avatar shadow-[4px_4px_8px_#c5c8cf,-4px_-4px_8px_#ffffff] group-hover:shadow-[5px_5px_10px_#c5c8cf,-5px_-5px_10px_#ffffff] transition-shadow shrink-0">
<Building2 className="w-5 h-5" />
</div>
<div className={cn(
'flex flex-col transition-all duration-300 overflow-hidden',
collapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'
)}>
<span className="text-lg font-bold text-foreground tracking-tight whitespace-nowrap">CRESync</span>
<span className="text-[10px] text-muted-foreground font-medium -mt-0.5 whitespace-nowrap">Real Estate CRM</span>
</div>
</Link>
<div className={cn(
'flex items-center gap-2 transition-all duration-300',
collapsed ? 'w-0 opacity-0 overflow-hidden' : 'w-auto opacity-100'
)}>
{onMobileClose && (
<button
onClick={onMobileClose}
className="lg:hidden 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="Close sidebar"
>
<X className="w-4 h-4" />
</button>
)}
<button
onClick={() => 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"
>
<ChevronLeft className="w-4 h-4" />
</button>
</div>
</div>
{/* Connection Status */}
<div className={cn(
'border-b border-border/50 transition-all duration-300 overflow-hidden',
collapsed ? 'max-h-0 py-0 px-0 opacity-0' : 'max-h-20 px-4 py-2 opacity-100'
)}>
<div className="flex items-center gap-2 text-xs">
{isConnected ? (
<>
<span className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.6)]" />
<span className="text-emerald-600 font-medium">Connected</span>
</>
) : (
<>
<Loader2 className="w-3 h-3 text-muted-foreground animate-spin" />
<span className="text-muted-foreground">Connecting...</span>
</>
)}
</div>
</div>
{/* Setup Section */}
<div className="px-3 pt-4 pb-2">
<div className={cn(
'px-3 mb-3 transition-all duration-300 overflow-hidden',
collapsed ? 'max-h-0 opacity-0' : 'max-h-10 opacity-100'
)}>
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider whitespace-nowrap">Setup</span>
</div>
<nav className="space-y-1" role="navigation" aria-label="Setup navigation">
{setupNavItems.map((item) => (
<NavLink key={item.href} item={item} collapsed={collapsed} isActive={pathname === item.href} />
))}
</nav>
</div>
{/* Main Navigation */}
<div className="flex-1 px-3 py-2 overflow-y-auto">
<div className={cn(
'px-3 mb-3 transition-all duration-300 overflow-hidden',
collapsed ? 'max-h-0 opacity-0' : 'max-h-10 opacity-100'
)}>
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider whitespace-nowrap">Main Menu</span>
</div>
<nav className="space-y-1" role="navigation" aria-label="Main navigation">
{navItems.map((item) => (
<NavLink key={item.href} item={item} collapsed={collapsed} isActive={pathname === item.href} />
))}
</nav>
</div>
{/* System Section */}
<div className="px-3 pb-2">
<div className={cn(
'px-3 mb-2 transition-all duration-300 overflow-hidden',
collapsed ? 'max-h-0 opacity-0' : 'max-h-10 opacity-100'
)}>
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider whitespace-nowrap">System</span>
</div>
<nav role="navigation" aria-label="System navigation">
{filteredBottomNavItems.map((item) => (
<NavLink key={item.href} item={item} collapsed={collapsed} isActive={pathname === item.href} />
))}
</nav>
</div>
{/* User Section */}
<div className="p-3 space-y-1 border-t border-border/50">
{/* User Menu */}
<div className={cn(
'flex items-center gap-3 p-3 rounded-xl bg-background/50 shadow-[inset_2px_2px_4px_#c5c8cf,inset_-2px_-2px_4px_#ffffff] transition-all duration-300',
collapsed && 'justify-center p-2'
)}>
<Tooltip content={user?.firstName ? `${user.firstName} ${user.lastName}` : 'User'} show={collapsed}>
<div
className="w-10 h-10 clay-avatar text-sm ring-2 ring-white/50 shadow-[3px_3px_6px_#c5c8cf,-3px_-3px_6px_#ffffff] shrink-0"
aria-label={`User avatar: ${user?.firstName || 'User'}`}
>
{getUserInitials(user)}
</div>
</Tooltip>
<div className={cn(
'flex-1 min-w-0 transition-all duration-300 overflow-hidden',
collapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'
)}>
<p className="text-sm font-semibold text-foreground truncate">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div>
<div className={cn(
'transition-all duration-300 overflow-hidden',
collapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'
)}>
<button
onClick={handleLogout}
disabled={isLoggingOut}
className={cn(
'clay-icon-btn w-9 h-9 transition-all duration-200',
'text-muted-foreground',
'hover:text-destructive hover:bg-destructive/10 hover:shadow-[2px_2px_4px_#c5c8cf,-2px_-2px_4px_#ffffff]',
'active:shadow-[inset_2px_2px_4px_#c5c8cf,inset_-2px_-2px_4px_#ffffff]',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
aria-label="Log out"
>
{isLoggingOut ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<LogOut className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Logout button when collapsed */}
{collapsed && (
<div className="mt-2">
<Tooltip content="Log out" show={collapsed}>
<button
onClick={handleLogout}
disabled={isLoggingOut}
className={cn(
'w-full clay-icon-btn h-9 transition-all duration-200',
'text-muted-foreground',
'hover:text-destructive hover:bg-destructive/10',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
aria-label="Log out"
>
{isLoggingOut ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<LogOut className="w-4 h-4" />
)}
</button>
</Tooltip>
</div>
)}
</div>
{/* Expand Button (when collapsed) */}
{collapsed && (
<div className="p-3 border-t border-border/50">
<Tooltip content="Expand sidebar" show={collapsed}>
<button
onClick={() => 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"
>
<ChevronRight className="w-4 h-4" />
</button>
</Tooltip>
</div>
)}
</div>
);
}
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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
const currentPage = [...setupNavItems, ...navItems, ...bottomNavItems].find((item) => pathname === item.href)?.label || 'CRESync';
return (
<div className="flex min-h-screen bg-background gap-0">
{/* Search Modal */}
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
{/* Desktop Sidebar */}
<aside
className={cn(
'hidden lg:flex flex-col shrink-0 h-screen sticky top-0 transition-all duration-300 ease-in-out overflow-visible',
collapsed ? 'w-[80px]' : 'w-[260px]'
)}
role="navigation"
aria-label="Main sidebar"
>
<SidebarContent collapsed={collapsed} setCollapsed={setCollapsed} />
</aside>
{/* Mobile Sidebar Overlay */}
{mobileOpen && (
<div className="lg:hidden fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label="Mobile navigation">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
<aside className="absolute left-0 top-0 h-full w-[260px] animate-fade-in-up shadow-[8px_0_24px_rgba(0,0,0,0.1)]">
<SidebarContent
collapsed={false}
setCollapsed={() => {}}
onMobileClose={() => setMobileOpen(false)}
/>
</aside>
</div>
)}
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 relative z-0 max-h-screen">
{/* Mobile Header */}
<header className="lg:hidden sticky top-0 h-16 bg-background border-b border-border/50 z-30 flex items-center justify-between px-4 shadow-[0_4px_12px_#b8bcc5]">
<div className="flex items-center gap-3">
<button
onClick={() => 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"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-3">
<div className="w-9 h-9 clay-avatar shadow-[3px_3px_6px_#c5c8cf,-3px_-3px_6px_#ffffff]">
<Building2 className="w-5 h-5" />
</div>
<span className="font-bold text-lg text-foreground tracking-tight">CRESync</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => 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"
>
<Search className="w-4 h-4" />
</button>
<button
className={cn(
'clay-icon-btn w-9 h-9 relative',
'hover:bg-white/60 hover:scale-105 transition-all',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
aria-label={`Notifications${notificationCount > 0 ? `, ${notificationCount} unread` : ''}`}
>
<Bell className="w-4 h-4" />
{notificationCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] bg-primary text-primary-foreground text-[10px] font-bold rounded-full ring-2 ring-background flex items-center justify-center px-1">
{notificationCount > 99 ? '99+' : notificationCount}
</span>
)}
</button>
</div>
</header>
{/* Main Content */}
<main className="flex-1 flex flex-col min-h-0" id="main-content">
{/* Top Bar (Desktop) */}
<header className="hidden lg:flex h-16 bg-background border-b border-border/50 items-center justify-between px-6 sticky top-0 z-20 shadow-[0_4px_12px_#b8bcc5]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-gradient-to-b from-primary to-primary/60 rounded-full" aria-hidden="true" />
<h1 className="text-xl font-bold text-foreground tracking-tight">{currentPage}</h1>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 p-1 rounded-xl bg-background/50">
<button
onClick={() => 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)"
>
<Search className="w-5 h-5" />
</button>
<button
className={cn(
'clay-icon-btn w-10 h-10 relative 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={`Notifications${notificationCount > 0 ? `, ${notificationCount} unread` : ''}`}
>
<Bell className="w-5 h-5" />
{notificationCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[20px] h-[20px] bg-primary text-primary-foreground text-[10px] font-bold rounded-full ring-2 ring-background flex items-center justify-center px-1 animate-pulse">
{notificationCount > 99 ? '99+' : notificationCount}
</span>
)}
</button>
</div>
<div className="w-px h-10 bg-gradient-to-b from-transparent via-border to-transparent mx-2" aria-hidden="true" />
<button
className={cn(
'flex items-center gap-3 p-2 rounded-xl transition-all duration-200',
'hover:bg-white/50 hover:shadow-[2px_2px_4px_#c5c8cf,-2px_-2px_4px_#ffffff]',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
'cursor-pointer'
)}
aria-label={`User profile: ${user?.firstName} ${user?.lastName}`}
>
<div className="w-10 h-10 clay-avatar text-sm shadow-[3px_3px_6px_#c5c8cf,-3px_-3px_6px_#ffffff]">
{getUserInitials(user)}
</div>
<div className="hidden xl:block text-left">
<p className="text-sm font-semibold text-foreground">{user?.firstName} {user?.lastName}</p>
<p className="text-xs text-muted-foreground">{getUserRoleDisplay(user)}</p>
</div>
</button>
</div>
</header>
{/* Page Content */}
<div className="flex-1 p-6 lg:p-8 min-h-0 overflow-auto">{children}</div>
</main>
</div>
{/* Skip to main content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg"
>
Skip to main content
</a>
</div>
);
}
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<RealtimeProvider>
<AppLayoutContent>{children}</AppLayoutContent>
</RealtimeProvider>
</AuthProvider>
);
}

193
app/(app)/leads/page.tsx Normal file
View File

@ -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 (
<div className="space-y-8">
{/* Hero Section */}
<div className="clay-card shadow-lg border border-border/50 max-w-4xl mx-auto text-center p-8 lg:p-12 mb-8">
{/* Animated Icon Stack */}
<div className="relative w-24 h-24 mx-auto mb-8">
<div className="absolute inset-0 clay-icon-btn !rounded-2xl w-24 h-24 flex items-center justify-center">
<Building2 className="w-12 h-12 text-primary" />
</div>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center border border-blue-200 shadow-sm">
<Search className="w-4 h-4 text-blue-600" />
</div>
<div className="absolute -bottom-2 -left-2 w-8 h-8 bg-violet-100 rounded-lg flex items-center justify-center border border-violet-200 shadow-sm">
<Sparkles className="w-4 h-4 text-violet-600" />
</div>
</div>
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/10 text-primary rounded-full text-sm font-medium mb-6">
<Clock className="w-4 h-4" />
Coming Q1 2026
</div>
<h1 className="text-3xl lg:text-4xl font-bold text-foreground mb-4">
Lead Generation & Prospecting
</h1>
<p className="text-lg text-slate-500 mb-4 max-w-2xl mx-auto leading-relaxed">
Discover high-potential properties and owners in your target markets.
Powered by comprehensive property data, find your next deal before the competition.
</p>
<p className="text-slate-500 mb-8 max-w-xl mx-auto">
Search millions of commercial properties, identify motivated sellers,
and enrich your contacts with verified owner information and contact details.
</p>
{/* Email Signup */}
{!submitted ? (
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 max-w-md mx-auto mb-4">
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => 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
/>
<button type="submit" className="clay-btn-primary px-6 py-3 font-medium inline-flex items-center justify-center gap-2">
<Bell className="w-4 h-4" />
Notify Me
</button>
</form>
) : (
<div className="flex items-center justify-center gap-2 text-emerald-600 bg-emerald-50 px-6 py-3 rounded-xl max-w-md mx-auto mb-4">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">You&apos;re on the list! We&apos;ll notify you when it&apos;s ready.</span>
</div>
)}
<p className="text-sm text-slate-500">
Get early access when leads launch. No spam, just updates.
</p>
</div>
{/* Features Preview */}
<div className="max-w-4xl mx-auto">
<h2 className="text-xl font-semibold text-foreground text-center mb-6">
What&apos;s Coming
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcomingFeatures.map((feature, index) => (
<div
key={index}
className="clay-card p-5 opacity-75 border border-dashed border-border/70"
>
<div className="w-10 h-10 bg-muted rounded-lg flex items-center justify-center mb-3">
<feature.icon className="w-5 h-5 text-muted-foreground" />
</div>
<h3 className="font-medium text-foreground mb-1">{feature.title}</h3>
<p className="text-sm text-slate-500">{feature.description}</p>
</div>
))}
</div>
{/* Search Interface Preview (Disabled/Grayed) */}
<div className="clay-card p-6 mt-8 opacity-50 pointer-events-none select-none border border-dashed border-border">
<div className="flex items-center gap-2 mb-4 text-muted-foreground">
<Search className="w-5 h-5" />
<span className="font-medium">Lead Search Preview</span>
<span className="ml-auto text-xs bg-muted px-2 py-1 rounded">Coming Soon</span>
</div>
{/* Search Bar */}
<div className="flex gap-3 mb-6">
<div className="flex-1 h-12 bg-muted/50 rounded-xl border border-muted-foreground/20 flex items-center px-4">
<Search className="w-5 h-5 text-muted-foreground/40 mr-3" />
<span className="text-muted-foreground/40">Search properties, owners, or addresses...</span>
</div>
<div className="h-12 px-4 bg-muted/50 rounded-xl border border-muted-foreground/20 flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground/40" />
<span className="text-muted-foreground/40">Filters</span>
</div>
</div>
{/* Results Preview */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map((i) => (
<div key={i} className="bg-muted/30 rounded-xl p-4 border border-muted-foreground/10">
<div className="flex items-start gap-3">
<div className="w-12 h-12 bg-muted/50 rounded-lg flex items-center justify-center">
<Building2 className="w-6 h-6 text-muted-foreground/30" />
</div>
<div className="flex-1">
<div className="h-4 w-32 bg-muted/50 rounded mb-2" />
<div className="h-3 w-48 bg-muted/50 rounded mb-2" />
<div className="flex gap-2">
<div className="h-3 w-16 bg-muted/50 rounded" />
<div className="h-3 w-20 bg-muted/50 rounded" />
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Data Points */}
<div className="clay-card p-6 mt-6">
<div className="flex items-center gap-2 mb-4">
<Database className="w-5 h-5 text-primary" />
<h3 className="font-semibold text-foreground">Data You&apos;ll Access</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{dataPoints.map((point, index) => (
<div key={index} className="flex items-center gap-2 text-sm text-slate-500">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
{point}
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<Marketplace
onQuizClick={handleQuizClick}
calendlyCoachingLink="https://calendly.com"
calendlyTeamLink="https://calendly.com"
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<div className="space-y-8">
{/* Hero Section */}
<div className="clay-card shadow-lg border border-border/50 max-w-4xl mx-auto text-center p-8 lg:p-12 mb-8">
{/* Animated Icon Stack */}
<div className="relative w-24 h-24 mx-auto mb-8">
<div className="absolute inset-0 clay-icon-btn w-24 h-24 flex items-center justify-center bg-gradient-to-br from-purple-50 to-violet-100">
<BarChart3 className="w-12 h-12 text-primary" />
</div>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center border border-emerald-200 shadow-sm">
<TrendingUp className="w-4 h-4 text-emerald-600" />
</div>
<div className="absolute -bottom-2 -left-2 w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center border border-blue-200 shadow-sm">
<PieChart className="w-4 h-4 text-blue-600" />
</div>
</div>
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/10 text-primary rounded-full text-sm font-medium mb-6">
<Clock className="w-4 h-4" />
Coming Q2 2026
</div>
<h1 className="text-3xl lg:text-4xl font-bold text-foreground mb-4">
Analytics & Reporting
</h1>
<p className="text-lg text-slate-500 mb-4 max-w-2xl mx-auto leading-relaxed">
Turn your CRE data into actionable insights. Get beautiful dashboards,
custom reports, and the metrics you need to grow your business.
</p>
<p className="text-slate-500 mb-8 max-w-xl mx-auto">
Track pipeline health, measure outreach effectiveness, and identify
opportunities with powerful analytics built specifically for commercial real estate.
</p>
{/* Email Signup */}
{!submitted ? (
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 max-w-md mx-auto mb-4">
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => 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
/>
<button type="submit" className="clay-btn-primary px-6 py-3 font-medium inline-flex items-center justify-center gap-2">
<Bell className="w-4 h-4" />
Notify Me
</button>
</form>
) : (
<div className="flex items-center justify-center gap-2 text-emerald-600 bg-emerald-50 px-6 py-3 rounded-xl max-w-md mx-auto mb-4">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">You&apos;re on the list! We&apos;ll notify you when it&apos;s ready.</span>
</div>
)}
<p className="text-sm text-slate-500">
Be first to access reporting when it launches. No spam, just updates.
</p>
</div>
{/* Features Preview */}
<div className="max-w-4xl mx-auto">
<h2 className="text-xl font-semibold text-foreground text-center mb-6">
What&apos;s Coming
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcomingFeatures.map((feature, index) => (
<div
key={index}
className="clay-card p-5 opacity-75 border border-dashed border-border/70"
>
<div className="w-10 h-10 bg-muted rounded-lg flex items-center justify-center mb-3">
<feature.icon className="w-5 h-5 text-muted-foreground" />
</div>
<h3 className="font-medium text-foreground mb-1">{feature.title}</h3>
<p className="text-sm text-slate-500">{feature.description}</p>
</div>
))}
</div>
{/* Dashboard Preview (Disabled/Grayed) */}
<div className="clay-card p-6 mt-8 opacity-50 pointer-events-none select-none border border-dashed border-border">
<div className="flex items-center gap-2 mb-6 text-muted-foreground">
<Layers className="w-5 h-5" />
<span className="font-medium">Dashboard Preview</span>
<span className="ml-auto text-xs bg-muted px-2 py-1 rounded">Coming Soon</span>
</div>
{/* Stats Row */}
<div className="grid grid-cols-4 gap-4 mb-6">
{['Total Deals', 'Pipeline Value', 'Conversion Rate', 'Avg. Deal Size'].map((label, i) => (
<div key={i} className="bg-muted/30 rounded-xl p-4 border border-muted-foreground/10">
<div className="h-3 w-16 bg-muted/50 rounded mb-2" />
<div className="h-6 w-20 bg-muted/50 rounded mb-1" />
<div className="h-2 w-12 bg-muted/50 rounded" />
</div>
))}
</div>
{/* Charts Row */}
<div className="grid grid-cols-2 gap-4">
{/* Line Chart Placeholder */}
<div className="bg-muted/30 rounded-xl p-4 border border-muted-foreground/10">
<div className="h-3 w-24 bg-muted/50 rounded mb-4" />
<div className="h-32 flex items-end gap-1 px-2">
{[40, 65, 45, 80, 55, 90, 70, 85, 60, 95, 75, 88].map((h, i) => (
<div
key={i}
className="flex-1 bg-muted/50 rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
</div>
{/* Pie Chart Placeholder */}
<div className="bg-muted/30 rounded-xl p-4 border border-muted-foreground/10">
<div className="h-3 w-28 bg-muted/50 rounded mb-4" />
<div className="flex items-center justify-center">
<div className="w-28 h-28 rounded-full border-8 border-muted/50 relative">
<div className="absolute inset-0 rounded-full" style={{
background: 'conic-gradient(from 0deg, rgb(var(--muted) / 0.7) 0deg 120deg, rgb(var(--muted) / 0.5) 120deg 220deg, rgb(var(--muted) / 0.3) 220deg 360deg)'
}} />
</div>
</div>
</div>
</div>
</div>
{/* Report Types */}
<div className="clay-card p-6 mt-6">
<div className="flex items-center gap-2 mb-4">
<FileSpreadsheet className="w-5 h-5 text-primary" />
<h3 className="font-semibold text-foreground">Available Report Types</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
'Pipeline Summary',
'Outreach Activity',
'Deal Velocity',
'Win/Loss Analysis',
'Market Breakdown',
'Team Performance',
].map((report, index) => (
<div key={index} className="flex items-center gap-2 text-sm text-slate-500">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
{report}
</div>
))}
</div>
</div>
</div>
</div>
);
}

582
app/(app)/settings/page.tsx Normal file
View File

@ -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<TabType>('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<NotificationSettings>({
emailNotifications: true,
dealUpdates: true,
weeklyDigest: false,
marketingEmails: false,
smsAlerts: true,
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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: <User className="w-4 h-4" /> },
{ id: 'password', label: 'Password', icon: <Lock className="w-4 h-4" /> },
{ id: 'notifications', label: 'Notifications', icon: <Bell className="w-4 h-4" /> },
];
const SaveStatusBadge = ({ status }: { status: 'idle' | 'saving' | 'success' | 'error' }) => {
if (status === 'idle') return null;
return (
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
status === 'saving' ? 'bg-blue-50 text-blue-700' :
status === 'success' ? 'bg-green-50 text-green-700' :
'bg-red-50 text-red-700'
}`}>
{status === 'saving' && (
<>
<div className="w-4 h-4 border-2 border-blue-700 border-t-transparent rounded-full animate-spin" />
Saving changes...
</>
)}
{status === 'success' && (
<>
<Check className="w-4 h-4" />
Changes saved successfully!
</>
)}
{status === 'error' && (
<>
<AlertCircle className="w-4 h-4" />
Failed to save. Please try again.
</>
)}
</div>
);
};
return (
<div className="space-y-8 max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800">Settings</h1>
<p className="text-gray-500 mt-2">Manage your account and preferences</p>
</div>
{/* Tab Navigation */}
<div className="flex flex-wrap gap-3 mb-8 p-1 bg-gray-100 rounded-xl w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => 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}
</button>
))}
</div>
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="clay-card shadow-lg border border-border/50 p-8">
<div className="flex items-center gap-3 mb-6">
<div className="clay-icon-btn bg-indigo-100">
<User className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-800 mb-1">Profile Information</h2>
<p className="text-sm text-gray-500">Update your personal details and photo</p>
</div>
</div>
{/* Profile Photo Upload */}
<div className="flex items-center gap-8 mb-8 pb-8 border-b border-gray-200">
<div className="relative p-2">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-2xl font-bold">
{formData.firstName.charAt(0)}{formData.lastName.charAt(0)}
</div>
<button className="absolute bottom-2 right-2 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center border border-gray-200 hover:bg-gray-50 transition-colors">
<Camera className="w-4 h-4 text-gray-600" />
</button>
</div>
<div>
<h3 className="font-semibold text-gray-800">Profile Photo</h3>
<p className="text-sm text-gray-500 mb-3">JPG, PNG or GIF. Max size 2MB.</p>
<button className="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
Upload new photo
</button>
</div>
</div>
<form onSubmit={handleProfileSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="firstName" className="block text-sm font-semibold text-gray-700 mb-2">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="Enter your first name"
className="clay-input h-14 py-4 border border-gray-200 w-full"
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-semibold text-gray-700 mb-2">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Enter your last name"
className="clay-input h-14 py-4 border border-gray-200 w-full"
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
<span className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400" />
Email Address
</span>
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter your email address"
className="clay-input h-14 py-4 border border-gray-200 w-full"
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-semibold text-gray-700 mb-2">
Phone Number
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="Enter your phone number"
className="clay-input h-14 py-4 border border-gray-200 w-full"
/>
</div>
<div>
<label htmlFor="brokerage" className="block text-sm font-semibold text-gray-700 mb-2">
<span className="flex items-center gap-2">
<Building className="w-4 h-4 text-gray-400" />
Brokerage Name
</span>
</label>
<input
type="text"
id="brokerage"
name="brokerage"
value={formData.brokerage}
onChange={handleChange}
placeholder="Enter your brokerage name"
className="clay-input h-14 py-4 border border-gray-200 w-full"
/>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pt-6 mt-8 border-t border-gray-200">
<SaveStatusBadge status={profileSaveStatus} />
<div className="flex gap-4">
<button
type="button"
onClick={resetProfileForm}
className="clay-btn flex items-center gap-2 px-6 py-3 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-xl"
>
<RotateCcw className="w-4 h-4" />
Reset
</button>
<button
type="submit"
className="clay-btn-primary flex items-center gap-2 px-6 py-3"
disabled={profileSaveStatus === 'saving'}
>
<Save className="w-4 h-4" />
Save Profile
</button>
</div>
</div>
</form>
</div>
)}
{/* Password Tab */}
{activeTab === 'password' && (
<div className="clay-card shadow-lg border border-border/50 p-8">
<div className="flex items-center gap-3 mb-6">
<div className="clay-icon-btn bg-amber-100">
<Lock className="w-5 h-5 text-amber-600" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-800 mb-1">Change Password</h2>
<p className="text-sm text-gray-500">Update your security credentials</p>
</div>
</div>
{/* Security Tips */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-8">
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="font-semibold text-blue-800 text-sm">Password Security Tips</h4>
<ul className="text-sm text-blue-700 mt-1 space-y-1">
<li>Use at least 12 characters with uppercase, lowercase, numbers, and symbols</li>
<li>Avoid using personal information or common words</li>
<li>Do not reuse passwords from other accounts</li>
</ul>
</div>
</div>
</div>
<form onSubmit={handlePasswordSubmit} className="space-y-6">
<div>
<label htmlFor="currentPassword" className="block text-sm font-semibold text-gray-700 mb-2">
Current Password
</label>
<div className="relative">
<input
type={showCurrentPassword ? 'text' : 'password'}
id="currentPassword"
name="currentPassword"
value={formData.currentPassword}
onChange={handleChange}
placeholder="Enter your current password"
className="clay-input h-14 py-4 border border-gray-200 w-full pr-12"
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-semibold text-gray-700 mb-2">
New Password
</label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
id="newPassword"
name="newPassword"
value={formData.newPassword}
onChange={handleChange}
placeholder="Enter new password"
className="clay-input h-14 py-4 border border-gray-200 w-full pr-12"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{/* Password Strength Indicator */}
{formData.newPassword && (
<div className="mt-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500">Password Strength</span>
<span className={`text-xs font-medium ${
passwordStrength.score <= 2 ? 'text-red-600' :
passwordStrength.score <= 3 ? 'text-yellow-600' : 'text-green-600'
}`}>
{passwordStrength.label}
</span>
</div>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((level) => (
<div
key={level}
className={`h-1.5 flex-1 rounded-full transition-colors ${
level <= passwordStrength.score ? passwordStrength.color : 'bg-gray-200'
}`}
/>
))}
</div>
</div>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-semibold text-gray-700 mb-2">
Confirm New Password
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="Confirm new password"
className={`clay-input h-14 py-4 border w-full pr-12 ${
formData.confirmPassword && formData.newPassword !== formData.confirmPassword
? 'border-red-300 focus:border-red-500'
: 'border-gray-200'
}`}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{formData.confirmPassword && formData.newPassword !== formData.confirmPassword && (
<p className="text-red-500 text-sm mt-2 flex items-center gap-1">
<X className="w-4 h-4" />
Passwords do not match
</p>
)}
{formData.confirmPassword && formData.newPassword === formData.confirmPassword && formData.newPassword && (
<p className="text-green-500 text-sm mt-2 flex items-center gap-1">
<Check className="w-4 h-4" />
Passwords match
</p>
)}
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pt-6 mt-8 border-t border-gray-200">
<SaveStatusBadge status={passwordSaveStatus} />
<div className="flex gap-4">
<button
type="button"
onClick={resetPasswordForm}
className="clay-btn flex items-center gap-2 px-6 py-3 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-xl"
>
<X className="w-4 h-4" />
Cancel
</button>
<button
type="submit"
className="clay-btn-primary flex items-center gap-2 px-6 py-3"
disabled={passwordSaveStatus === 'saving' || !formData.currentPassword || !formData.newPassword || formData.newPassword !== formData.confirmPassword}
>
<Lock className="w-4 h-4" />
Update Password
</button>
</div>
</div>
</form>
</div>
)}
{/* Notifications Tab */}
{activeTab === 'notifications' && (
<div className="clay-card shadow-lg border border-border/50 p-8">
<div className="flex items-center gap-3 mb-6">
<div className="clay-icon-btn bg-purple-100">
<Bell className="w-5 h-5 text-purple-600" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-800 mb-1">Notification Preferences</h2>
<p className="text-sm text-gray-500">Choose how you want to be notified</p>
</div>
</div>
<form onSubmit={handleNotificationSubmit} className="space-y-2">
{[
{ key: 'emailNotifications', label: 'Email Notifications', description: 'Receive important updates via email' },
{ key: 'dealUpdates', label: 'Deal Updates', description: 'Get notified when deals change status' },
{ key: 'weeklyDigest', label: 'Weekly Digest', description: 'Receive a summary of your activity each week' },
{ key: 'marketingEmails', label: 'Marketing Communications', description: 'Stay updated on new features and tips' },
{ key: 'smsAlerts', label: 'SMS Alerts', description: 'Receive text messages for urgent notifications' },
].map((item) => (
<div
key={item.key}
className="flex items-center justify-between py-5 px-4 gap-6 rounded-xl hover:bg-gray-50 transition-colors"
>
<div>
<h4 className="font-medium text-gray-800 mb-1">{item.label}</h4>
<p className="text-sm text-gray-500">{item.description}</p>
</div>
<button
type="button"
onClick={() => handleNotificationChange(item.key as keyof NotificationSettings)}
className={`relative w-12 h-7 rounded-full transition-colors flex-shrink-0 ${
notifications[item.key as keyof NotificationSettings]
? 'bg-indigo-600'
: 'bg-gray-300'
}`}
>
<div
className={`absolute top-1 w-5 h-5 bg-white rounded-full shadow transition-transform ${
notifications[item.key as keyof NotificationSettings]
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
))}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pt-6 mt-8 border-t border-gray-200">
<SaveStatusBadge status={notificationSaveStatus} />
<button
type="submit"
className="clay-btn-primary flex items-center gap-2 px-6 py-3"
disabled={notificationSaveStatus === 'saving'}
>
<Save className="w-4 h-4" />
Save Preferences
</button>
</div>
</form>
</div>
)}
</div>
);
}

290
app/(app)/setup/page.tsx Normal file
View File

@ -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<SetupStatus>({
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<string, { bg: string; icon: string; border: string }> = {
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 (
<div className="space-y-8 pb-8 max-w-4xl mx-auto">
{/* Header */}
<div className="clay-card-subtle p-6 border border-border/50">
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${
isComplete ? 'bg-emerald-100' : 'bg-primary/10'
}`}>
{isComplete ? (
<CheckCircle2 className="w-7 h-7 text-emerald-600" />
) : (
<Settings className="w-7 h-7 text-primary" />
)}
</div>
<div>
<h1 className="text-3xl font-bold text-foreground">
{isComplete ? 'Setup Complete!' : 'Setup Your Account'}
</h1>
<p className="text-slate-500 text-lg mt-1">
{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}` : ''}.`}
</p>
</div>
</div>
</div>
{/* Progress Overview */}
<div className="clay-card p-6 border border-border/50">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${
isComplete ? 'bg-emerald-100' : 'bg-primary/10'
}`}>
{isComplete ? (
<Sparkles className="w-5 h-5 text-emerald-600" />
) : (
<Rocket className="w-5 h-5 text-primary" />
)}
</div>
<div>
<h2 className="text-xl font-bold text-foreground">Progress Overview</h2>
<p className="text-sm text-slate-500">
{completedCount} of {steps.length} steps completed
</p>
</div>
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-full font-bold ${
isComplete
? 'bg-emerald-100 text-emerald-700'
: 'bg-primary/10 text-primary'
}`}>
<span className="text-2xl">{Math.round(progressPercent)}%</span>
<span className="text-sm font-medium">complete</span>
</div>
</div>
{/* Progress Bar */}
<div className="p-4 rounded-xl bg-slate-50 border border-slate-200">
<div className="h-4 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${
isComplete
? 'bg-gradient-to-r from-emerald-500 to-emerald-400'
: 'bg-gradient-to-r from-primary via-indigo-500 to-violet-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex justify-between mt-3">
{steps.map((step, index) => (
<div
key={step.key}
className={`flex items-center gap-1.5 ${
step.done ? 'text-emerald-600' : 'text-slate-400'
}`}
>
{step.done ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<span className="w-4 h-4 rounded-full border-2 border-current" />
)}
<span className="text-xs font-medium hidden sm:inline">{step.label.split(' ')[0]}</span>
</div>
))}
</div>
</div>
</div>
{/* Setup Steps */}
<div className="space-y-4">
<div className="clay-card-subtle p-5 border border-border/50">
<h2 className="text-xl font-bold text-foreground">Setup Steps</h2>
<p className="text-sm text-slate-500 mt-1">Complete each step to get the most out of CRESync</p>
</div>
{loading ? (
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="clay-card p-6 border border-border/50">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-slate-200 animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-5 w-1/3 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-2/3 bg-slate-200 rounded animate-pulse" />
</div>
</div>
</div>
))}
</div>
) : (
<div className="space-y-4">
{steps.map((step, index) => {
const StepIcon = step.icon;
const colors = colorClasses[step.color];
return (
<div
key={step.key}
className={`clay-card p-6 border transition-all ${
step.done
? 'border-emerald-200 bg-emerald-50/50'
: 'border-border/50 hover:border-primary/30'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
{/* Step Number & Icon */}
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg ${
step.done
? 'bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-500 border-2 border-slate-300'
}`}>
{step.done ? (
<CheckCircle2 className="w-5 h-5" />
) : (
index + 1
)}
</div>
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
step.done ? 'bg-emerald-100' : colors.bg
}`}>
<StepIcon className={`w-7 h-7 ${step.done ? 'text-emerald-600' : colors.icon}`} />
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h3 className={`text-lg font-bold ${step.done ? 'text-emerald-700' : 'text-foreground'}`}>
{step.label}
</h3>
<p className="text-slate-500 mt-1">{step.description}</p>
</div>
{/* Action */}
<div className="flex-shrink-0">
{step.done ? (
<span className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-100 text-emerald-700 font-semibold text-sm">
<CheckCircle2 className="w-4 h-4" />
Completed
</span>
) : (
<Link
href={step.href}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-white font-semibold text-sm hover:bg-primary/90 transition-colors"
>
Get Started
<ArrowUpRight className="w-4 h-4" />
</Link>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Completion Message */}
{isComplete && (
<div className="clay-card p-8 border border-emerald-200 bg-gradient-to-br from-emerald-50 to-teal-50 text-center">
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-emerald-100 flex items-center justify-center">
<Sparkles className="w-10 h-10 text-emerald-600" />
</div>
<h2 className="text-2xl font-bold text-emerald-800 mb-2">You're All Set!</h2>
<p className="text-emerald-700 mb-6 max-w-md mx-auto">
Your account is fully configured. Start managing your contacts, running campaigns, and closing more deals.
</p>
<Link
href="/dashboard"
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-emerald-600 text-white font-semibold hover:bg-emerald-700 transition-colors"
>
Go to Dashboard
<ArrowUpRight className="w-5 h-5" />
</Link>
</div>
)}
</div>
);
}

18
app/(app)/tools/page.tsx Normal file
View File

@ -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 (
<ExternalTools
loiToolUrl="#"
underwritingToolUrl="#"
onToolClick={handleToolClick}
/>
);
}

17
app/(auth)/layout.tsx Normal file
View File

@ -0,0 +1,17 @@
'use client';
import { AuthProvider } from '@/lib/hooks/useAuth';
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<div className="min-h-screen bg-[#F2F5F8]">
{children}
</div>
</AuthProvider>
);
}

167
app/(auth)/login/page.tsx Normal file
View File

@ -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 (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-12 animate-fade-in-up">
<Link href="/" className="inline-flex items-center gap-4 group">
<div className="w-16 h-16 clay-avatar">
<Building2 className="w-8 h-8" />
</div>
<div className="text-left">
<span className="text-3xl font-bold text-foreground">CRESync</span>
<p className="text-sm text-muted-foreground">Commercial Real Estate CRM</p>
</div>
</Link>
</div>
{/* Login Card */}
<div className="clay-card p-10 animate-fade-in-scale shadow-lg" style={{ animationDelay: '0.1s' }}>
<div className="text-center mb-10">
<h1 className="text-2xl font-bold text-foreground mb-3">Welcome Back</h1>
<p className="text-muted-foreground">Enter your credentials to continue</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-xl bg-red-50 text-red-600 text-sm border border-red-200 animate-fade-in-up">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 pointer-events-none" />
<input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full h-14 clay-input pl-12 pr-4 border border-gray-200"
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<Link href="/forgot-password" className="text-sm text-primary hover:underline">
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 pointer-events-none" />
<input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-14 clay-input pl-12 pr-4 border border-gray-200"
required
/>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={() => setRememberMe(!rememberMe)}
className={`w-6 h-6 rounded-md border-2 transition-all flex items-center justify-center ${
rememberMe
? 'bg-primary border-primary'
: 'bg-white border-gray-300 hover:border-gray-400'
}`}
>
{rememberMe && (
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<label
onClick={() => setRememberMe(!rememberMe)}
className="text-sm text-foreground cursor-pointer select-none"
>
Remember me for 30 days
</label>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-4 clay-btn-primary flex items-center justify-center gap-2 disabled:opacity-50 mt-2"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Signing in...
</>
) : (
<>
Sign In
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
</form>
<div className="clay-divider my-8" />
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{' '}
<Link href="/signup" className="text-primary font-medium hover:underline">
Create one
</Link>
</p>
</div>
{/* Footer */}
<p className="text-center text-xs text-muted-foreground mt-8 animate-fade-in-up" style={{ animationDelay: '0.3s' }}>
By signing in, you agree to our{' '}
<Link href="/terms" className="hover:text-foreground transition-colors">Terms</Link>
{' '}and{' '}
<Link href="/privacy" className="hover:text-foreground transition-colors">Privacy Policy</Link>
</p>
</div>
</div>
);
}

View File

@ -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<string[]>([]);
const toggleGoal = (goalId: string) => {
setSelectedGoals((prev) =>
prev.includes(goalId)
? prev.filter((id) => id !== goalId)
: [...prev, goalId]
);
};
return (
<div className="clay-card max-w-2xl mx-auto">
{/* Progress */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
currentStep > step.id
? "bg-green-500 text-white"
: currentStep === step.id
? "bg-primary-600 text-white"
: "bg-slate-200 text-slate-500"
}`}
>
{currentStep > step.id ? <Check size={20} /> : step.id}
</div>
{index < steps.length - 1 && (
<div
className={`w-16 md:w-24 h-1 mx-2 ${
currentStep > step.id ? "bg-green-500" : "bg-slate-200"
}`}
/>
)}
</div>
))}
</div>
<div className="text-center">
<h2 className="text-xl font-bold text-slate-900">
{steps[currentStep - 1].title}
</h2>
<p className="text-slate-600 mt-1">
{steps[currentStep - 1].description}
</p>
</div>
</div>
{/* Step Content */}
{currentStep === 1 && (
<div className="space-y-4">
{goals.map((goal) => {
const Icon = goal.icon;
const isSelected = selectedGoals.includes(goal.id);
return (
<button
key={goal.id}
onClick={() => 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"
}`}
>
<div
className={`p-3 rounded-xl ${
isSelected ? "bg-primary-100" : "bg-slate-100"
}`}
>
<Icon
className={isSelected ? "text-primary-600" : "text-slate-500"}
size={24}
/>
</div>
<span
className={`font-medium ${
isSelected ? "text-primary-900" : "text-slate-700"
}`}
>
{goal.label}
</span>
{isSelected && (
<Check className="ml-auto text-primary-600" size={20} />
)}
</button>
);
})}
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
How many deals do you close per month?
</label>
<select className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500">
<option>1-5 deals</option>
<option>6-10 deals</option>
<option>11-20 deals</option>
<option>20+ deals</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Team size
</label>
<select className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500">
<option>Just me</option>
<option>2-5 people</option>
<option>6-10 people</option>
<option>10+ people</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Primary market
</label>
<select className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500">
<option>Office</option>
<option>Retail</option>
<option>Industrial</option>
<option>Multifamily</option>
<option>Mixed Use</option>
</select>
</div>
</div>
)}
{currentStep === 3 && (
<div className="space-y-4">
<p className="text-slate-600 mb-4">
Select the channels you want to use for outreach:
</p>
{[
{ 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) => (
<label
key={channel.id}
className="flex items-start gap-4 p-4 rounded-xl border border-slate-200 hover:border-slate-300 cursor-pointer"
>
<input
type="checkbox"
className="w-5 h-5 mt-0.5 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="font-medium text-slate-900">{channel.label}</span>
<p className="text-sm text-slate-500">{channel.description}</p>
</div>
</label>
))}
</div>
)}
{/* Navigation */}
<div className="flex justify-between mt-8">
<button
onClick={() => setCurrentStep((prev) => Math.max(1, prev - 1))}
className={`btn-secondary flex items-center gap-2 ${
currentStep === 1 ? "invisible" : ""
}`}
>
<ArrowLeft size={18} />
Back
</button>
<button
onClick={() => setCurrentStep((prev) => Math.min(3, prev + 1))}
className="btn-primary flex items-center gap-2"
>
{currentStep === 3 ? "Finish Setup" : "Continue"}
<ArrowRight size={18} />
</button>
</div>
</div>
);
}

266
app/(auth)/signup/page.tsx Normal file
View File

@ -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 (
<div className="min-h-screen flex items-center justify-center p-4 py-8">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8 animate-fade-in-up">
<Link href="/" className="inline-flex items-center gap-3 group">
<div className="w-14 h-14 clay-avatar">
<Building2 className="w-7 h-7" />
</div>
<div className="text-left">
<span className="text-2xl font-bold text-foreground">CRESync</span>
<p className="text-xs text-muted-foreground">Commercial Real Estate CRM</p>
</div>
</Link>
</div>
{/* Signup Card */}
<div className="clay-card p-8 animate-fade-in-scale" style={{ animationDelay: '0.1s' }}>
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2">Create Account</h1>
<p className="text-muted-foreground">Start managing your CRE business</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-4 rounded-xl bg-error-soft text-error text-sm animate-fade-in-up">
{error}
</div>
)}
{/* Name Fields */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="firstName" className="block text-sm font-medium text-foreground">
First Name
</label>
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<input
id="firstName"
type="text"
placeholder="John"
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
className="w-full clay-input clay-input-icon"
required
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="lastName" className="block text-sm font-medium text-foreground">
Last Name
</label>
<input
id="lastName"
type="text"
placeholder="Doe"
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
className="w-full clay-input"
required
/>
</div>
</div>
{/* Email */}
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<input
id="email"
type="email"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full clay-input clay-input-icon"
required
/>
</div>
</div>
{/* Brokerage */}
<div className="space-y-2">
<label htmlFor="brokerage" className="block text-sm font-medium text-foreground">
Brokerage <span className="text-muted-foreground font-normal">(Optional)</span>
</label>
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<input
id="brokerage"
type="text"
placeholder="Your Company"
value={formData.brokerage}
onChange={(e) => setFormData({ ...formData, brokerage: e.target.value })}
className="w-full clay-input clay-input-icon"
/>
</div>
</div>
{/* Password */}
<div className="space-y-2">
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<input
id="password"
type="password"
placeholder="Min 8 characters"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full clay-input clay-input-icon"
required
minLength={8}
/>
</div>
</div>
{/* Confirm Password */}
<div className="space-y-2">
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground">
Confirm Password
</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full clay-input clay-input-icon"
required
/>
</div>
</div>
{/* Terms Agreement */}
<div className="flex items-start gap-3 py-2">
<button
type="button"
onClick={() => setAgreed(!agreed)}
className={`w-5 h-5 rounded-md transition-all flex-shrink-0 mt-0.5 ${
agreed ? 'bg-primary' : 'clay-card-pressed'
}`}
>
{agreed && (
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<label
onClick={() => setAgreed(!agreed)}
className="text-sm text-muted-foreground cursor-pointer leading-relaxed"
>
I agree to the{' '}
<Link href="/terms" className="text-primary hover:underline">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
</label>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3.5 clay-btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Creating account...
</>
) : (
<>
Create Account
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
</form>
<div className="clay-divider my-6" />
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-primary font-medium hover:underline">
Sign in
</Link>
</p>
</div>
{/* Footer */}
<p className="text-center text-xs text-muted-foreground mt-6 animate-fade-in-up" style={{ animationDelay: '0.3s' }}>
Protected by industry-standard encryption
</p>
</div>
</div>
);
}

23
app/api/v1/admin/route.ts Normal file
View File

@ -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,
});
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 }
);
}
}

View File

@ -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 });
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 }
);
}
}

View File

@ -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<Response> {
// 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',
},
});
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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`;
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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',
},
});
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

83
app/api/v1/users/route.ts Normal file
View File

@ -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),
},
});
}

View File

@ -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<boolean> {
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' });
}

View File

@ -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",
});
}

531
app/globals.css Normal file
View File

@ -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;
}

22
app/layout.tsx Normal file
View File

@ -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 (
<html lang="en">
<body className={`${inter.className} bg-[#F2F5F8]`}>{children}</body>
</html>
);
}

119
app/page.tsx Normal file
View File

@ -0,0 +1,119 @@
import Link from "next/link";
import { ArrowRight, BarChart3, Users, Zap, Shield } from "lucide-react";
export default function LandingPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
{/* Navigation */}
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<span className="text-2xl font-bold text-primary-600">CRESync</span>
</div>
<div className="flex items-center gap-4">
<Link
href="/login"
className="text-slate-600 hover:text-slate-900 font-medium"
>
Sign In
</Link>
<Link
href="/signup"
className="btn-primary inline-flex items-center gap-2"
>
Get Started
<ArrowRight size={18} />
</Link>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="pt-32 pb-20 px-4">
<div className="max-w-7xl mx-auto text-center">
<h1 className="text-5xl md:text-6xl font-bold text-slate-900 mb-6">
The CRM Built for
<span className="text-primary-600"> Commercial Real Estate</span>
</h1>
<p className="text-xl text-slate-600 max-w-3xl mx-auto mb-10">
Streamline your deals, automate follow-ups, and close more transactions
with the all-in-one platform designed specifically for CRE professionals.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link
href="/signup"
className="btn-primary inline-flex items-center justify-center gap-2 text-lg px-8 py-4"
>
Start Free Trial
<ArrowRight size={20} />
</Link>
<Link
href="/login"
className="btn-secondary inline-flex items-center justify-center gap-2 text-lg px-8 py-4"
>
Sign In to Dashboard
</Link>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<h2 className="text-3xl font-bold text-center text-slate-900 mb-12">
Everything You Need to Close More Deals
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
<FeatureCard
icon={<Users className="text-primary-600" size={32} />}
title="Contact Management"
description="Organize leads, clients, and prospects with smart tagging and segmentation."
/>
<FeatureCard
icon={<Zap className="text-primary-600" size={32} />}
title="Automation"
description="Set up automated follow-up sequences and never miss an opportunity."
/>
<FeatureCard
icon={<BarChart3 className="text-primary-600" size={32} />}
title="Analytics"
description="Track your pipeline, conversion rates, and team performance."
/>
<FeatureCard
icon={<Shield className="text-primary-600" size={32} />}
title="Integrations"
description="Connect with your existing tools and data sources seamlessly."
/>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-12 px-4 border-t border-slate-200">
<div className="max-w-7xl mx-auto text-center text-slate-500">
<p>&copy; {new Date().getFullYear()} CRESync. All rights reserved.</p>
</div>
</footer>
</div>
);
}
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="clay-card">
<div className="mb-4">{icon}</div>
<h3 className="text-lg font-bold text-slate-900 mb-2">{title}</h3>
<p className="text-slate-600">{description}</p>
</div>
);
}

22
components.json Normal file
View File

@ -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": {}
}

428
components/AdminView.tsx Normal file
View File

@ -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<AdminViewProps> = ({
users,
onUserClick,
onNotifyUser
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<SortField>('createdAt');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [filterType, setFilterType] = useState<FilterType>('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'
? <ChevronUp size={16} className="inline ml-1" />
: <ChevronDown size={16} className="inline ml-1" />;
};
const StatusBadge: React.FC<{ configured: boolean; label: string }> = ({ configured, label }) => (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
configured
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{configured ? <CheckCircle size={12} /> : <XCircle size={12} />}
{label}
</span>
);
const highGCICount = users.filter(u => isHighGCI(u.gciLast12Months)).length;
const incompleteSetupCount = users.filter(u => hasIncompleteSetup(u.setupStatus)).length;
return (
<div className="max-w-7xl mx-auto py-10 px-4">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800">Admin Dashboard</h1>
<p className="text-gray-500 mt-2">Manage and monitor user onboarding progress</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<ClayCard className="flex items-center gap-4">
<div className="bg-indigo-100 p-4 rounded-2xl text-indigo-600">
<Users size={28} />
</div>
<div>
<p className="text-sm text-gray-500">Total Users</p>
<p className="text-2xl font-bold text-gray-800">{users.length}</p>
</div>
</ClayCard>
<ClayCard className="flex items-center gap-4">
<div className="bg-yellow-100 p-4 rounded-2xl text-yellow-600">
<DollarSign size={28} />
</div>
<div>
<p className="text-sm text-gray-500">High GCI Users ($100K+)</p>
<p className="text-2xl font-bold text-gray-800">{highGCICount}</p>
</div>
</ClayCard>
<ClayCard className="flex items-center gap-4">
<div className="bg-red-100 p-4 rounded-2xl text-red-600">
<AlertTriangle size={28} />
</div>
<div>
<p className="text-sm text-gray-500">Incomplete Setup</p>
<p className="text-2xl font-bold text-gray-800">{incompleteSetupCount}</p>
</div>
</ClayCard>
</div>
{/* Filters and Search */}
<ClayCard className="mb-6">
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="Search by name, email, or brokerage..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
{/* Filter Buttons */}
<div className="flex gap-2 flex-wrap">
<button
onClick={() => 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
</button>
<button
onClick={() => 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'
}`}
>
<DollarSign size={14} /> High GCI
</button>
<button
onClick={() => 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'
}`}
>
<AlertTriangle size={14} /> Incomplete
</button>
<button
onClick={() => 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'
}`}
>
<CheckCircle size={14} /> Complete
</button>
</div>
</div>
</ClayCard>
{/* Users Table */}
<ClayCard className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
onClick={() => handleSort('name')}
>
Name <SortIcon field="name" />
</th>
<th
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
onClick={() => handleSort('email')}
>
Email <SortIcon field="email" />
</th>
<th
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
onClick={() => handleSort('brokerage')}
>
Brokerage <SortIcon field="brokerage" />
</th>
<th
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
onClick={() => handleSort('yearsInBusiness')}
>
Years <SortIcon field="yearsInBusiness" />
</th>
<th
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
onClick={() => handleSort('gci')}
>
GCI (12mo) <SortIcon field="gci" />
</th>
<th className="text-left py-4 px-4 font-semibold text-gray-600">
CRM
</th>
<th className="text-left py-4 px-4 font-semibold text-gray-600">
Goals
</th>
<th
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
onClick={() => handleSort('setupCompletion')}
>
Setup Status <SortIcon field="setupCompletion" />
</th>
<th className="text-left py-4 px-4 font-semibold text-gray-600">
Actions
</th>
</tr>
</thead>
<tbody>
{filteredAndSortedUsers.map((user) => {
const userIsHighGCI = isHighGCI(user.gciLast12Months);
const userHasIncompleteSetup = hasIncompleteSetup(user.setupStatus);
return (
<tr
key={user.id}
className={`border-b border-gray-100 transition-colors hover:bg-gray-50 ${
userIsHighGCI ? 'bg-yellow-50/50' : ''
} ${userHasIncompleteSetup && !userIsHighGCI ? 'bg-red-50/30' : ''}`}
>
<td className="py-4 px-4">
<div
className="flex items-center gap-2 cursor-pointer hover:text-indigo-600"
onClick={() => onUserClick?.(user.id)}
>
<span className="font-medium text-gray-800">
{user.firstName} {user.lastName}
</span>
{userIsHighGCI && (
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 text-xs rounded-full font-medium">
High GCI
</span>
)}
</div>
</td>
<td className="py-4 px-4 text-gray-600">{user.email}</td>
<td className="py-4 px-4 text-gray-600">{user.brokerage}</td>
<td className="py-4 px-4 text-gray-600">{user.yearsInBusiness}</td>
<td className="py-4 px-4">
<span className={`font-semibold ${userIsHighGCI ? 'text-yellow-600' : 'text-gray-800'}`}>
{user.gciLast12Months}
</span>
</td>
<td className="py-4 px-4 text-gray-600">
{user.currentCRM || <span className="text-gray-400 italic">None</span>}
</td>
<td className="py-4 px-4">
<div className="flex flex-wrap gap-1">
{user.goalsSelected.length > 0 ? (
user.goalsSelected.map((goal, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded-full"
>
{goal}
</span>
))
) : (
<span className="text-gray-400 italic text-sm">No goals</span>
)}
</div>
</td>
<td className="py-4 px-4">
<div className="flex flex-wrap gap-1">
<StatusBadge configured={user.setupStatus.smsConfigured} label="SMS" />
<StatusBadge configured={user.setupStatus.emailConfigured} label="Email" />
<StatusBadge configured={user.setupStatus.contactsImported} label="Contacts" />
<StatusBadge configured={user.setupStatus.campaignsSetup} label="Campaigns" />
</div>
</td>
<td className="py-4 px-4">
<div className="flex gap-2">
<Button
variant="ghost"
onClick={() => onUserClick?.(user.id)}
className="!p-2"
>
View
</Button>
{userHasIncompleteSetup && onNotifyUser && (
<button
onClick={() => onNotifyUser(user.id)}
className="p-2 text-orange-500 hover:bg-orange-50 rounded-lg transition-colors"
title="Send reminder notification"
>
<Bell size={18} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{filteredAndSortedUsers.length === 0 && (
<div className="text-center py-12 text-gray-500">
<Users size={48} className="mx-auto mb-4 text-gray-300" />
<p className="text-lg font-medium">No users found</p>
<p className="text-sm mt-1">Try adjusting your search or filter criteria</p>
</div>
)}
</div>
</ClayCard>
{/* Results Summary */}
<div className="mt-4 text-center text-gray-500 text-sm">
Showing {filteredAndSortedUsers.length} of {users.length} users
</div>
</div>
);
};

33
components/Button.tsx Normal file
View File

@ -0,0 +1,33 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost';
fullWidth?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
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 (
<button
className={`${baseStyles} ${variants[variant]} ${fullWidth ? 'w-full' : ''} ${className}`}
disabled={disabled}
{...props}
>
{children}
</button>
);
};

57
components/ClayCard.tsx Normal file
View File

@ -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<ElevationLevel, string> = {
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<ClayCardProps> = ({
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 (
<div
onClick={onClick}
className={`
bg-background
rounded-3xl
p-6
border-2
${selected ? 'border-indigo-500' : 'border-transparent'}
${elevationClass}
transition-all
duration-300
${onClick ? 'cursor-pointer hover:transform hover:-translate-y-1' : ''}
${className}
`}
>
{children}
</div>
);
};

124
components/DFYForm.tsx Normal file
View File

@ -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<Props> = ({ 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 (
<>
<input className="input-field" placeholder="Business Legal Name" required />
<input className="input-field" placeholder="Business Address" required />
<input className="input-field" placeholder="Website URL" required />
<div className="grid grid-cols-2 gap-4">
<input className="input-field" placeholder="Contact Email" required type="email" />
<input className="input-field" placeholder="Contact Phone" required type="tel" />
</div>
<textarea className="input-field h-24" placeholder="Sample Message (Use Case)" required />
<input className="input-field" placeholder="Est. Volume (msgs/day)" type="number" />
</>
);
}
if (type === 'EMAIL') {
return (
<>
<input className="input-field" placeholder="Sending Domain (e.g. mail.company.com)" required />
<select className="input-field">
<option>I have access to DNS</option>
<option>I need to find a technician</option>
</select>
<input className="input-field" placeholder="From Name (e.g. John Doe)" required />
<input className="input-field" placeholder="Reply-To Email" required type="email" />
</>
);
}
return (
<>
<div className="bg-indigo-50 p-4 rounded-xl border border-indigo-100 mb-4 text-sm text-indigo-800">
This is a paid service ($199). You will be billed after intake.
</div>
<input className="input-field" placeholder="Target Lead Type & Region" required />
<input className="input-field" placeholder="Main Offer / CTA" required />
<textarea className="input-field h-24" placeholder="Brand Voice Description" />
<textarea className="input-field h-24" placeholder="Exclusions (Past clients, DNC...)" />
</>
);
};
if (submitted) {
return (
<div className="max-w-md mx-auto py-20 px-4 text-center animate-fadeIn">
<div className="bg-green-100 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 text-green-600 shadow-inner">
<Check size={40} />
</div>
<h2 className="text-2xl font-bold text-gray-800">Request Received!</h2>
<p className="text-gray-500 mt-2">We will start setting this up for you.</p>
</div>
);
}
return (
<div className="max-w-xl mx-auto py-10 px-4">
<button onClick={onBack} className="flex items-center text-gray-500 hover:text-indigo-600 mb-6 transition-colors">
<ArrowLeft size={18} className="mr-1" /> Back to Dashboard
</button>
<ClayCard>
<div className="mb-6 border-b border-gray-100 pb-4">
<h2 className="text-2xl font-bold text-gray-800">
{type === 'SMS' && 'Done-For-You SMS Setup'}
{type === 'EMAIL' && 'Done-For-You Email Setup'}
{type === 'CAMPAIGN' && 'Campaign Setup Service'}
</h2>
<p className="text-gray-500 mt-1 text-sm">Fill out the details below and our team will handle the technical configuration.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{renderFields()}
<Button type="submit" fullWidth className="mt-6">
Submit Request
</Button>
</form>
</ClayCard>
{/* Quick style for inputs inside this component scope */}
<style>{`
.input-field {
width: 100%;
padding: 12px 16px;
border-radius: 12px;
background-color: #F9FAFB;
border: 1px solid transparent;
box-shadow: inset 2px 2px 5px rgba(0,0,0,0.05);
outline: none;
transition: all 0.2s;
}
.input-field:focus {
background-color: #fff;
box-shadow: 0 0 0 2px #6366f1;
}
`}</style>
</div>
);
};

410
components/Dashboard.tsx Normal file
View File

@ -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<DashboardProps> = ({
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<string, string> = {
[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: <Target size={28} />,
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: <Users size={28} />,
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: <MessageSquare size={28} />,
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: <Layers size={28} />,
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: <BarChart3 size={28} />,
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: <Smartphone size={28} />,
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: <Mail size={28} />,
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<string, string> = {
[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: <Link2 size={28} />,
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: <Upload size={28} />,
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 (
<div className="max-w-4xl mx-auto py-10 px-4">
{/* Welcome Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800">
Welcome, {user_first_name || 'there'}!
</h1>
<p className="text-gray-500 mt-2 text-lg">{subtitle}</p>
</div>
{/* Setup Progress Card */}
<ClayCard className="mb-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-gray-800">Setup Progress</h2>
<p className="text-gray-500 text-sm mt-1">
{progressData.completedCount} of {progressData.items.length} steps completed
</p>
</div>
<div className="flex items-center gap-4">
<div className="w-48 h-3 bg-gray-200 rounded-full overflow-hidden shadow-inner">
<div
className="h-full bg-gradient-to-r from-indigo-500 to-indigo-600 rounded-full transition-all duration-500 ease-out"
style={{ width: `${progressData.percentage}%` }}
/>
</div>
<span className="text-2xl font-bold text-indigo-600">{progressData.percentage}%</span>
</div>
</div>
{/* Progress indicators */}
<div className="flex flex-wrap gap-3 mt-4">
{progressData.items.map((item, index) => (
<div
key={index}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
item.completed
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{item.completed && <CheckCircle size={14} />}
{item.label}
</div>
))}
</div>
</ClayCard>
{/* Completed Items */}
{completedItems.length > 0 && (
<div className="space-y-3 mb-6">
{completedItems.map(item => (
<div
key={item.id}
className="p-4 rounded-2xl border-2 border-green-100 bg-green-50/50 flex items-center gap-3 text-green-700"
>
<CheckCircle size={20} />
<span className="font-medium">{item.label}</span>
</div>
))}
</div>
)}
{/* Dynamic To-Do Items */}
<div className="space-y-4 mb-10">
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<ClipboardList size={24} className="text-indigo-600" />
Your To-Do List
</h2>
{todoItems.length === 0 ? (
<ClayCard className="text-center py-8">
<div className="text-6xl mb-4">🎉</div>
<h3 className="text-xl font-bold text-gray-800">All caught up!</h3>
<p className="text-gray-500 mt-2">You've completed all your setup tasks.</p>
</ClayCard>
) : (
<div className="grid gap-4">
{todoItems.map((item) => (
<ClayCard key={item.id} className="flex flex-col gap-4">
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
<div className={`${item.iconBgColor} p-4 rounded-2xl ${item.iconTextColor} shrink-0`}>
{item.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-800">{item.title}</h3>
<p className="text-gray-500 text-sm mt-1">{item.description}</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<Button
variant="secondary"
fullWidth
onClick={() => onSetupClick(`${item.setupType}_DIY`)}
>
DIY Setup
</Button>
<Button
fullWidth
onClick={() => onSetupClick(`${item.setupType}_DFY`)}
className="gap-2"
>
<Sparkles size={18} />
Done For You
</Button>
</div>
</ClayCard>
))}
</div>
)}
</div>
{/* Quick Links Section */}
<div className="space-y-4">
<h2 className="text-xl font-bold text-gray-800">Quick Links</h2>
<div className="grid md:grid-cols-2 gap-4">
{/* Performance Quiz Card */}
<ClayCard
onClick={onQuizClick}
className="cursor-pointer hover:transform hover:-translate-y-1 transition-all duration-300"
>
<div className="flex items-center gap-4">
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 p-4 rounded-2xl text-white">
<BarChart3 size={28} />
</div>
<div>
<h3 className="text-lg font-bold text-gray-800">Take the Performance Quiz</h3>
<p className="text-gray-500 text-sm mt-1">See how you compare to your peers</p>
</div>
</div>
</ClayCard>
{/* Browse Resources Card */}
<ClayCard
onClick={() => onSetupClick('BROWSE_RESOURCES')}
className="cursor-pointer hover:transform hover:-translate-y-1 transition-all duration-300"
>
<div className="flex items-center gap-4">
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-4 rounded-2xl text-white">
<BookOpen size={28} />
</div>
<div>
<h3 className="text-lg font-bold text-gray-800">Browse Resources</h3>
<p className="text-gray-500 text-sm mt-1">Tutorials, guides, and best practices</p>
</div>
</div>
</ClayCard>
</div>
</div>
</div>
);
};

View File

@ -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<ExternalToolsProps> = ({
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: <FileSignature size={32} />,
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: <Zap className="w-4 h-4" />, text: 'Auto-generate from deal data' },
{ icon: <Shield className="w-4 h-4" />, text: 'Industry-standard templates' },
{ icon: <Clock className="w-4 h-4" />, 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: <BarChart3 size={32} />,
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: <PieChart className="w-4 h-4" />, text: 'Interactive financial modeling' },
{ icon: <Target className="w-4 h-4" />, text: 'Scenario comparison' },
{ icon: <TrendingUp className="w-4 h-4" />, text: 'ROI & IRR calculations' },
],
capabilities: [
'Multi-year projections',
'Sensitivity analysis',
'Cap rate calculations',
'Cash flow modeling',
'Investment scoring',
],
},
];
return (
<div className="max-w-5xl mx-auto py-10 px-4">
{/* Header - Level 1 (subtle) since it's a page header */}
<ClayCard elevation="subtle" className="mb-10">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-50 rounded-full text-indigo-600 text-sm font-medium mb-4">
<Sparkles className="w-4 h-4" />
Productivity Suite
</div>
<h1 className="text-3xl font-bold text-gray-800">External Tools</h1>
<p className="text-gray-500 mt-2 max-w-2xl">
Access specialized tools designed to streamline your workflow and help you close deals faster.
Each tool is built specifically for commercial real estate professionals.
</p>
</ClayCard>
{/* Tools Grid */}
<div className="grid gap-8">
{tools.map((tool) => (
<ClayCard key={tool.id} className="relative overflow-hidden">
{/* Background Decoration */}
<div className={`absolute top-0 right-0 w-96 h-96 bg-gradient-to-br ${tool.gradientFrom} ${tool.gradientTo} rounded-full -translate-y-1/2 translate-x-1/3 opacity-60`} />
<div className="relative">
{/* Header Section */}
<div className="flex flex-col lg:flex-row gap-6 items-start">
<div className={`${tool.iconBg} p-5 rounded-2xl text-white shadow-lg flex-shrink-0`}>
{tool.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-2">
<h3 className="text-2xl font-bold text-gray-800">{tool.fullName}</h3>
{tool.badge && (
<span className={`px-3 py-1 ${tool.badgeColor} text-xs font-semibold rounded-full`}>
{tool.badge}
</span>
)}
</div>
<p className="text-gray-600 mb-4">{tool.longDescription}</p>
{/* Features Row */}
<div className="flex flex-wrap gap-4 mb-6">
{tool.features.map((feature, index) => (
<div key={index} className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded-lg">
<span className="text-gray-400">{feature.icon}</span>
{feature.text}
</div>
))}
</div>
</div>
<Button
onClick={() => handleToolAccess(tool.id, tool.id === 'loi' ? loiToolUrl : underwritingToolUrl)}
className="flex-shrink-0"
>
Access Tool
<ArrowRight size={16} />
</Button>
</div>
{/* Capabilities Section */}
<div className="mt-6 pt-6 border-t border-gray-100">
<div className="flex items-center gap-2 mb-4">
<Layers className="w-4 h-4 text-gray-400" />
<h4 className="font-semibold text-gray-700">Key Capabilities</h4>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{tool.capabilities.map((capability, index) => (
<div
key={index}
className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 hover:bg-gray-100 px-3 py-2.5 rounded-lg transition-colors"
>
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="truncate">{capability}</span>
</div>
))}
</div>
</div>
</div>
</ClayCard>
))}
</div>
{/* Coming Soon Section */}
<div className="mt-10">
<h2 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
<Award className="w-5 h-5 text-indigo-600" />
Coming Soon
</h2>
<div className="grid md:grid-cols-3 gap-4">
{[
{ name: 'Comp Analysis Tool', description: 'Market comparison insights', icon: <BarChart3 className="w-5 h-5" /> },
{ name: 'Document Generator', description: 'Custom CRE documents', icon: <FileText className="w-5 h-5" /> },
{ name: 'Deal Calculator', description: 'Quick deal metrics', icon: <Calculator className="w-5 h-5" /> },
].map((item, index) => (
<div
key={index}
className="p-5 bg-gray-50 rounded-2xl border border-dashed border-gray-300 opacity-70"
>
<div className="w-10 h-10 bg-gray-200 rounded-xl flex items-center justify-center text-gray-500 mb-3">
{item.icon}
</div>
<h4 className="font-semibold text-gray-700">{item.name}</h4>
<p className="text-sm text-gray-500 mt-1">{item.description}</p>
<span className="inline-block mt-3 text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded">
In Development
</span>
</div>
))}
</div>
</div>
</div>
);
};

280
components/Marketplace.tsx Normal file
View File

@ -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<MarketplaceProps> = ({
onQuizClick,
calendlyCoachingLink = 'https://calendly.com',
calendlyTeamLink = 'https://calendly.com',
}) => {
const handleExternalLink = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
return (
<div className="max-w-5xl mx-auto py-10 px-4">
{/* Header - Level 1 (subtle) since it's a page header */}
<ClayCard elevation="subtle" className="mb-10 text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-50 rounded-full text-indigo-600 text-sm font-medium mb-4">
<Sparkles className="w-4 h-4" />
Your Success Hub
</div>
<h1 className="text-3xl font-bold text-gray-800">Town Hall</h1>
<p className="text-gray-500 mt-2 max-w-xl mx-auto">
Resources, coaching, and opportunities to accelerate your commercial real estate success.
</p>
</ClayCard>
{/* Main Action Cards */}
<div className="grid gap-6 mb-10">
{/* Get Coaching Card */}
<ClayCard className="relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-indigo-100/50 to-purple-100/50 rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="relative flex flex-col md:flex-row gap-6 items-start md:items-center">
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 p-4 rounded-2xl text-white shadow-lg">
<GraduationCap size={32} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-xl font-bold text-gray-800">Get Coaching</h3>
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-semibold rounded-full">Popular</span>
</div>
<p className="text-gray-500 text-sm mt-1">
Work one-on-one with an expert coach to optimize your outreach strategy,
improve your scripts, and close more deals faster.
</p>
<div className="flex flex-wrap gap-3 mt-3">
<span className="flex items-center gap-1 text-xs text-gray-500">
<Target className="w-3 h-3" /> Strategy Sessions
</span>
<span className="flex items-center gap-1 text-xs text-gray-500">
<Mic className="w-3 h-3" /> Script Review
</span>
<span className="flex items-center gap-1 text-xs text-gray-500">
<TrendingUp className="w-3 h-3" /> Deal Analysis
</span>
</div>
</div>
<Button onClick={() => handleExternalLink(calendlyCoachingLink)}>
Book a Session
<ExternalLink size={16} />
</Button>
</div>
</ClayCard>
{/* Take the Quiz Card */}
<ClayCard className="relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-purple-100/50 to-pink-100/50 rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="relative flex flex-col md:flex-row gap-6 items-start md:items-center">
<div className="bg-gradient-to-br from-purple-500 to-pink-600 p-4 rounded-2xl text-white shadow-lg">
<ClipboardCheck size={32} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-xl font-bold text-gray-800">Take the Performance Quiz</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-semibold rounded-full">Free</span>
</div>
<p className="text-gray-500 text-sm mt-1">
Discover where you stand among your peers. Get personalized insights and
actionable recommendations to level up your performance.
</p>
<div className="flex flex-wrap gap-3 mt-3">
<span className="flex items-center gap-1 text-xs text-gray-500">
<Trophy className="w-3 h-3" /> Benchmarking
</span>
<span className="flex items-center gap-1 text-xs text-gray-500">
<Star className="w-3 h-3" /> Personalized Tips
</span>
<span className="flex items-center gap-1 text-xs text-gray-500">
<Clock className="w-3 h-3" /> 5 Minutes
</span>
</div>
</div>
<Button onClick={onQuizClick}>
Start Quiz
<ArrowRight size={16} />
</Button>
</div>
</ClayCard>
{/* Join Our Team Card */}
<ClayCard className="relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-emerald-100/50 to-teal-100/50 rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="relative flex flex-col md:flex-row gap-6 items-start md:items-center">
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-4 rounded-2xl text-white shadow-lg">
<Users size={32} />
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-gray-800">Join Our Team</h3>
<p className="text-gray-500 text-sm mt-1">
Interested in becoming part of our growing team? We are always looking
for talented individuals who share our passion for real estate success.
</p>
<div className="flex flex-wrap gap-3 mt-3">
<span className="flex items-center gap-1 text-xs text-gray-500">
<Star className="w-3 h-3" /> Growth Opportunities
</span>
<span className="flex items-center gap-1 text-xs text-gray-500">
<Users className="w-3 h-3" /> Collaborative Culture
</span>
</div>
</div>
<Button
variant="secondary"
onClick={() => handleExternalLink(calendlyTeamLink)}
>
Learn More
<ExternalLink size={16} />
</Button>
</div>
</ClayCard>
</div>
{/* Two Column Layout for Events and Resources */}
<div className="grid md:grid-cols-2 gap-6">
{/* Upcoming Events Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-orange-600" />
<h2 className="text-xl font-bold text-gray-800">Upcoming Events</h2>
</div>
<ClayCard className="h-full">
<div className="flex items-center gap-4 mb-5">
<div className="bg-gradient-to-br from-orange-400 to-red-500 p-3 rounded-xl text-white shadow-md">
<CalendarDays size={24} />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800">Workshops & Live Events</h3>
<p className="text-gray-500 text-sm">Join our community events to learn and network.</p>
</div>
</div>
{/* Empty State with Illustration */}
<div className="bg-gradient-to-br from-orange-50 to-amber-50 rounded-2xl p-8 text-center border border-orange-100">
<div className="w-20 h-20 mx-auto mb-4 bg-orange-100 rounded-full flex items-center justify-center">
<Video className="w-10 h-10 text-orange-400" />
</div>
<h4 className="font-semibold text-gray-700 mb-1">No Events Scheduled</h4>
<p className="text-gray-500 text-sm mb-4">
Exciting workshops and networking events are coming soon!
</p>
<div className="flex flex-wrap justify-center gap-2 text-xs text-gray-500">
<span className="px-3 py-1 bg-white rounded-full border border-gray-200">
Webinars
</span>
<span className="px-3 py-1 bg-white rounded-full border border-gray-200">
Q&A Sessions
</span>
<span className="px-3 py-1 bg-white rounded-full border border-gray-200">
Masterclasses
</span>
</div>
</div>
{/* Preview Event Card (Coming Soon) */}
<div className="mt-4 p-4 bg-gray-50 rounded-xl border border-dashed border-gray-300 opacity-60">
<div className="flex items-start gap-3">
<div className="bg-gray-200 rounded-lg p-2 text-gray-500">
<Clock className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="font-medium text-gray-600 text-sm">Coming Soon</p>
<p className="text-xs text-gray-400">Market Analysis Workshop</p>
<div className="flex items-center gap-2 mt-2 text-xs text-gray-400">
<MapPin className="w-3 h-3" /> Virtual Event
</div>
</div>
</div>
</div>
</ClayCard>
</div>
{/* Free Resources Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-teal-600" />
<h2 className="text-xl font-bold text-gray-800">Free Resources</h2>
</div>
<ClayCard className="h-full">
<div className="flex items-center gap-4 mb-5">
<div className="bg-gradient-to-br from-teal-400 to-cyan-500 p-3 rounded-xl text-white shadow-md">
<BookOpen size={24} />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800">Scripts, Guides & Templates</h3>
<p className="text-gray-500 text-sm">Download free resources to boost your productivity.</p>
</div>
</div>
{/* Empty State with Illustration */}
<div className="bg-gradient-to-br from-teal-50 to-cyan-50 rounded-2xl p-8 text-center border border-teal-100">
<div className="w-20 h-20 mx-auto mb-4 bg-teal-100 rounded-full flex items-center justify-center">
<Download className="w-10 h-10 text-teal-400" />
</div>
<h4 className="font-semibold text-gray-700 mb-1">Resources Coming Soon</h4>
<p className="text-gray-500 text-sm mb-4">
We are preparing valuable templates and guides for you.
</p>
<div className="flex flex-wrap justify-center gap-2 text-xs text-gray-500">
<span className="px-3 py-1 bg-white rounded-full border border-gray-200">
Cold Call Scripts
</span>
<span className="px-3 py-1 bg-white rounded-full border border-gray-200">
Email Templates
</span>
<span className="px-3 py-1 bg-white rounded-full border border-gray-200">
Deal Checklists
</span>
</div>
</div>
{/* Preview Resource Cards (Coming Soon) */}
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-50 rounded-xl border border-dashed border-gray-300 opacity-60">
<div className="w-8 h-8 bg-gray-200 rounded-lg flex items-center justify-center mb-2">
<FileText className="w-4 h-4 text-gray-500" />
</div>
<p className="font-medium text-gray-600 text-xs">LOI Template</p>
<p className="text-xs text-gray-400">PDF</p>
</div>
<div className="p-3 bg-gray-50 rounded-xl border border-dashed border-gray-300 opacity-60">
<div className="w-8 h-8 bg-gray-200 rounded-lg flex items-center justify-center mb-2">
<FileText className="w-4 h-4 text-gray-500" />
</div>
<p className="font-medium text-gray-600 text-xs">Call Scripts</p>
<p className="text-xs text-gray-400">DOC</p>
</div>
</div>
</ClayCard>
</div>
</div>
</div>
);
};

View File

@ -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<Props> = ({ onComplete }) => {
const [step, setStep] = useState(1);
const [data, setData] = useState<OnboardingData>(INITIAL_DATA);
const updateData = (key: keyof OnboardingData, value: any) => {
setData(prev => ({ ...prev, [key]: value }));
};
const toggleArrayItem = <T,>(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 = () => (
<div className="space-y-6 animate-fadeIn">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-800">Tell us about your experience</h2>
<p className="text-gray-500 mt-2">This helps us personalize your setup</p>
</div>
<div className="space-y-6">
<div className="space-y-3">
<label className="block text-gray-700 font-medium">How long have you been in the business?</label>
<div className="grid gap-3">
{Object.values(ExperienceLevel).map((level) => (
<ClayCard
key={level}
selected={data.years_in_business === level}
onClick={() => updateData('years_in_business', level)}
className="flex items-center justify-between"
>
<span className="font-medium text-gray-700">{level}</span>
{data.years_in_business === level && <Check className="text-indigo-600 w-6 h-6" />}
</ClayCard>
))}
</div>
</div>
<div className="space-y-3">
<label className="block text-gray-700 font-medium">How much GCI have you done in the last 12 months?</label>
<div className="grid gap-3">
{Object.values(GCIRange).map((range) => (
<ClayCard
key={range}
selected={data.gci_last_12_months === range}
onClick={() => updateData('gci_last_12_months', range)}
className="flex items-center justify-between"
>
<span className="font-medium text-gray-700">{range}</span>
{data.gci_last_12_months === range && <Check className="text-indigo-600 w-6 h-6" />}
</ClayCard>
))}
</div>
</div>
</div>
<Button
fullWidth
disabled={!data.years_in_business || !data.gci_last_12_months}
onClick={nextStep}
className="mt-8"
>
Continue <ArrowRight size={20} />
</Button>
</div>
);
// Step 2: CRM Questions
const renderStep2 = () => (
<div className="space-y-6 animate-fadeIn">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-800">Your Current CRM</h2>
<p className="text-gray-500 mt-2">Tell us about your current setup</p>
</div>
<div className="space-y-4">
<label className="block text-gray-700 font-medium">Are you currently using a CRM?</label>
<div className="flex gap-4">
<ClayCard
className="flex-1 text-center"
selected={data.using_other_crm === true}
onClick={() => updateData('using_other_crm', true)}
>
Yes
</ClayCard>
<ClayCard
className="flex-1 text-center"
selected={data.using_other_crm === false}
onClick={() => updateData('using_other_crm', false)}
>
No
</ClayCard>
</div>
</div>
{data.using_other_crm === true && (
<>
<div className="space-y-2">
<label className="block text-gray-700 font-medium">Which CRM are you using?</label>
<input
type="text"
value={data.current_crm_name}
onChange={(e) => 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"
/>
<div className="flex gap-2 flex-wrap mt-2">
{['Follow Up Boss', 'KvCore', 'Salesforce', 'HubSpot', 'Other'].map(crm => (
<button
key={crm}
onClick={() => 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}
</button>
))}
</div>
</div>
<div className="space-y-2">
<label className="block text-gray-700 font-medium">What are you looking for that it doesn't do?</label>
<p className="text-sm text-gray-500">Select all that apply</p>
<div className="grid grid-cols-2 gap-3">
{Object.values(CRMPainPoint).map((painPoint) => (
<ClayCard
key={painPoint}
selected={data.crm_pain_points.includes(painPoint)}
onClick={() => toggleArrayItem('crm_pain_points', painPoint)}
className="py-3 px-4 text-center text-sm"
>
{painPoint}
{data.crm_pain_points.includes(painPoint) && (
<Check className="inline-block ml-1 text-indigo-600 w-4 h-4" />
)}
</ClayCard>
))}
</div>
</div>
</>
)}
<Button
fullWidth
disabled={data.using_other_crm === null || (data.using_other_crm && !data.current_crm_name)}
onClick={nextStep}
>
Continue <ArrowRight size={20} />
</Button>
</div>
);
// Step 3: Goals
const renderStep3 = () => (
<div className="space-y-6 animate-fadeIn">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-800">What are you looking to accomplish?</h2>
<p className="text-gray-500 mt-2">Select all that apply</p>
</div>
<div className="grid gap-4">
{Object.values(GoalPrimary).map((goal) => (
<ClayCard
key={goal}
selected={data.goals_selected.includes(goal)}
onClick={() => toggleArrayItem('goals_selected', goal)}
className="flex items-center justify-between"
>
<span className="font-medium text-gray-700">{goal}</span>
{data.goals_selected.includes(goal) && <Check className="text-indigo-600 w-6 h-6" />}
</ClayCard>
))}
</div>
<Button
fullWidth
disabled={data.goals_selected.length === 0}
onClick={nextStep}
className="mt-8"
>
Continue <ArrowRight size={20} />
</Button>
</div>
);
// Step 4: Lead Source
const renderStep4 = () => (
<div className="space-y-6 animate-fadeIn">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-800">Lead Sources</h2>
<p className="text-gray-500 mt-2">Tell us about your lead generation</p>
</div>
<div className="space-y-4">
<label className="block text-gray-700 font-medium">Do you currently have a lead source?</label>
<div className="flex gap-4">
<ClayCard
className="flex-1 text-center"
selected={data.has_lead_source === true}
onClick={() => updateData('has_lead_source', true)}
>
Yes
</ClayCard>
<ClayCard
className="flex-1 text-center"
selected={data.has_lead_source === false}
onClick={() => updateData('has_lead_source', false)}
>
No
</ClayCard>
</div>
</div>
{data.has_lead_source === true && (
<div className="space-y-2">
<label className="block text-gray-700 font-medium">Where are your leads coming from?</label>
<input
type="text"
value={data.lead_source_name}
onChange={(e) => 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"
/>
</div>
)}
<div className="space-y-4">
<label className="block text-gray-700 font-medium">Do you want more leads?</label>
<div className="flex gap-4">
<ClayCard
className="flex-1 text-center"
selected={data.wants_more_leads === true}
onClick={() => updateData('wants_more_leads', true)}
>
Yes
</ClayCard>
<ClayCard
className="flex-1 text-center"
selected={data.wants_more_leads === false}
onClick={() => updateData('wants_more_leads', false)}
>
No
</ClayCard>
</div>
</div>
{data.wants_more_leads === true && (
<div className="space-y-2">
<label className="block text-gray-700 font-medium">What type of leads?</label>
<p className="text-sm text-gray-500">Select all that apply</p>
<div className="grid grid-cols-3 gap-3">
{[LeadType.SELLER, LeadType.BUYER, LeadType.COMMERCIAL].map((type) => (
<ClayCard
key={type}
selected={data.lead_type_desired.includes(type)}
onClick={() => toggleArrayItem('lead_type_desired', type)}
className="py-3 px-4 text-center text-sm"
>
{type}
{data.lead_type_desired.includes(type) && (
<Check className="inline-block ml-1 text-indigo-600 w-4 h-4" />
)}
</ClayCard>
))}
</div>
</div>
)}
<Button
fullWidth
disabled={
data.has_lead_source === null ||
data.wants_more_leads === null ||
(data.has_lead_source && !data.lead_source_name) ||
(data.wants_more_leads && data.lead_type_desired.length === 0)
}
onClick={nextStep}
>
Continue <ArrowRight size={20} />
</Button>
</div>
);
// Step 5: Systems to Connect
const renderStep5 = () => (
<div className="space-y-6 animate-fadeIn">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-800">Systems to Connect</h2>
<p className="text-gray-500 mt-2">What other systems would you like to connect?</p>
</div>
<div className="grid gap-4">
{Object.values(ExternalSystem).map((system) => (
<ClayCard
key={system}
selected={data.systems_to_connect.includes(system)}
onClick={() => toggleArrayItem('systems_to_connect', system)}
className="flex items-center justify-between"
>
<span className="font-medium text-gray-700">{system}</span>
{data.systems_to_connect.includes(system) && <Check className="text-indigo-600 w-6 h-6" />}
</ClayCard>
))}
</div>
<Button
fullWidth
onClick={nextStep}
className="mt-8"
>
Continue <ArrowRight size={20} />
</Button>
</div>
);
// Step 6: Contact Channels
const renderStep6 = () => (
<div className="space-y-6 animate-fadeIn">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-800">Contact Channels</h2>
<p className="text-gray-500 mt-2">How do you want to reach leads?</p>
</div>
<div className="grid gap-4">
{Object.values(Channel).map((channel) => (
<ClayCard
key={channel}
selected={data.channels_selected.includes(channel)}
onClick={() => toggleArrayItem('channels_selected', channel)}
className="flex items-center justify-between"
>
<span className="font-medium text-gray-700">{channel}</span>
{data.channels_selected.includes(channel) && <Check className="text-indigo-600 w-6 h-6" />}
</ClayCard>
))}
</div>
<Button
fullWidth
disabled={data.channels_selected.length === 0}
onClick={nextStep}
className="mt-8"
>
Continue <ArrowRight size={20} />
</Button>
</div>
);
// 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 (
<div className="space-y-6 animate-fadeIn">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-800">Let's personalize your experience</h2>
<p className="text-gray-500 mt-2">Tell us a bit about yourself</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-gray-700 font-medium">First Name *</label>
<input
type="text"
value={data.user_first_name}
onChange={(e) => updateData('user_first_name', e.target.value)}
placeholder="Enter your first name"
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="space-y-2">
<label className="block text-gray-700 font-medium">Last Name *</label>
<input
type="text"
value={data.user_last_name}
onChange={(e) => updateData('user_last_name', e.target.value)}
placeholder="Enter your last name"
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="space-y-2">
<label className="block text-gray-700 font-medium">Email *</label>
<input
type="email"
value={data.user_email}
onChange={(e) => updateData('user_email', e.target.value)}
placeholder="Enter your email address"
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="space-y-2">
<label className="block text-gray-700 font-medium">Brokerage Name *</label>
<input
type="text"
value={data.brokerage_name}
onChange={(e) => updateData('brokerage_name', e.target.value)}
placeholder="Enter your brokerage name"
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
<Button
fullWidth
disabled={!isStep7Valid}
onClick={() => onComplete(data)}
className="mt-8"
>
Finish Setup <Check size={20} />
</Button>
</div>
);
};
return (
<div className="max-w-md mx-auto py-10 px-4">
<div className="mb-8 flex justify-center space-x-2">
{Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map(s => (
<div
key={s}
className={`h-2 rounded-full transition-all duration-500 ${s <= step ? 'w-8 bg-indigo-600' : 'w-2 bg-gray-300'}`}
/>
))}
</div>
{step === 1 && renderStep1()}
{step === 2 && renderStep2()}
{step === 3 && renderStep3()}
{step === 4 && renderStep4()}
{step === 5 && renderStep5()}
{step === 6 && renderStep6()}
{step === 7 && renderStep7()}
</div>
);
};

376
components/Quiz.tsx Normal file
View File

@ -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<QuizResult, 'yearsInBusiness' | 'dealsLast12Months' | 'leadsPerMonth' | 'hoursProspecting'>;
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<string, { deals: number; leads: number; hours: number }> = {
'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<QuizResult>
): { 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<QuizProps> = ({
onComplete,
calendlyCoachingLink = 'https://calendly.com/coaching',
calendlyTeamLink = 'https://calendly.com/team',
}) => {
const [currentStep, setCurrentStep] = useState(0);
const [answers, setAnswers] = useState<Partial<QuizResult>>({});
const [showResults, setShowResults] = useState(false);
const [result, setResult] = useState<QuizResult | null>(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 (
<div className="max-w-xl mx-auto py-10 px-4 animate-fadeIn">
<ClayCard>
<div className="text-center">
<div
className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 shadow-inner ${
result.performanceLevel === 'above'
? 'bg-green-100 text-green-600'
: result.performanceLevel === 'at'
? 'bg-yellow-100 text-yellow-600'
: 'bg-indigo-100 text-indigo-600'
}`}
>
<TrendingUp size={40} />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-4">Your Results</h2>
{result.performanceLevel === 'above' && (
<div className="mb-6">
<p className="text-gray-600 leading-relaxed">
Great work! Based on your responses, you're performing above your peers.
Keep up the excellent momentum!
</p>
</div>
)}
{result.performanceLevel === 'at' && (
<div className="mb-6">
<p className="text-gray-600 leading-relaxed">
You're performing at peer level. With some additional coaching,
you could take your business to the next level.
</p>
</div>
)}
{result.performanceLevel === 'below' && (
<div className="mb-6">
<p className="text-gray-600 leading-relaxed">
Based on your peers, you may benefit from additional support.
Would you like to learn more about coaching or joining our team?
</p>
</div>
)}
{result.performanceLevel !== 'above' && (
<div className="space-y-3 mt-8">
<Button
fullWidth
variant="primary"
onClick={() => handleCalendlyClick(calendlyCoachingLink)}
className="gap-3"
>
<BookOpen size={20} />
Learn about coaching
</Button>
<Button
fullWidth
variant="secondary"
onClick={() => handleCalendlyClick(calendlyTeamLink)}
className="gap-3"
>
<Users size={20} />
Learn about joining the team
</Button>
</div>
)}
<div className="mt-8 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">
Your Responses
</h3>
<div className="grid grid-cols-2 gap-4 text-left">
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-500">Experience</p>
<p className="font-semibold text-gray-800">{result.yearsInBusiness} years</p>
</div>
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-500">Deals (12 mo)</p>
<p className="font-semibold text-gray-800">{result.dealsLast12Months}</p>
</div>
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-500">Leads/Month</p>
<p className="font-semibold text-gray-800">{result.leadsPerMonth}</p>
</div>
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-500">Prospecting Hrs/Wk</p>
<p className="font-semibold text-gray-800">{result.hoursProspecting}</p>
</div>
</div>
</div>
</div>
</ClayCard>
</div>
);
}
return (
<div className="max-w-xl mx-auto py-10 px-4">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between text-sm text-gray-500 mb-2">
<span>Question {currentStep + 1} of {questions.length}</span>
<span>{Math.round(progress)}% complete</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden shadow-inner">
<div
className="h-full bg-indigo-600 rounded-full transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<ClayCard>
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-800 mb-2">
{currentQuestion.question}
</h2>
</div>
<div className="space-y-4 mb-8">
{currentQuestion.type === 'select' && currentQuestion.options && (
<div className="space-y-3">
{currentQuestion.options.map((option) => (
<ClayCard
key={option.value}
onClick={() => handleAnswer(option.value)}
selected={getCurrentAnswer() === option.value}
className="!p-4 !rounded-xl"
>
<span className="text-gray-700 font-medium">{option.label}</span>
</ClayCard>
))}
</div>
)}
{currentQuestion.type === 'number' && (
<input
type="number"
min="0"
className="input-field"
placeholder={currentQuestion.placeholder}
value={getCurrentAnswer() ?? ''}
onChange={(e) => handleAnswer(e.target.value ? Number(e.target.value) : '')}
/>
)}
</div>
<div className="flex gap-3">
{currentStep > 0 && (
<Button variant="secondary" onClick={handleBack}>
<ArrowLeft size={18} />
Back
</Button>
)}
<Button
fullWidth
variant="primary"
onClick={handleNext}
disabled={!canProceed()}
>
{isLastQuestion ? 'See Results' : 'Continue'}
<ArrowRight size={18} />
</Button>
</div>
</ClayCard>
{/* Input field styles */}
<style>{`
.input-field {
width: 100%;
padding: 16px 20px;
border-radius: 16px;
background-color: #F9FAFB;
border: 1px solid transparent;
box-shadow: inset 2px 2px 5px rgba(0,0,0,0.05);
outline: none;
transition: all 0.2s;
font-size: 16px;
}
.input-field:focus {
background-color: #fff;
box-shadow: 0 0 0 2px #6366f1;
}
.input-field::placeholder {
color: #9CA3AF;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeIn {
animation: fadeIn 0.4s ease-out;
}
`}</style>
</div>
);
};

190
components/Sidebar.tsx Normal file
View File

@ -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: <LayoutDashboard size={20} /> },
{ id: ViewState.CONTROL_CENTER, label: 'Control Center', icon: <Sparkles size={20} /> },
{ id: ViewState.CONVERSATIONS, label: 'Conversations', icon: <MessageSquare size={20} /> },
{ id: ViewState.CONTACTS, label: 'Contacts', icon: <Users size={20} /> },
{ id: ViewState.OPPORTUNITIES, label: 'Opportunities', icon: <TrendingUp size={20} /> },
{ id: ViewState.GET_LEADS, label: 'Get Leads', icon: <Target size={20} /> },
{ id: ViewState.AUTOMATIONS, label: 'Automations', icon: <Zap size={20} /> },
{ id: ViewState.TODO_LIST, label: 'To-Do List', icon: <CheckSquare size={20} /> },
{ id: ViewState.REPORTING, label: 'Reporting', icon: <BarChart2 size={20} /> },
{ id: ViewState.MARKETPLACE, label: 'Town Hall', icon: <ShoppingBag size={20} /> },
{ id: ViewState.EXTERNAL_TOOLS, label: 'External Tools', icon: <Wrench size={20} /> },
{ id: ViewState.LEADERBOARD, label: 'Leaderboard', icon: <Award size={20} />, disabled: true, comingSoon: true },
{ id: ViewState.QUIZ, label: 'Performance Quiz', icon: <ClipboardCheck size={20} /> },
{ id: ViewState.ADMIN, label: 'Admin', icon: <Shield size={20} />, adminOnly: true },
];
export const Sidebar: React.FC<SidebarProps> = ({
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 (
<button
key={item.id}
onClick={() => 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'
}
`}
>
<span className={`flex-shrink-0 ${isActive ? 'text-white' : isDisabled ? 'text-slate-500' : 'text-slate-400 group-hover:text-indigo-400'}`}>
{item.icon}
</span>
<span className="flex-1 font-medium text-sm">{item.label}</span>
{item.comingSoon && (
<span className="text-xs bg-slate-700 text-slate-400 px-2 py-0.5 rounded-full">
Soon
</span>
)}
{isActive && (
<ChevronRight size={16} className="text-white/70" />
)}
</button>
);
};
const sidebarContent = (
<>
{/* Logo */}
<div className="p-6 border-b border-slate-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600 rounded-xl shadow-lg shadow-indigo-600/30 flex items-center justify-center text-white font-bold text-lg">
C
</div>
<span className="text-xl font-bold text-white tracking-tight">CRESync</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{visibleNavItems.map(renderNavItem)}
</nav>
{/* User Profile Section */}
<div className="p-4 border-t border-slate-800">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-slate-800/50">
<div className="w-10 h-10 bg-indigo-600/20 rounded-full flex items-center justify-center text-indigo-400">
<User size={20} />
</div>
<div className="flex-1 min-w-0">
{userName ? (
<>
<p className="text-sm font-medium text-white truncate">{userName}</p>
{userEmail && (
<p className="text-xs text-slate-400 truncate">{userEmail}</p>
)}
</>
) : (
<p className="text-sm text-slate-400">Not signed in</p>
)}
</div>
</div>
</div>
</>
);
return (
<>
{/* Mobile Hamburger Button */}
<button
onClick={toggleMobile}
className="lg:hidden fixed top-4 left-4 z-50 p-3 bg-slate-900 text-white rounded-xl shadow-lg hover:bg-slate-800 transition-colors"
aria-label="Toggle menu"
>
{isMobileOpen ? <X size={24} /> : <Menu size={24} />}
</button>
{/* Mobile Overlay */}
{isMobileOpen && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
onClick={() => setIsMobileOpen(false)}
/>
)}
{/* Sidebar - Desktop */}
<aside className="hidden lg:flex lg:flex-col fixed left-0 top-0 h-full w-64 bg-slate-900 z-40">
{sidebarContent}
</aside>
{/* Sidebar - Mobile */}
<aside
className={`
lg:hidden fixed left-0 top-0 h-full w-72 bg-slate-900 z-50
transform transition-transform duration-300 ease-in-out flex flex-col
${isMobileOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
{sidebarContent}
</aside>
</>
);
};

View File

@ -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<Props> = ({ 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 backdrop-blur-md animate-fadeIn">
<div className="text-center p-8">
<div className="relative w-24 h-24 mx-auto mb-6 bg-indigo-50 rounded-full flex items-center justify-center shadow-lg">
{step < steps.length ? (
<MousePointer2 className="text-indigo-600 animate-bounce" size={40} />
) : (
<Check className="text-green-600 scale-125 transition-transform" size={40} />
)}
</div>
<h2 className="text-3xl font-bold text-gray-800 mb-4">
{step < steps.length ? 'Guided Tour in Progress' : 'Setup Complete!'}
</h2>
<div className="space-y-3 min-h-[160px]">
{steps.map((text, index) => (
<div
key={index}
className={`transition-all duration-500 flex items-center justify-center gap-2
${index === step ? 'text-indigo-600 font-bold scale-110 opacity-100' : ''}
${index < step ? 'text-green-500 opacity-50' : ''}
${index > step ? 'text-gray-300 opacity-30 blur-[1px]' : ''}
`}
>
{index < step && <Check size={16} />}
{index === step && <Loader2 size={16} className="animate-spin" />}
{text}
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -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<string, string>;
onSave: (settings: Record<string, string>) => Promise<void>;
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<Record<string, boolean>>({});
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) => (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<div className="relative">
<input
type={showSecrets[key] ? 'text' : 'password'}
value={settings[key] || ''}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => toggleSecret(key)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showSecrets[key] ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
);
const renderTextInput = (key: string, label: string, placeholder: string) => (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<input
type="text"
value={settings[key] || ''}
onChange={(e) => 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"
/>
</div>
);
return (
<div className="space-y-8">
{/* GHL Configuration */}
<ClayCard>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-800">GoHighLevel Configuration</h3>
<Button
variant="secondary"
onClick={handleTestGHL}
disabled={testingGHL}
>
{testingGHL ? (
<Loader2 className="animate-spin" size={16} />
) : ghlStatus === 'success' ? (
<Check className="text-green-600" size={16} />
) : ghlStatus === 'error' ? (
<X className="text-red-600" size={16} />
) : null}
Test Connection
</Button>
</div>
<div className="grid gap-4">
{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')}
</div>
</ClayCard>
{/* Tag Configuration */}
<ClayCard>
<h3 className="text-lg font-bold text-gray-800 mb-6">Tag Configuration</h3>
<p className="text-sm text-gray-500 mb-4">
These tags will be applied to contacts in the owner's GHL account to trigger automations.
</p>
<div className="grid gap-4 md:grid-cols-3">
{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')}
</div>
</ClayCard>
{/* Stripe Configuration */}
<ClayCard>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-800">Stripe Configuration</h3>
<Button
variant="secondary"
onClick={handleTestStripe}
disabled={testingStripe}
>
{testingStripe ? (
<Loader2 className="animate-spin" size={16} />
) : stripeStatus === 'success' ? (
<Check className="text-green-600" size={16} />
) : stripeStatus === 'error' ? (
<X className="text-red-600" size={16} />
) : null}
Test Connection
</Button>
</div>
<div className="grid gap-4">
{renderSecretInput('stripeSecretKey', 'Stripe Secret Key', 'sk_live_... or sk_test_...')}
{renderSecretInput('stripeWebhookSecret', 'Stripe Webhook Secret', 'whsec_...')}
</div>
</ClayCard>
{/* Pricing Configuration */}
<ClayCard>
<h3 className="text-lg font-bold text-gray-800 mb-6">DFY Pricing (in cents)</h3>
<div className="grid gap-4 md:grid-cols-3">
{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')}
</div>
</ClayCard>
{/* Calendly Links */}
<ClayCard>
<h3 className="text-lg font-bold text-gray-800 mb-6">Calendly Links</h3>
<div className="grid gap-4 md:grid-cols-2">
{renderTextInput('calendlyCoachingLink', 'Coaching Calendly Link', 'https://calendly.com/...')}
{renderTextInput('calendlyTeamLink', 'Join Team Calendly Link', 'https://calendly.com/...')}
</div>
</ClayCard>
{/* Notifications */}
<ClayCard>
<h3 className="text-lg font-bold text-gray-800 mb-6">Notifications</h3>
<div className="grid gap-4">
{renderTextInput('notificationEmail', 'Notification Email', 'Email for high-GCI alerts')}
</div>
</ClayCard>
{/* ClickUp Integration */}
<ClayCard>
<h3 className="text-lg font-bold text-gray-800 mb-6">ClickUp Integration (for DFY tasks)</h3>
<div className="grid gap-4 md:grid-cols-2">
{renderSecretInput('clickupApiKey', 'ClickUp API Key', 'Enter your ClickUp API key')}
{renderTextInput('clickupListId', 'ClickUp List ID', 'List ID for DFY tasks')}
</div>
</ClayCard>
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="animate-spin mr-2" size={16} /> : <Save className="mr-2" size={16} />}
Save Settings
</Button>
</div>
</div>
);
}

View File

@ -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<HTMLButtonElement> {
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 (
<button {...props} disabled={disabled || !canAccess}>
{children}
</button>
);
}

View File

@ -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 || <LoadingSpinner />;
}
if (!hasAccess()) {
return fallback || null;
}
return <>{children}</>;
}
function LoadingSpinner() {
return (
<div className="flex items-center justify-center min-h-[200px]">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
</div>
);
}

View File

@ -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 <RoleGate requireAdmin fallback={fallback}>{children}</RoleGate>;
}
export function SuperAdminOnly({ children, fallback }: { children: React.ReactNode; fallback?: React.ReactNode }) {
return <RoleGate requireSuperAdmin fallback={fallback}>{children}</RoleGate>;
}

3
components/auth/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { ProtectedRoute } from './ProtectedRoute';
export { RoleGate, AdminOnly, SuperAdminOnly } from './RoleGate';
export { PermissionButton } from './PermissionButton';

View File

@ -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<AIMessageBubbleProps> = ({
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 (
<div className="flex gap-3 max-w-[85%]">
{/* AI Avatar */}
<div
className={cn(
'shrink-0 w-9 h-9 rounded-xl flex items-center justify-center',
'bg-gradient-to-br from-indigo-500 to-purple-600 text-white',
'shadow-[3px_3px_6px_#c5c9d1,-3px_-3px_6px_#ffffff]'
)}
>
<Bot size={18} />
</div>
{/* Message Content */}
<div className="flex-1 space-y-3">
{/* Text Content Bubble */}
{(hasContent || isStreaming) && (
<div
className={cn(
'bg-[#F0F4F8] rounded-2xl rounded-tl-md p-4',
'shadow-[6px_6px_12px_#bfc3cc,-6px_-6px_12px_#ffffff]',
'border-2 border-transparent transition-all duration-300'
)}
>
{/* Message Text */}
<div className="text-gray-800 text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
{isStreaming && (
<span className="inline-flex ml-1">
<span className="animate-pulse">|</span>
</span>
)}
</div>
{/* Streaming "Thinking" Animation */}
{isStreaming && !hasContent && (
<div className="flex items-center gap-2 text-gray-500">
<div className="flex gap-1">
<span
className="w-2 h-2 bg-indigo-400 rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 bg-indigo-400 rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 bg-indigo-400 rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-xs text-gray-400">Thinking...</span>
</div>
)}
</div>
)}
{/* Tool Calls */}
{hasToolCalls && (
<div className="space-y-2">
{message.toolCalls!.map((toolCall) => (
<ToolCallCard
key={toolCall.id}
toolCall={toolCall}
isExecuting={isStreaming}
/>
))}
</div>
)}
{/* Tool Results */}
{hasToolResults && (
<div className="space-y-2">
{message.toolResults!.map((toolResult) => (
<ToolResultCard
key={toolResult.toolCallId}
toolResult={toolResult}
/>
))}
</div>
)}
{/* Timestamp */}
<div className="text-xs text-gray-400 mt-1 px-1">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
);
};
export default AIMessageBubble;

View File

@ -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<ChatComposerProps> = ({
onSend,
disabled = false,
isStreaming = false,
showProviderSelector = false,
}) => {
const [message, setMessage] = useState('');
const [provider, setProvider] = useState<AIProvider>('claude');
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
// Ctrl/Cmd + Enter to send
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
},
[]
);
return (
<div
className={cn(
'bg-[#F0F4F8]',
'rounded-2xl',
'p-4',
'shadow-[6px_6px_12px_#bfc3cc,-6px_-6px_12px_#ffffff]',
'transition-all duration-300'
)}
>
<div className="flex flex-col gap-3">
{/* Provider selector (optional) */}
{showProviderSelector && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Provider:</span>
<Select
value={provider}
onValueChange={(value: AIProvider) => setProvider(value)}
disabled={isDisabled}
>
<SelectTrigger
className={cn(
'w-[140px] h-8',
'bg-[#F0F4F8]',
'border-0',
'shadow-[inset_2px_2px_4px_rgba(0,0,0,0.05),inset_-2px_-2px_4px_rgba(255,255,255,0.8)]',
'rounded-lg',
'text-sm',
'focus:ring-indigo-500'
)}
>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Main input area */}
<div className="flex items-end gap-3">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={message}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Ask me anything about your GHL account..."
disabled={isDisabled}
rows={1}
className={cn(
'w-full',
'min-h-[44px]',
'max-h-[200px]',
'px-4 py-3',
'bg-[#F0F4F8]',
'border-0',
'rounded-xl',
'shadow-[inset_3px_3px_6px_rgba(0,0,0,0.06),inset_-3px_-3px_6px_rgba(255,255,255,0.9)]',
'text-gray-800',
'placeholder:text-gray-400',
'resize-none',
'focus:outline-none',
'focus:ring-2',
'focus:ring-indigo-400',
'focus:ring-offset-0',
'transition-all duration-200',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
'text-sm md:text-base'
)}
/>
</div>
{/* Send button */}
<button
onClick={handleSend}
disabled={!canSend}
aria-label="Send message"
className={cn(
'flex items-center justify-center',
'w-11 h-11',
'rounded-xl',
'transition-all duration-200',
canSend
? [
'bg-indigo-500',
'text-white',
'shadow-[4px_4px_8px_#bfc3cc,-4px_-4px_8px_#ffffff]',
'hover:bg-indigo-600',
'hover:shadow-[2px_2px_4px_#bfc3cc,-2px_-2px_4px_#ffffff]',
'active:shadow-[inset_2px_2px_4px_rgba(0,0,0,0.1)]',
]
: [
'bg-[#F0F4F8]',
'text-gray-400',
'shadow-[inset_2px_2px_4px_rgba(0,0,0,0.05),inset_-2px_-2px_4px_rgba(255,255,255,0.8)]',
'cursor-not-allowed',
]
)}
>
<Send className="w-5 h-5" />
</button>
</div>
{/* Keyboard shortcut hint */}
<div className="flex justify-end">
<span className="text-xs text-gray-400">
Press <kbd className="px-1.5 py-0.5 bg-gray-200 rounded text-gray-600 font-mono">Ctrl</kbd>
{' + '}
<kbd className="px-1.5 py-0.5 bg-gray-200 rounded text-gray-600 font-mono">Enter</kbd>
{' to send'}
</span>
</div>
</div>
</div>
);
};
export default ChatComposer;

View File

@ -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 (
<div className="flex items-center gap-2">
<span
className={cn(
'w-2.5 h-2.5 rounded-full',
config.color,
config.pulse && 'animate-pulse'
)}
/>
<span className="text-xs text-gray-500 font-medium">{config.label}</span>
</div>
);
}
// =============================================================================
// Error Banner Component
// =============================================================================
interface ErrorBannerProps {
message: string;
onDismiss: () => void;
}
function ErrorBanner({ message, onDismiss }: ErrorBannerProps) {
return (
<div
className={cn(
'flex items-center gap-3 px-4 py-3 mx-4 mt-4 rounded-xl',
'bg-red-50 border border-red-200',
'shadow-[inset_2px_2px_4px_rgba(0,0,0,0.03),inset_-2px_-2px_4px_rgba(255,255,255,0.5)]'
)}
>
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
<p className="flex-1 text-sm text-red-700">{message}</p>
<button
onClick={onDismiss}
className={cn(
'p-1 rounded-lg text-red-400 hover:text-red-600',
'hover:bg-red-100 transition-colors'
)}
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
// =============================================================================
// Header Component
// =============================================================================
interface HeaderProps {
status: 'idle' | 'loading' | 'streaming' | 'error';
}
function Header({ status }: HeaderProps) {
return (
<header
className={cn(
'flex items-center justify-between px-6 py-4',
'border-b border-gray-200/60',
'bg-gradient-to-r from-[#F0F4F8] to-[#E8ECF0]',
'shadow-[inset_0_-2px_4px_rgba(0,0,0,0.02)]'
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center',
'bg-gradient-to-br from-indigo-500 to-purple-600',
'shadow-[4px_4px_8px_#bfc3cc,-4px_-4px_8px_#ffffff]'
)}
>
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-gray-800">Control Center</h1>
<p className="text-xs text-gray-500">AI-powered assistant</p>
</div>
</div>
<StatusIndicator status={status} />
</header>
);
}
// =============================================================================
// 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 (
<div
className={cn(
'flex flex-col h-full w-full',
'bg-[#F0F4F8]',
'rounded-3xl overflow-hidden',
'shadow-[8px_8px_16px_#b8bcc5,-8px_-8px_16px_#ffffff]',
'border-2 border-white/50',
className
)}
>
{/* Header */}
<Header status={getStatus()} />
{/* Error Banner */}
{error && (
<ErrorBanner message={error} onDismiss={clearError} />
)}
{/* Message List */}
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<MessageList />
</div>
{/* Chat Composer */}
<div
className={cn(
'px-4 py-4 border-t border-gray-200/60',
'bg-gradient-to-r from-[#F0F4F8] to-[#E8ECF0]'
)}
>
<ChatComposer
onSend={handleSend}
disabled={isLoading}
isStreaming={isStreaming}
/>
</div>
</div>
);
}
export default ChatInterface;

View File

@ -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<void>;
/** Select and load a conversation by ID */
selectConversation: (id: string) => Promise<void>;
/** Send a message to the current conversation */
sendMessage: (message: string, provider?: string) => Promise<void>;
/** Start a new conversation */
newConversation: () => void;
/** Clear the current error */
clearError: () => void;
}
type ChatContextValue = ChatContextState & ChatContextActions;
// =============================================================================
// Context
// =============================================================================
const ChatContext = createContext<ChatContextValue | null>(null);
// =============================================================================
// Provider Component
// =============================================================================
interface ChatProviderProps {
children: ReactNode;
}
export function ChatProvider({ children }: ChatProviderProps) {
// State
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [currentConversation, setCurrentConversation] = useState<ControlCenterConversation | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [error, setError] = useState<string | null>(null);
// Abort controller for cancelling streams
const abortControllerRef = useRef<AbortController | null>(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 (
<ChatContext.Provider value={value}>
{children}
</ChatContext.Provider>
);
}
// =============================================================================
// 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;

View File

@ -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<ConversationSidebarProps> = ({
conversations,
currentId,
onSelect,
onNew,
}) => {
return (
<div
className={cn(
'flex flex-col',
'h-full',
'bg-[#F0F4F8]',
'rounded-2xl',
'shadow-[6px_6px_12px_#bfc3cc,-6px_-6px_12px_#ffffff]',
'overflow-hidden'
)}
>
{/* Header with New Chat button */}
<div className="p-4 border-b border-gray-200/50">
<button
onClick={onNew}
className={cn(
'w-full',
'flex items-center justify-center gap-2',
'px-4 py-3',
'bg-indigo-500',
'text-white',
'rounded-xl',
'font-medium',
'shadow-[4px_4px_8px_#bfc3cc,-4px_-4px_8px_#ffffff]',
'hover:bg-indigo-600',
'hover:shadow-[2px_2px_4px_#bfc3cc,-2px_-2px_4px_#ffffff]',
'active:shadow-[inset_2px_2px_4px_rgba(0,0,0,0.1)]',
'transition-all duration-200'
)}
>
<Plus className="w-5 h-5" />
<span>New Chat</span>
</button>
</div>
{/* Conversation list */}
<ScrollArea className="flex-1">
<div className="p-3">
{conversations.length === 0 ? (
// Empty state
<div className="flex flex-col items-center justify-center py-12 px-4">
<div
className={cn(
'w-16 h-16',
'flex items-center justify-center',
'rounded-2xl',
'bg-[#F0F4F8]',
'shadow-[inset_3px_3px_6px_rgba(0,0,0,0.06),inset_-3px_-3px_6px_rgba(255,255,255,0.9)]',
'mb-4'
)}
>
<MessageSquare className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-sm font-medium text-gray-600 mb-1">
No conversations yet
</h3>
<p className="text-xs text-gray-400 text-center">
Start a new chat to begin exploring your GHL account
</p>
</div>
) : (
// Conversation items
<div className="flex flex-col gap-2">
{conversations.map((conversation) => {
const isSelected = conversation.id === currentId;
const displayTitle = conversation.title || 'New conversation';
return (
<button
key={conversation.id}
onClick={() => onSelect(conversation.id)}
className={cn(
'w-full',
'text-left',
'px-4 py-3',
'rounded-xl',
'transition-all duration-200',
isSelected
? [
'bg-[#F0F4F8]',
'shadow-[inset_4px_4px_8px_rgba(0,0,0,0.05),inset_-4px_-4px_8px_rgba(255,255,255,0.8)]',
'border-2 border-indigo-500',
]
: [
'bg-[#F0F4F8]',
'shadow-[3px_3px_6px_#c5c9d1,-3px_-3px_6px_#ffffff]',
'border-2 border-transparent',
'hover:shadow-[2px_2px_4px_#c5c9d1,-2px_-2px_4px_#ffffff]',
'hover:border-gray-300',
]
)}
>
<div className="flex flex-col gap-1">
<span
className={cn(
'text-sm font-medium truncate',
isSelected ? 'text-indigo-700' : 'text-gray-700'
)}
>
{displayTitle}
</span>
<span className="text-xs text-gray-400">
{formatRelativeTime(conversation.createdAt)}
</span>
</div>
</button>
);
})}
</div>
)}
</div>
</ScrollArea>
</div>
);
};
export default ConversationSidebar;

View File

@ -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 (
<div className="flex justify-center mb-4">
<div
className={cn(
'max-w-[90%] rounded-xl px-4 py-2',
'bg-gray-100 text-gray-500',
'border border-gray-200',
'text-xs text-center'
)}
>
{message.content}
</div>
</div>
);
}
// =============================================================================
// Empty State Component
// =============================================================================
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
<div
className={cn(
'w-20 h-20 rounded-full flex items-center justify-center mb-6',
'bg-gradient-to-br from-indigo-100 to-purple-100',
'shadow-[6px_6px_12px_#bfc3cc,-6px_-6px_12px_#ffffff]'
)}
>
<svg
className="w-10 h-10 text-indigo-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-700 mb-2">
Start a Conversation
</h3>
<p className="text-sm text-gray-500 max-w-sm">
Ask me anything about your CRM data, create automations, or get help
managing your commercial real estate business.
</p>
</div>
);
}
// =============================================================================
// Loading Indicator
// =============================================================================
function LoadingIndicator() {
return (
<div className="flex justify-start mb-4">
<div
className={cn(
'rounded-2xl rounded-bl-md px-4 py-3',
'bg-[#F0F4F8]',
'shadow-[4px_4px_8px_#d1d5db,-4px_-4px_8px_#ffffff]',
'border border-gray-100'
)}
>
<div className="flex items-center gap-1">
<span className="w-2 h-2 bg-indigo-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-indigo-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-indigo-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
);
}
// =============================================================================
// Main Component
// =============================================================================
export function MessageList({ className }: MessageListProps) {
const {
currentConversation,
isLoading,
isStreaming,
streamingContent,
} = useChatContext();
const scrollRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div
ref={scrollRef}
className={cn(
'flex-1 overflow-y-auto',
className
)}
>
<div className="p-4 md:p-6">
{!hasMessages && !isLoading ? (
<EmptyState />
) : (
<div className="space-y-4">
{messages.map((message) => {
switch (message.role) {
case 'user':
return (
<UserMessageBubble
key={message.id}
message={message}
/>
);
case 'assistant':
return (
<AIMessageBubble
key={message.id}
message={message}
/>
);
case 'system':
return (
<SystemMessage
key={message.id}
message={message}
/>
);
default:
return null;
}
})}
{/* Show streaming content */}
{isStreaming && streamingContent && (
<AIMessageBubble
message={{
id: 'streaming',
role: 'assistant',
content: streamingContent,
createdAt: new Date().toISOString(),
}}
isStreaming={true}
/>
)}
{/* Show loading indicator when waiting for response but not yet streaming */}
{isLoading && !isStreaming && !streamingContent && (
<LoadingIndicator />
)}
{/* Scroll anchor */}
<div ref={messagesEndRef} />
</div>
)}
</div>
</div>
);
}
export default MessageList;

View File

@ -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: <Wifi className="w-3.5 h-3.5 text-green-600" />,
},
connecting: {
color: 'bg-yellow-500',
bgColor: 'bg-yellow-100',
pulseColor: 'bg-yellow-400',
text: 'Connecting...',
icon: <Loader2 className="w-3.5 h-3.5 text-yellow-600 animate-spin" />,
},
error: {
color: 'bg-red-500',
bgColor: 'bg-red-100',
text: 'Connection Error',
icon: <WifiOff className="w-3.5 h-3.5 text-red-600" />,
},
idle: {
color: 'bg-gray-400',
bgColor: 'bg-gray-100',
text: 'Idle',
icon: <Wifi className="w-3.5 h-3.5 text-gray-500" />,
},
};
/**
* 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<StatusIndicatorProps> = ({
status,
mcpConnected = true,
label,
}) => {
const config = STATUS_CONFIG[status];
const showMcpWarning = !mcpConnected;
return (
<TooltipProvider>
<div className="flex items-center gap-2">
{/* Main status indicator */}
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'flex items-center gap-2',
'px-3 py-1.5',
'bg-[#F0F4F8]',
'rounded-full',
'shadow-[3px_3px_6px_#c5c9d1,-3px_-3px_6px_#ffffff]',
'cursor-default',
'transition-all duration-200'
)}
>
{/* Status dot with pulse animation for active states */}
<div className="relative flex items-center justify-center">
<span
className={cn(
'w-2.5 h-2.5',
'rounded-full',
config.color,
'relative z-10'
)}
/>
{config.pulseColor && (
<span
className={cn(
'absolute',
'w-2.5 h-2.5',
'rounded-full',
config.pulseColor,
'animate-ping',
'opacity-75'
)}
/>
)}
</div>
{/* Status text/label */}
<span className="text-xs font-medium text-gray-600">
{label || config.text}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<div className="flex items-center gap-2">
{config.icon}
<span>{config.text}</span>
</div>
</TooltipContent>
</Tooltip>
{/* MCP Warning indicator */}
{showMcpWarning && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'flex items-center gap-1.5',
'px-2.5 py-1.5',
'bg-amber-50',
'border border-amber-200',
'rounded-full',
'cursor-default',
'transition-all duration-200'
)}
>
<AlertTriangle className="w-3.5 h-3.5 text-amber-600" />
<span className="text-xs font-medium text-amber-700">
Limited
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[200px] text-xs">
<div className="flex flex-col gap-1">
<span className="font-medium text-amber-700">
MCP Not Connected
</span>
<span className="text-gray-600">
Some features may be unavailable. Tool execution will be limited.
</span>
</div>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
};
/**
* 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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'relative flex items-center justify-center',
'w-4 h-4',
className
)}
>
<span
className={cn(
'w-2.5 h-2.5',
'rounded-full',
config.color,
'relative z-10'
)}
/>
{config.pulseColor && (
<span
className={cn(
'absolute',
'w-2.5 h-2.5',
'rounded-full',
config.pulseColor,
'animate-ping',
'opacity-75'
)}
/>
)}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
<div className="flex items-center gap-2">
{config.icon}
<span>{config.text}</span>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default StatusIndicator;

View File

@ -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<ToolCallCardProps> = ({
toolCall,
isExecuting = false,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const hasInput = toolCall.input && Object.keys(toolCall.input).length > 0;
return (
<div
className={cn(
'bg-[#E8EDF2] rounded-2xl border-2 border-transparent',
'shadow-[4px_4px_8px_#c5c9d1,-4px_-4px_8px_#ffffff]',
'transition-all duration-300',
isExecuting && 'border-amber-400'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center gap-3 p-4',
hasInput && 'cursor-pointer'
)}
onClick={() => hasInput && setIsExpanded(!isExpanded)}
>
{/* Tool Icon */}
<div
className={cn(
'p-2.5 rounded-xl',
isExecuting
? 'bg-amber-100 text-amber-600'
: 'bg-indigo-100 text-indigo-600'
)}
>
{isExecuting ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Wrench size={18} />
)}
</div>
{/* Tool Name */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-800 truncate">
{toolCall.name}
</span>
{isExecuting && (
<span className="text-xs text-amber-600 font-medium">
Executing...
</span>
)}
</div>
{!isExpanded && hasInput && (
<p className="text-xs text-gray-500 mt-0.5 truncate">
{Object.keys(toolCall.input).length} parameter
{Object.keys(toolCall.input).length !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Expand/Collapse Toggle */}
{hasInput && (
<button
className={cn(
'p-1.5 rounded-lg transition-colors',
'hover:bg-gray-200 text-gray-500'
)}
aria-label={isExpanded ? 'Collapse parameters' : 'Expand parameters'}
>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
)}
</div>
{/* Collapsible Parameters */}
{isExpanded && hasInput && (
<div className="px-4 pb-4">
<div
className={cn(
'bg-[#F5F8FA] rounded-xl p-3',
'shadow-[inset_2px_2px_4px_rgba(0,0,0,0.05),inset_-2px_-2px_4px_rgba(255,255,255,0.7)]'
)}
>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Parameters
</p>
<pre className="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-words font-mono">
{JSON.stringify(toolCall.input, null, 2)}
</pre>
</div>
</div>
)}
</div>
);
};
export default ToolCallCard;

View File

@ -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<ToolResultCardProps> = ({ 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 (
<div
className={cn(
'bg-[#E8EDF2] rounded-2xl border-2',
'shadow-[4px_4px_8px_#c5c9d1,-4px_-4px_8px_#ffffff]',
'transition-all duration-300',
isError ? 'border-danger-200' : 'border-success-200'
)}
>
{/* Header */}
<div
className="flex items-center gap-3 p-4 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
{/* Status Icon */}
<div
className={cn(
'p-2.5 rounded-xl',
isError
? 'bg-danger-100 text-danger-600'
: 'bg-success-100 text-success-600'
)}
>
{isError ? <XCircle size={18} /> : <CheckCircle size={18} />}
</div>
{/* Result Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<FileCode size={14} className="text-gray-400" />
<span className="text-sm font-medium text-gray-600">
Tool Result
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className={cn(
'text-xs font-semibold px-2 py-0.5 rounded-full',
isError
? 'bg-danger-100 text-danger-700'
: 'bg-success-100 text-success-700'
)}
>
{isError ? 'Error' : 'Success'}
</span>
{!isExpanded && (
<span className="text-xs text-gray-500 truncate">
{resultString.substring(0, 50)}
{resultString.length > 50 ? '...' : ''}
</span>
)}
</div>
</div>
{/* Expand/Collapse Toggle */}
<button
className={cn(
'p-1.5 rounded-lg transition-colors',
'hover:bg-gray-200 text-gray-500'
)}
aria-label={isExpanded ? 'Collapse result' : 'Expand result'}
>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{/* Collapsible Result Content */}
{isExpanded && (
<div className="px-4 pb-4">
<div
className={cn(
'rounded-xl p-3',
'shadow-[inset_2px_2px_4px_rgba(0,0,0,0.05),inset_-2px_-2px_4px_rgba(255,255,255,0.7)]',
isError ? 'bg-danger-50' : 'bg-[#F5F8FA]'
)}
>
<p
className={cn(
'text-xs font-medium uppercase tracking-wide mb-2',
isError ? 'text-danger-600' : 'text-gray-500'
)}
>
{isError ? 'Error Details' : 'Result Data'}
</p>
<pre
className={cn(
'text-xs overflow-x-auto whitespace-pre-wrap break-words font-mono max-h-64 overflow-y-auto',
isError ? 'text-danger-700' : 'text-gray-700'
)}
>
{formatResult(resultData)}
</pre>
</div>
</div>
)}
</div>
);
};
export default ToolResultCard;

View File

@ -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<UserMessageBubbleProps> = ({
message,
}) => {
return (
<div className="flex justify-end">
<div className="max-w-[75%] space-y-1">
{/* Message Bubble */}
<div
className={cn(
'bg-indigo-600 text-white rounded-2xl rounded-tr-md px-4 py-3',
'shadow-[6px_6px_12px_rgba(79,70,229,0.25),-4px_-4px_8px_rgba(255,255,255,0.15)]',
'transition-all duration-300'
)}
>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
</p>
</div>
{/* Timestamp */}
<div className="text-xs text-gray-400 text-right px-1">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
);
};
export default UserMessageBubble;

View File

@ -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';

View File

@ -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: <T = any>(event: RealtimeEvent, handler: (data: T) => void) => () => void;
lastEvent: RealtimeMessage | null;
}
const RealtimeContext = createContext<RealtimeContextValue | null>(null);
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<RealtimeMessage | null>(null);
const [handlers, setHandlers] = useState<Map<RealtimeEvent, Set<(data: any) => 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 = <T = any>(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 (
<RealtimeContext.Provider value={{ isConnected, subscribe, lastEvent }}>
{children}
</RealtimeContext.Provider>
);
}
export function useRealtimeContext() {
const context = useContext(RealtimeContext);
if (!context) {
throw new Error('useRealtimeContext must be used within RealtimeProvider');
}
return context;
}

59
components/ui/alert.tsx Normal file
View File

@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

50
components/ui/avatar.tsx Normal file
View File

@ -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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

36
components/ui/badge.tsx Normal file
View File

@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

57
components/ui/button.tsx Normal file
View File

@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

76
components/ui/card.tsx Normal file
View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

Some files were not shown because too many files have changed in this diff Show More