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:
parent
cda952f5f7
commit
4e6467ffb0
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cresync?schema=public"
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
318
__tests__/api/control-center/conversations.test.ts
Normal file
318
__tests__/api/control-center/conversations.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
296
__tests__/lib/control-center/conversation-service.test.ts
Normal file
296
__tests__/lib/control-center/conversation-service.test.ts
Normal 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
341
__tests__/lib/control-center/tool-router.test.ts
Normal file
341
__tests__/lib/control-center/tool-router.test.ts
Normal 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
46
__tests__/setup.ts
Normal 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();
|
||||||
19
app/(app)/admin/layout.tsx
Normal file
19
app/(app)/admin/layout.tsx
Normal 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
1127
app/(app)/admin/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
151
app/(app)/automations/page.tsx
Normal file
151
app/(app)/automations/page.tsx
Normal 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're on the list! We'll notify you when it'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'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
1665
app/(app)/contacts/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
297
app/(app)/control-center/page.tsx
Normal file
297
app/(app)/control-center/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
965
app/(app)/conversations/page.tsx
Normal file
965
app/(app)/conversations/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
618
app/(app)/dashboard/page.tsx
Normal file
618
app/(app)/dashboard/page.tsx
Normal 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's what'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
114
app/(app)/dfy/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/(app)/dfy/success/page.tsx
Normal file
39
app/(app)/dfy/success/page.tsx
Normal 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'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
685
app/(app)/layout.tsx
Normal 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
193
app/(app)/leads/page.tsx
Normal 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're on the list! We'll notify you when it'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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
app/(app)/marketplace/page.tsx
Normal file
19
app/(app)/marketplace/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1414
app/(app)/opportunities/page.tsx
Normal file
1414
app/(app)/opportunities/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
197
app/(app)/reporting/page.tsx
Normal file
197
app/(app)/reporting/page.tsx
Normal 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're on the list! We'll notify you when it'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'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
582
app/(app)/settings/page.tsx
Normal 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
290
app/(app)/setup/page.tsx
Normal 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
18
app/(app)/tools/page.tsx
Normal 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
17
app/(auth)/layout.tsx
Normal 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
167
app/(auth)/login/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
app/(auth)/onboarding/page.tsx
Normal file
210
app/(auth)/onboarding/page.tsx
Normal 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
266
app/(auth)/signup/page.tsx
Normal 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
23
app/api/v1/admin/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
70
app/api/v1/admin/settings/route.ts
Normal file
70
app/api/v1/admin/settings/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/v1/admin/settings/test/route.ts
Normal file
32
app/api/v1/admin/settings/test/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/v1/admin/stats/route.ts
Normal file
56
app/api/v1/admin/stats/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/api/v1/auth/login/route.ts
Normal file
73
app/api/v1/auth/login/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/api/v1/auth/logout/route.ts
Normal file
7
app/api/v1/auth/logout/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
87
app/api/v1/auth/me/route.ts
Normal file
87
app/api/v1/auth/me/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/api/v1/auth/signup/route.ts
Normal file
94
app/api/v1/auth/signup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/api/v1/contacts/[contactId]/route.ts
Normal file
115
app/api/v1/contacts/[contactId]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/api/v1/contacts/[contactId]/tags/route.ts
Normal file
63
app/api/v1/contacts/[contactId]/tags/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/api/v1/contacts/route.ts
Normal file
82
app/api/v1/contacts/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
345
app/api/v1/control-center/chat/route.ts
Normal file
345
app/api/v1/control-center/chat/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
37
app/api/v1/control-center/conversations/route.ts
Normal file
37
app/api/v1/control-center/conversations/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/api/v1/control-center/history/[conversationId]/route.ts
Normal file
42
app/api/v1/control-center/history/[conversationId]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/api/v1/control-center/tools/route.ts
Normal file
58
app/api/v1/control-center/tools/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/v1/conversations/[conversationId]/messages/route.ts
Normal file
29
app/api/v1/conversations/[conversationId]/messages/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/v1/conversations/[conversationId]/route.ts
Normal file
51
app/api/v1/conversations/[conversationId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/api/v1/conversations/[conversationId]/status/route.ts
Normal file
47
app/api/v1/conversations/[conversationId]/status/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/v1/conversations/route.ts
Normal file
32
app/api/v1/conversations/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/api/v1/conversations/send/route.ts
Normal file
62
app/api/v1/conversations/send/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
297
app/api/v1/dashboard/recommendations/route.ts
Normal file
297
app/api/v1/dashboard/recommendations/route.ts
Normal 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`;
|
||||||
|
}
|
||||||
70
app/api/v1/dashboard/stats/route.ts
Normal file
70
app/api/v1/dashboard/stats/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/api/v1/onboarding/route.ts
Normal file
96
app/api/v1/onboarding/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/v1/onboarding/status/route.ts
Normal file
54
app/api/v1/onboarding/status/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/api/v1/opportunities/[opportunityId]/route.ts
Normal file
89
app/api/v1/opportunities/[opportunityId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/api/v1/opportunities/pipelines/[pipelineId]/route.ts
Normal file
83
app/api/v1/opportunities/pipelines/[pipelineId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/v1/opportunities/pipelines/route.ts
Normal file
54
app/api/v1/opportunities/pipelines/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/api/v1/opportunities/route.ts
Normal file
70
app/api/v1/opportunities/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/api/v1/realtime/events/route.ts
Normal file
74
app/api/v1/realtime/events/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
40
app/api/v1/stripe/checkout/route.ts
Normal file
40
app/api/v1/stripe/checkout/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/api/v1/stripe/webhook/route.ts
Normal file
49
app/api/v1/stripe/webhook/route.ts
Normal 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
83
app/api/v1/users/route.ts
Normal 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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
54
app/api/v1/webhooks/ghl/route.ts
Normal file
54
app/api/v1/webhooks/ghl/route.ts
Normal 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' });
|
||||||
|
}
|
||||||
20
app/api/v1/webhooks/route.ts
Normal file
20
app/api/v1/webhooks/route.ts
Normal 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
531
app/globals.css
Normal 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
22
app/layout.tsx
Normal 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
119
app/page.tsx
Normal 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>© {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
22
components.json
Normal 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
428
components/AdminView.tsx
Normal 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
33
components/Button.tsx
Normal 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
57
components/ClayCard.tsx
Normal 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
124
components/DFYForm.tsx
Normal 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
410
components/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
229
components/ExternalTools.tsx
Normal file
229
components/ExternalTools.tsx
Normal 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
280
components/Marketplace.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
506
components/OnboardingFlow.tsx
Normal file
506
components/OnboardingFlow.tsx
Normal 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
376
components/Quiz.tsx
Normal 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
190
components/Sidebar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
components/TourSimulation.tsx
Normal file
72
components/TourSimulation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
216
components/admin/SettingsForm.tsx
Normal file
216
components/admin/SettingsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/auth/PermissionButton.tsx
Normal file
51
components/auth/PermissionButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
components/auth/ProtectedRoute.tsx
Normal file
74
components/auth/ProtectedRoute.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
components/auth/RoleGate.tsx
Normal file
52
components/auth/RoleGate.tsx
Normal 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
3
components/auth/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
export { RoleGate, AdminOnly, SuperAdminOnly } from './RoleGate';
|
||||||
|
export { PermissionButton } from './PermissionButton';
|
||||||
117
components/control-center/AIMessageBubble.tsx
Normal file
117
components/control-center/AIMessageBubble.tsx
Normal 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;
|
||||||
214
components/control-center/ChatComposer.tsx
Normal file
214
components/control-center/ChatComposer.tsx
Normal 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;
|
||||||
206
components/control-center/ChatInterface.tsx
Normal file
206
components/control-center/ChatInterface.tsx
Normal 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;
|
||||||
436
components/control-center/ChatProvider.tsx
Normal file
436
components/control-center/ChatProvider.tsx
Normal 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;
|
||||||
192
components/control-center/ConversationSidebar.tsx
Normal file
192
components/control-center/ConversationSidebar.tsx
Normal 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;
|
||||||
200
components/control-center/MessageList.tsx
Normal file
200
components/control-center/MessageList.tsx
Normal 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;
|
||||||
225
components/control-center/StatusIndicator.tsx
Normal file
225
components/control-center/StatusIndicator.tsx
Normal 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;
|
||||||
110
components/control-center/ToolCallCard.tsx
Normal file
110
components/control-center/ToolCallCard.tsx
Normal 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;
|
||||||
136
components/control-center/ToolResultCard.tsx
Normal file
136
components/control-center/ToolResultCard.tsx
Normal 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;
|
||||||
42
components/control-center/UserMessageBubble.tsx
Normal file
42
components/control-center/UserMessageBubble.tsx
Normal 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;
|
||||||
26
components/control-center/index.ts
Normal file
26
components/control-center/index.ts
Normal 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';
|
||||||
81
components/realtime/RealtimeProvider.tsx
Normal file
81
components/realtime/RealtimeProvider.tsx
Normal 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
59
components/ui/alert.tsx
Normal 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
50
components/ui/avatar.tsx
Normal 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
36
components/ui/badge.tsx
Normal 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
57
components/ui/button.tsx
Normal 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
76
components/ui/card.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user