feat: add system monitor component and API for performance metrics
feat: implement conversations API with message retrieval and posting feat: add avatar URL handling for users and update user role definitions feat: create chat system tables in the database with initial seed data fix: update user roles from "sales_user" to "sales" for consistency chore: add middleware for system API route access fixed fuckups on john's side, added a benchmark Made Graphs actualy load from database and realtime data, graphs will update and percentages will be mathematically made, and made realtime added chatcart edits, made graphs glow. added in some little small home feeling fuctions added in a slide show, in login with qoutes from creators because i want to leave a print on it saying we made this shit, we built it brick for brick login page added qoutes some random, and made them shuffle from left to right one dissapears other come in adding shape to the textbox for the qoutes Fixed my chat fuckups when it comes to chats
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
-- ============================================================================
|
||||
-- Chat System: conversations, participants, and messages
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_participants_user_id ON conversation_participants(user_id);
|
||||
|
||||
-- Seed conversations between superadmin and other users
|
||||
INSERT INTO conversations (id, created_at) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'),
|
||||
('c0000000-0000-0000-0000-000000000002', NOW() - INTERVAL '1 hour'),
|
||||
('c0000000-0000-0000-0000-000000000003', NOW() - INTERVAL '30 minutes')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Add participants (superadmin + each sales/dev user)
|
||||
INSERT INTO conversation_participants (conversation_id, user_id) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003'),
|
||||
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000004'),
|
||||
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Seed some messages
|
||||
INSERT INTO messages (id, conversation_id, sender_id, content, created_at) VALUES
|
||||
('00000000-0000-0000-0000-000000000101', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', 'Hey! Just finalized the TechCorp deal.',
|
||||
NOW() - INTERVAL '2 hours'),
|
||||
('00000000-0000-0000-0000-000000000102', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000001', 'Great work! What were the final terms?',
|
||||
NOW() - INTERVAL '105 minutes'),
|
||||
('00000000-0000-0000-0000-000000000103', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', '3-year enterprise license, 15% discount. They signed this morning.',
|
||||
NOW() - INTERVAL '100 minutes'),
|
||||
('00000000-0000-0000-0000-000000000104', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000004', 'The API integration tests are passing on staging.',
|
||||
NOW() - INTERVAL '50 minutes'),
|
||||
('00000000-0000-0000-0000-000000000105', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000001', 'Great, when can we deploy to production?',
|
||||
NOW() - INTERVAL '45 minutes'),
|
||||
('00000000-0000-0000-0000-000000000106', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000004', 'Tomorrow morning after final review.',
|
||||
NOW() - INTERVAL '40 minutes'),
|
||||
('00000000-0000-0000-0000-000000000107', 'c0000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000002', 'New report shows 23% increase in lead conversion this quarter.',
|
||||
NOW() - INTERVAL '25 minutes'),
|
||||
('00000000-0000-0000-0000-000000000108', 'c0000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000001', 'That is excellent! Let us discuss in the next team meeting.',
|
||||
NOW() - INTERVAL '20 minutes')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Update conversation timestamps
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '20 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000003';
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '40 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000002';
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '100 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000001';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS last_read_at TIMESTAMPTZ;
|
||||
UPDATE conversation_participants SET last_read_at = NOW() WHERE last_read_at IS NULL;
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Test leads - 2026-06-18
|
||||
INSERT INTO leads (id, stage_id, assigned_to, company_name, contact_name, email, phone, created_at, updated_at) VALUES
|
||||
('c0011001-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 1', 'Contact 1', 'contact1@test.com', '555-2636', '2025-12-23T22:09:54Z', '2025-12-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 2', 'Contact 2', 'contact2@test.com', '555-5691', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 3', 'Contact 3', 'contact3@test.com', '555-6446', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 4', 'Contact 4', 'contact4@test.com', '555-5969', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 5', 'Contact 5', 'contact5@test.com', '555-4200', '2026-01-14T22:09:54Z', '2026-01-14T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 6', 'Contact 6', 'contact6@test.com', '555-8324', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000007', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 7', 'Contact 7', 'contact7@test.com', '555-1047', '2025-12-27T22:09:54Z', '2025-12-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000008', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 8', 'Contact 8', 'contact8@test.com', '555-634', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000009', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 9', 'Contact 9', 'contact9@test.com', '555-3245', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000010', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 10', 'Contact 10', 'contact10@test.com', '555-3656', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000011', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 11', 'Contact 11', 'contact11@test.com', '555-2993', '2026-01-23T22:09:54Z', '2026-01-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000012', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 12', 'Contact 12', 'contact12@test.com', '555-1634', '2026-02-24T22:09:54Z', '2026-02-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000013', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 13', 'Contact 13', 'contact13@test.com', '555-2032', '2026-02-10T22:09:54Z', '2026-02-10T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000014', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 14', 'Contact 14', 'contact14@test.com', '555-867', '2026-01-31T22:09:54Z', '2026-01-31T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000015', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 15', 'Contact 15', 'contact15@test.com', '555-2136', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000016', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 16', 'Contact 16', 'contact16@test.com', '555-89', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000017', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 17', 'Contact 17', 'contact17@test.com', '555-4274', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000018', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 18', 'Contact 18', 'contact18@test.com', '555-4294', '2026-03-16T22:09:54Z', '2026-03-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000019', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 19', 'Contact 19', 'contact19@test.com', '555-7998', '2025-12-30T22:09:54Z', '2025-12-30T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000020', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 20', 'Contact 20', 'contact20@test.com', '555-8557', '2026-02-13T22:09:54Z', '2026-02-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000021', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 21', 'Contact 21', 'contact21@test.com', '555-5170', '2026-04-11T22:09:54Z', '2026-04-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000022', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 22', 'Contact 22', 'contact22@test.com', '555-6250', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000023', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 23', 'Contact 23', 'contact23@test.com', '555-5611', '2026-01-06T22:09:54Z', '2026-01-06T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000024', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 24', 'Contact 24', 'contact24@test.com', '555-3467', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000025', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 25', 'Contact 25', 'contact25@test.com', '555-7087', '2026-05-07T22:09:54Z', '2026-05-07T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000026', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 26', 'Contact 26', 'contact26@test.com', '555-2945', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000027', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 27', 'Contact 27', 'contact27@test.com', '555-9502', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000028', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 28', 'Contact 28', 'contact28@test.com', '555-8114', '2026-05-10T22:09:54Z', '2026-05-10T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000029', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 29', 'Contact 29', 'contact29@test.com', '555-4926', '2026-03-02T22:09:54Z', '2026-03-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000030', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 30', 'Contact 30', 'contact30@test.com', '555-2421', '2026-03-17T22:09:54Z', '2026-03-17T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000031', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 31', 'Contact 31', 'contact31@test.com', '555-7506', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000032', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 32', 'Contact 32', 'contact32@test.com', '555-6832', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000033', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 33', 'Contact 33', 'contact33@test.com', '555-6548', '2026-01-07T22:09:54Z', '2026-01-07T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000034', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 34', 'Contact 34', 'contact34@test.com', '555-8793', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000035', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 35', 'Contact 35', 'contact35@test.com', '555-6217', '2026-01-29T22:09:54Z', '2026-01-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000036', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 36', 'Contact 36', 'contact36@test.com', '555-9206', '2025-12-31T22:09:54Z', '2025-12-31T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000037', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 37', 'Contact 37', 'contact37@test.com', '555-1599', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000038', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 38', 'Contact 38', 'contact38@test.com', '555-3873', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000039', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 39', 'Contact 39', 'contact39@test.com', '555-6122', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000040', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 40', 'Contact 40', 'contact40@test.com', '555-832', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000041', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 41', 'Contact 41', 'contact41@test.com', '555-5979', '2026-01-11T22:09:54Z', '2026-01-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000042', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 42', 'Contact 42', 'contact42@test.com', '555-1823', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000043', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 43', 'Contact 43', 'contact43@test.com', '555-7102', '2026-02-28T22:09:54Z', '2026-02-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000044', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 44', 'Contact 44', 'contact44@test.com', '555-5106', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000045', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 45', 'Contact 45', 'contact45@test.com', '555-3278', '2026-03-27T22:09:54Z', '2026-03-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000046', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 46', 'Contact 46', 'contact46@test.com', '555-4973', '2026-04-28T22:09:54Z', '2026-04-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000047', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 47', 'Contact 47', 'contact47@test.com', '555-3297', '2026-03-01T22:09:54Z', '2026-03-01T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000048', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 48', 'Contact 48', 'contact48@test.com', '555-4584', '2026-03-18T22:09:54Z', '2026-03-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000049', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 49', 'Contact 49', 'contact49@test.com', '555-6788', '2026-03-04T22:09:54Z', '2026-03-04T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000050', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 50', 'Contact 50', 'contact50@test.com', '555-2734', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000051', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 51', 'Contact 51', 'contact51@test.com', '555-2625', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000052', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 52', 'Contact 52', 'contact52@test.com', '555-620', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000053', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 53', 'Contact 53', 'contact53@test.com', '555-6691', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000054', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 54', 'Contact 54', 'contact54@test.com', '555-8847', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000055', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 55', 'Contact 55', 'contact55@test.com', '555-2187', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000056', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 56', 'Contact 56', 'contact56@test.com', '555-3267', '2026-01-19T22:09:54Z', '2026-01-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000057', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 57', 'Contact 57', 'contact57@test.com', '555-8119', '2026-03-05T22:09:54Z', '2026-03-05T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000058', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 58', 'Contact 58', 'contact58@test.com', '555-3181', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000059', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 59', 'Contact 59', 'contact59@test.com', '555-4403', '2026-02-23T22:09:54Z', '2026-02-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000060', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 60', 'Contact 60', 'contact60@test.com', '555-1407', '2026-04-06T22:09:54Z', '2026-04-06T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000061', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 61', 'Contact 61', 'contact61@test.com', '555-2333', '2026-02-27T22:09:54Z', '2026-02-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000062', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 62', 'Contact 62', 'contact62@test.com', '555-1436', '2026-04-26T22:09:54Z', '2026-04-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000063', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 63', 'Contact 63', 'contact63@test.com', '555-7135', '2026-04-19T22:09:54Z', '2026-04-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000064', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 64', 'Contact 64', 'contact64@test.com', '555-9366', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000065', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 65', 'Contact 65', 'contact65@test.com', '555-7135', '2026-02-18T22:09:54Z', '2026-02-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000066', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 66', 'Contact 66', 'contact66@test.com', '555-6854', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000067', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 67', 'Contact 67', 'contact67@test.com', '555-2322', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000068', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 68', 'Contact 68', 'contact68@test.com', '555-2632', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000069', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 69', 'Contact 69', 'contact69@test.com', '555-876', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000070', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 70', 'Contact 70', 'contact70@test.com', '555-974', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000071', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 71', 'Contact 71', 'contact71@test.com', '555-1919', '2026-01-18T22:09:54Z', '2026-01-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000072', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 72', 'Contact 72', 'contact72@test.com', '555-3034', '2025-12-25T22:09:54Z', '2025-12-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000073', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 73', 'Contact 73', 'contact73@test.com', '555-1795', '2026-01-27T22:09:54Z', '2026-01-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000074', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 74', 'Contact 74', 'contact74@test.com', '555-727', '2026-01-08T22:09:54Z', '2026-01-08T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000075', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 75', 'Contact 75', 'contact75@test.com', '555-3877', '2026-03-08T22:09:54Z', '2026-03-08T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000076', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 76', 'Contact 76', 'contact76@test.com', '555-6384', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000077', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 77', 'Contact 77', 'contact77@test.com', '555-5607', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000078', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 78', 'Contact 78', 'contact78@test.com', '555-6225', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000079', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 79', 'Contact 79', 'contact79@test.com', '555-9288', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000080', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 80', 'Contact 80', 'contact80@test.com', '555-8660', '2026-02-16T22:09:54Z', '2026-02-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000081', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 81', 'Contact 81', 'contact81@test.com', '555-826', '2025-12-28T22:09:54Z', '2025-12-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000082', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 82', 'Contact 82', 'contact82@test.com', '555-1761', '2026-03-11T22:09:54Z', '2026-03-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000083', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 83', 'Contact 83', 'contact83@test.com', '555-9860', '2026-05-18T22:09:54Z', '2026-05-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000084', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 84', 'Contact 84', 'contact84@test.com', '555-1104', '2026-06-01T22:09:54Z', '2026-06-01T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000085', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 85', 'Contact 85', 'contact85@test.com', '555-9528', '2026-06-14T22:09:54Z', '2026-06-14T22:09:54Z');
|
||||
+262
-145
@@ -14,7 +14,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { conversations as conversationsData } from "@/data/chats"
|
||||
import {
|
||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||
@@ -94,7 +93,7 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
|
||||
export default function ChatsPage() {
|
||||
const { theme } = useTheme()
|
||||
const { user } = useUser()
|
||||
const [activeChat, setActiveChat] = useState(conversationsData[0]?.id ?? null)
|
||||
const [activeChat, setActiveChat] = useState<string | null>(null)
|
||||
const [messageInput, setMessageInput] = useState("")
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [attachments, setAttachments] = useState<File[]>([])
|
||||
@@ -103,7 +102,7 @@ export default function ChatsPage() {
|
||||
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
||||
const [reportReason, setReportReason] = useState("")
|
||||
const [forwardDialogOpen, setForwardDialogOpen] = useState(false)
|
||||
const [forwardMessage, setForwardMessage] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||
const [forwardMessage, setForwardMessage] = useState<any>(null)
|
||||
const [forwardSearch, setForwardSearch] = useState("")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
@@ -112,18 +111,17 @@ export default function ChatsPage() {
|
||||
const recordingDurationRef = useRef(0)
|
||||
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
|
||||
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
||||
const [replyingTo, setReplyingTo] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||
const [editingMessage, setEditingMessage] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||
const [replyingTo, setReplyingTo] = useState<any>(null)
|
||||
const [editingMessage, setEditingMessage] = useState<any>(null)
|
||||
const [replyMap, setReplyMap] = useState<Map<string, { repliedToSender: string; repliedToContent: string }>>(new Map())
|
||||
const [messageStatus, setMessageStatus] = useState<Map<string, "sent" | "delivered" | "read">>(() => {
|
||||
const map = new Map<string, "sent" | "delivered" | "read">()
|
||||
conversationsData.forEach((conv) =>
|
||||
conv.messages.forEach((msg) => {
|
||||
if (msg.senderId === "user1") map.set(msg.id, "read")
|
||||
})
|
||||
)
|
||||
return map
|
||||
})
|
||||
// message status tracking removed — now using msg.read from API
|
||||
const [conversations, setConversations] = useState<any[]>([])
|
||||
const [conversationMessages, setConversationMessages] = useState<Map<string, any[]>>(new Map())
|
||||
const [loadingChats, setLoadingChats] = useState(true)
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -141,15 +139,79 @@ export default function ChatsPage() {
|
||||
ta.style.height = `${ta.scrollHeight}px`
|
||||
}, [])
|
||||
|
||||
const [conversations, setConversations] = useState(conversationsData)
|
||||
if (!user) return null
|
||||
|
||||
const messages = conversationMessages.get(activeChat || "") || []
|
||||
const conversation = conversations.find((c) => c.id === activeChat)
|
||||
const otherParticipant = (conv: typeof conversationsData[0]) =>
|
||||
conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0]
|
||||
const otherParticipant = (conv: any) => conv.otherUser
|
||||
const filteredConversations = searchQuery.trim()
|
||||
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: conversations
|
||||
|
||||
// Fetch conversations from API
|
||||
useEffect(() => {
|
||||
const fetchConvs = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/conversations")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const convs = data.conversations || []
|
||||
setConversations(convs)
|
||||
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
|
||||
if (convs.length > 0) setActiveChat(convs[0].id)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingChats(false)
|
||||
}
|
||||
}
|
||||
fetchConvs()
|
||||
}, [])
|
||||
|
||||
// Fetch messages when active chat changes, and mark as read
|
||||
useEffect(() => {
|
||||
if (!activeChat) return
|
||||
const fetchMsgs = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${activeChat}/messages`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(activeChat, data.messages || [])
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Mark conversation as read
|
||||
fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch(() => {})
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||
}
|
||||
fetchMsgs()
|
||||
}, [activeChat])
|
||||
|
||||
// Search users when search query changes
|
||||
useEffect(() => {
|
||||
const q = searchQuery.trim()
|
||||
if (!q) { setSearchResults([]); return }
|
||||
const timer = setTimeout(async () => {
|
||||
setSearchingUsers(true)
|
||||
try {
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
||||
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setSearchingUsers(false)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, conversations])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
|
||||
@@ -202,79 +264,77 @@ export default function ChatsPage() {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const addMessageToChat = (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, attachments?: { name: string; type: string; url: string }[]) => {
|
||||
const id = crypto.randomUUID()
|
||||
const newMessage = {
|
||||
id,
|
||||
conversationId: activeChat!,
|
||||
senderId: "user1",
|
||||
senderName: "Sarah Chen",
|
||||
senderAvatar: "SC",
|
||||
content,
|
||||
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
}
|
||||
setConversations((prev) =>
|
||||
prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? {
|
||||
...conv,
|
||||
messages: [...conv.messages, newMessage],
|
||||
lastMessage: voice ? "🎤 Voice note" : (replyTo ? `↪ ${content}` : content),
|
||||
lastMessageTime: "Just now",
|
||||
unread: 0,
|
||||
}
|
||||
: conv
|
||||
)
|
||||
)
|
||||
setMessageStatus((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, "sent")
|
||||
return next
|
||||
})
|
||||
setTimeout(() => {
|
||||
setMessageStatus((prev) => {
|
||||
const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => {
|
||||
if (!activeChat || !user) return
|
||||
const payload = voice ? "[Voice message]" : content
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: payload }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const msg = data.message
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.get(id) === "sent") next.set(id, "delivered")
|
||||
const existing = next.get(activeChat) || []
|
||||
next.set(activeChat, [...existing, msg])
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
if (voice) {
|
||||
setVoiceMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, voice)
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (replyTo) {
|
||||
setReplyMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, { repliedToSender: replyTo.senderName, repliedToContent: replyTo.content })
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (attachments && attachments.length > 0) {
|
||||
setMessageAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, attachments)
|
||||
return next
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
|
||||
: conv
|
||||
)
|
||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||
if (idx > 0) {
|
||||
const [item] = updated.splice(idx, 1)
|
||||
updated.unshift(item)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||
await fetch(`/api/conversations/${activeChat}/read`, { method: "POST" })
|
||||
if (voice) {
|
||||
setVoiceMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(msg.id, voice)
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (replyTo) {
|
||||
setReplyMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(msg.id, { repliedToSender: replyTo.senderName, repliedToContent: replyTo.content })
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (fileAttachments && fileAttachments.length > 0) {
|
||||
setMessageAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(msg.id, fileAttachments)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to send message")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = (e: React.FormEvent) => {
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!messageInput.trim() && attachments.length === 0) return
|
||||
if (editingMessage) {
|
||||
setConversations((prev) =>
|
||||
prev.map((conv) => ({
|
||||
...conv,
|
||||
messages: conv.messages.map((m) =>
|
||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
||||
),
|
||||
lastMessage: conv.id === activeChat && conv.messages.some((m) => m.id === editingMessage.id)
|
||||
? messageInput.trim() : conv.lastMessage,
|
||||
}))
|
||||
)
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
||||
)
|
||||
next.set(activeChat || "", msgs)
|
||||
return next
|
||||
})
|
||||
setEditingMessage(null)
|
||||
setMessageInput("")
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
@@ -284,11 +344,11 @@ export default function ChatsPage() {
|
||||
? attachments.map((f) => ({ name: f.name, type: f.type, url: URL.createObjectURL(f) }))
|
||||
: undefined
|
||||
if (replyingTo) {
|
||||
const replySender = replyingTo.senderId === "user1" ? user.name : otherParticipant(conversation!).name
|
||||
addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
||||
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
||||
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
||||
setReplyingTo(null)
|
||||
} else {
|
||||
addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
||||
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
||||
}
|
||||
setMessageInput("")
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
@@ -296,12 +356,18 @@ export default function ChatsPage() {
|
||||
}
|
||||
|
||||
const handleDeleteMessage = (messageId: string) => {
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
||||
next.set(activeChat || "", msgs)
|
||||
return next
|
||||
})
|
||||
setConversations((prev) =>
|
||||
prev.map((conv) => ({
|
||||
...conv,
|
||||
messages: conv.messages.filter((m) => m.id !== messageId),
|
||||
lastMessage: conv.messages.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage,
|
||||
}))
|
||||
prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
|
||||
: conv
|
||||
)
|
||||
)
|
||||
toast.success("Message deleted")
|
||||
}
|
||||
@@ -405,16 +471,30 @@ export default function ChatsPage() {
|
||||
const person = otherParticipant(conv)
|
||||
const isActive = conv.id === activeChat
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => setActiveChat(conv.id)}
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
setActiveChat(conv.id)
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
||||
setConversations((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === conv.id)
|
||||
if (idx > 0) {
|
||||
const u = [...prev]
|
||||
const [item] = u.splice(idx, 1)
|
||||
u.unshift(item)
|
||||
return u
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50",
|
||||
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
|
||||
isActive && "bg-muted"
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarFallback>{person.avatar}</AvatarFallback>
|
||||
<AvatarImage src={person.avatar} />
|
||||
<AvatarFallback>{person.name?.charAt(0) || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -423,15 +503,55 @@ export default function ChatsPage() {
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
|
||||
</div>
|
||||
{conv.unread > 0 && (
|
||||
<span className="shrink-0 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-medium text-primary-foreground">
|
||||
{conv.unread}
|
||||
</span>
|
||||
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
|
||||
<div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredConversations.length === 0 && searchQuery && (
|
||||
{searchResults.length > 0 && (
|
||||
<>
|
||||
{searchQuery && <div className="px-4 py-2 text-xs font-medium text-muted-foreground">New conversation</div>}
|
||||
{searchResults.map((person: any) => (
|
||||
<button
|
||||
key={person.id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch("/api/conversations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: person.id }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const conv = data.conversation || conversations.find((c) => c.id === data.conversationId)
|
||||
if (conv) {
|
||||
setConversations((prev) => [conv, ...prev])
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
||||
setActiveChat(conv.id)
|
||||
setSearchQuery("")
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarImage src={person.avatar} />
|
||||
<AvatarFallback>{person.name?.charAt(0) || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground font-bold">+</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{person.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">{person.email}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredConversations.length === 0 && searchResults.length === 0 && searchQuery && !searchingUsers && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">No conversations found</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@@ -451,8 +571,9 @@ export default function ChatsPage() {
|
||||
{/* Chat header */}
|
||||
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
<AvatarFallback>{otherParticipant(conversation).avatar}</AvatarFallback>
|
||||
<Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}>
|
||||
<AvatarImage src={otherParticipant(conversation).avatar} />
|
||||
<AvatarFallback>{otherParticipant(conversation).name?.charAt(0) || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{otherParticipant(conversation).name}</p>
|
||||
@@ -493,15 +614,15 @@ export default function ChatsPage() {
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
|
||||
<div className="space-y-4 min-h-0">
|
||||
{conversation.messages.map((msg) => {
|
||||
const isMe = msg.senderId === "user1"
|
||||
{messages.map((msg) => {
|
||||
const isMe = msg.senderId === user?.id
|
||||
const voiceUrl = voiceMessages.get(msg.id)
|
||||
return (
|
||||
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
|
||||
<Avatar className="h-8 w-8 mt-0.5 shrink-0">
|
||||
{isMe ? <AvatarImage src={user.avatar} /> : null}
|
||||
<Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
|
||||
<AvatarImage src={msg.senderAvatar} />
|
||||
<AvatarFallback className={cn("text-xs", isMe && "bg-primary text-primary-foreground")}>
|
||||
{msg.senderAvatar}
|
||||
{msg.senderName?.charAt(0) || "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn("max-w-[70%]", isMe && "items-end flex flex-col")}>
|
||||
@@ -608,12 +729,9 @@ export default function ChatsPage() {
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground mt-1.5 px-1">
|
||||
{msg.timestamp}
|
||||
{isMe && (
|
||||
(() => {
|
||||
const status = messageStatus.get(msg.id)
|
||||
if (status === "read") return <CheckCheck className="h-3 w-3 text-blue-400" />
|
||||
if (status === "delivered") return <CheckCheck className="h-3 w-3 text-muted-foreground/60" />
|
||||
return <Check className="h-3 w-3 text-muted-foreground/60" />
|
||||
})()
|
||||
msg.read
|
||||
? <CheckCheck className="h-3 w-3 text-blue-400" />
|
||||
: <Check className="h-3 w-3 text-muted-foreground/60" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -649,7 +767,7 @@ export default function ChatsPage() {
|
||||
<div className="flex items-center gap-2 rounded-lg border-l-4 border-primary bg-muted/50 px-3 py-2 text-sm">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-primary">
|
||||
Replying to {replyingTo.senderId === "user1" ? "yourself" : otherParticipant(conversation).name}
|
||||
Replying to {replyingTo.senderId === user?.id ? "yourself" : otherParticipant(conversation).name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{replyingTo.content}</p>
|
||||
</div>
|
||||
@@ -690,7 +808,7 @@ export default function ChatsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
||||
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
||||
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
|
||||
<Smile className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -740,6 +858,19 @@ export default function ChatsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Avatar preview dialog */}
|
||||
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
||||
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||
{previewAvatarUrl && (
|
||||
<img
|
||||
src={previewAvatarUrl}
|
||||
alt="Profile picture"
|
||||
className="w-full h-auto max-h-[80vh] object-contain rounded-2xl"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Forward dialog */}
|
||||
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
@@ -754,8 +885,6 @@ export default function ChatsPage() {
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="space-y-0.5">
|
||||
{conversations
|
||||
.slice()
|
||||
.sort((a, b) => b.messages.length - a.messages.length)
|
||||
.filter((conv) => {
|
||||
const person = otherParticipant(conv)
|
||||
return person.name.toLowerCase().includes(forwardSearch.toLowerCase())
|
||||
@@ -766,45 +895,33 @@ export default function ChatsPage() {
|
||||
<button
|
||||
key={conv.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const msg = forwardMessage
|
||||
if (!msg) return
|
||||
if (!msg || !user) return
|
||||
const newContent = `📨 Forwarded: ${msg.content}`
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === conv.id
|
||||
? {
|
||||
...c,
|
||||
messages: [
|
||||
...c.messages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
conversationId: conv.id,
|
||||
senderId: "user1",
|
||||
senderName: "Sarah Chen",
|
||||
senderAvatar: "SC",
|
||||
content: newContent,
|
||||
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
],
|
||||
lastMessage: newContent,
|
||||
lastMessageTime: "Just now",
|
||||
}
|
||||
: c
|
||||
)
|
||||
)
|
||||
setForwardDialogOpen(false)
|
||||
setForwardSearch("")
|
||||
toast.success("Message forwarded")
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${conv.id}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: newContent }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setForwardDialogOpen(false)
|
||||
setForwardSearch("")
|
||||
toast.success("Message forwarded")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to forward message")
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-lg p-2.5 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
<AvatarFallback>{person.avatar}</AvatarFallback>
|
||||
<AvatarFallback>{person.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{person.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.role} · {conv.messages.length} messages</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.email || ""}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { StatCard } from "@/components/dashboard/stat-card"
|
||||
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
||||
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
||||
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
||||
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
||||
import { getDashboardStats } from "@/data/dashboard"
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
@@ -29,19 +28,36 @@ import { DashboardStats } from "@/types"
|
||||
export default function DashboardPage() {
|
||||
const [period, setPeriod] = useState("6months")
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
async function fetchStats(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard?period=${p}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setStats(getDashboardStats(period))
|
||||
fetchStats(period)
|
||||
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: stats.periodLabel },
|
||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, variant: "blue" as const, description: "Needs attention" },
|
||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, variant: "amber" as const, description: "In conversation" },
|
||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, variant: "purple" as const, description: "Awaiting decision" },
|
||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, variant: "emerald" as const, description: "Won" },
|
||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, variant: "emerald" as const, description: `${stats.closedLeads} of ${stats.totalLeads} leads` },
|
||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, description: "Needs attention", trend: stats.trends.openLeads, sparklineField: "open" as const },
|
||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, description: "In conversation", trend: stats.trends.contactedLeads, sparklineField: "contacted" as const },
|
||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, description: "Awaiting decision", trend: stats.trends.pendingLeads, sparklineField: "pending" as const },
|
||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, description: "Won", trend: stats.trends.closedLeads, sparklineField: "closed" as const },
|
||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, description: `${stats.closedLeads} of ${stats.totalLeads} leads`, trend: stats.trends.conversionRate, sparklineField: "closed" as const, conversionRate: stats.conversionRate },
|
||||
]
|
||||
: []
|
||||
|
||||
@@ -67,7 +83,7 @@ export default function DashboardPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{stats
|
||||
? statCards.map((card, i) => (
|
||||
<StatCard key={card.title} {...card} index={i} />
|
||||
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
||||
))
|
||||
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { use } from "react"
|
||||
@@ -16,14 +17,39 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { getLeadById } from "@/data/leads"
|
||||
import { getNotesByLeadId } from "@/data/notes"
|
||||
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||
import { Lead, Note } from "@/types"
|
||||
|
||||
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const lead = getLeadById(id)
|
||||
const leadNotes = lead ? getNotesByLeadId(lead.id) : []
|
||||
const [lead, setLead] = useState<Lead | null>(null)
|
||||
const [leadNotes, setLeadNotes] = useState<Note[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [leadRes, notesRes] = await Promise.all([
|
||||
fetch(`/api/leads/${id}`),
|
||||
fetch(`/api/leads/${id}/notes`),
|
||||
])
|
||||
if (leadRes.ok) setLead(await leadRes.json())
|
||||
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
fetchData()
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
@@ -61,71 +87,45 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
||||
}
|
||||
description={lead.contactName}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select defaultValue={lead.status}>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="contacted">Contacted</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button className="gap-2">
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={lead.status}
|
||||
onValueChange={(v) => {
|
||||
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="contacted">Contacted</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<LeadDetailsCard lead={lead} />
|
||||
<div className="space-y-6">
|
||||
<NoteForm leadId={lead.id} />
|
||||
<NoteTimeline notes={leadNotes} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<LeadDetailsCard lead={lead} />
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h3 className="font-semibold">Activity</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{[
|
||||
{ action: "Lead created", date: lead.createdAt, user: lead.assignedUser?.name || "System" },
|
||||
...leadNotes.slice(0, 3).map((n) => ({
|
||||
action: "Note added",
|
||||
date: n.createdAt,
|
||||
user: n.authorName,
|
||||
})),
|
||||
].map((activity, i) => (
|
||||
<div key={i} className="px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-primary/60 shrink-0" />
|
||||
<p className="text-sm">{activity.action}</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground pl-4">
|
||||
by {activity.user} · {new Date(activity.date).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<NoteForm leadId={lead.id} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -133,20 +133,54 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h3 className="font-semibold">Quick Actions</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Call Lead
|
||||
</Button>
|
||||
<NoteTimeline notes={leadNotes} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Activity</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<div>
|
||||
<p className="font-medium">Lead Created</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(lead.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{leadNotes.slice(0, 3).map((note) => (
|
||||
<div key={note.id} className="flex items-start gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground mt-1.5" />
|
||||
<div>
|
||||
<p className="font-medium">{note.authorName}</p>
|
||||
<p className="text-xs text-muted-foreground">{note.note.slice(0, 60)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.25 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Call Lead
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadsTable } from "@/components/leads/leads-table"
|
||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||
import { leads } from "@/data/leads"
|
||||
import { filterLeadsByPeriod } from "@/lib/date-utils"
|
||||
import { Lead } from "@/types"
|
||||
|
||||
export default function LeadsPage() {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [periodFilter, setPeriodFilter] = useState("all")
|
||||
const [leadsData, setLeadsData] = useState<Lead[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
let result = leads
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set("search", search)
|
||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||
if (periodFilter !== "all") params.set("period", periodFilter)
|
||||
|
||||
if (periodFilter && periodFilter !== "all") {
|
||||
result = filterLeadsByPeriod(result, periodFilter)
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
result = result.filter(
|
||||
(l) =>
|
||||
l.companyName.toLowerCase().includes(q) ||
|
||||
l.contactName.toLowerCase().includes(q) ||
|
||||
l.email.toLowerCase().includes(q) ||
|
||||
l.phone.includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
if (statusFilter && statusFilter !== "all") {
|
||||
result = result.filter((l) => l.status === statusFilter)
|
||||
}
|
||||
|
||||
return result
|
||||
setLoading(true)
|
||||
fetch(`/api/leads?${params.toString()}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setLeadsData(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [search, statusFilter, periodFilter])
|
||||
|
||||
return (
|
||||
@@ -58,7 +50,7 @@ export default function LeadsPage() {
|
||||
onCreateClick={() => router.push("/leads/new")}
|
||||
/>
|
||||
</div>
|
||||
<LeadsTable data={filteredLeads} />
|
||||
<LeadsTable data={leadsData} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function ProfilePage() {
|
||||
if (!user) return null
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const validTypes = ["image/png", "image/jpeg"]
|
||||
@@ -22,9 +22,27 @@ export default function ProfilePage() {
|
||||
toast.error("Only PNG and JPEG files are allowed")
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
updateAvatar(url)
|
||||
toast.success("Avatar updated")
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string
|
||||
try {
|
||||
const res = await fetch("/api/users/avatar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ avatar: dataUrl }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
updateAvatar(data.avatar)
|
||||
toast.success("Avatar updated")
|
||||
} else {
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { UsersTable } from "@/components/users/users-table";
|
||||
import { UserFormDialog } from "@/components/users/user-form-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { users } from "@/data/users";
|
||||
import { Plus, Users as UsersIcon } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { UsersTable } from "@/components/users/users-table"
|
||||
import { UserFormDialog } from "@/components/users/user-form-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Plus, Users as UsersIcon, Search } from "lucide-react"
|
||||
import type { User } from "@/types"
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user } = useUser();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const { user } = useUser()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/users")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUsers(data.users || [])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const activeUsers = users.filter((u) => u.active !== false)
|
||||
const admins = users.filter((u) => u.role === "admin")
|
||||
|
||||
const stats = [
|
||||
{ label: "Total Users", value: users.length, color: "bg-blue-500/10", textColor: "text-blue-500" },
|
||||
{ label: "Active", value: activeUsers.length, color: "bg-green-500/10", textColor: "text-green-500" },
|
||||
{ label: "Admins", value: admins.length, color: "bg-purple-500/10", textColor: "text-purple-500" },
|
||||
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
|
||||
]
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -53,7 +92,7 @@ export default function UsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<UsersTable data={users} />
|
||||
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
|
||||
</div>
|
||||
|
||||
<UserFormDialog
|
||||
@@ -62,5 +101,5 @@ export default function UsersPage() {
|
||||
onUserCreated={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const [msgResult, otherReadResult] = await Promise.all([
|
||||
query(
|
||||
`SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
|
||||
u.first_name || ' ' || u.last_name AS sender_name,
|
||||
u.email AS sender_email,
|
||||
u.avatar_url AS sender_avatar_url
|
||||
FROM messages m
|
||||
JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL
|
||||
ORDER BY m.created_at ASC`,
|
||||
[id],
|
||||
),
|
||||
query(
|
||||
`SELECT last_read_at FROM conversation_participants
|
||||
WHERE conversation_id = $1 AND user_id != $2`,
|
||||
[id, user.id],
|
||||
),
|
||||
])
|
||||
|
||||
const otherLastReadAt = otherReadResult.rows[0]?.last_read_at
|
||||
? new Date(otherReadResult.rows[0].last_read_at).getTime()
|
||||
: 0
|
||||
|
||||
const messages = msgResult.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
conversationId: id,
|
||||
senderId: row.sender_id,
|
||||
senderName: row.sender_name,
|
||||
senderAvatar: row.sender_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.sender_name)}&background=1d4ed8&color=fff&size=128`,
|
||||
content: row.content,
|
||||
timestamp: formatTime(new Date(row.created_at)),
|
||||
createdAt: row.created_at,
|
||||
read: row.sender_id === user.id
|
||||
? new Date(row.created_at).getTime() <= otherLastReadAt
|
||||
: true,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ messages })
|
||||
} catch (error) {
|
||||
console.error("Messages error:", error)
|
||||
return NextResponse.json({ error: "Failed to load messages" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const { content } = await request.json()
|
||||
|
||||
if (!content?.trim()) {
|
||||
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, created_at`,
|
||||
[id, user.id, content.trim()],
|
||||
)
|
||||
|
||||
await query(
|
||||
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
|
||||
const msg = result.rows[0]
|
||||
|
||||
return NextResponse.json({
|
||||
message: {
|
||||
id: msg.id,
|
||||
conversationId: id,
|
||||
senderId: user.id,
|
||||
senderName: `${user.firstName} ${user.lastName}`,
|
||||
senderAvatar: user.avatar,
|
||||
content: content.trim(),
|
||||
timestamp: formatTime(new Date(msg.created_at)),
|
||||
createdAt: msg.created_at,
|
||||
read: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Send message error:", error)
|
||||
return NextResponse.json({ error: "Failed to send message" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
await query(
|
||||
`UPDATE conversation_participants
|
||||
SET last_read_at = NOW()
|
||||
WHERE conversation_id = $1 AND user_id = $2`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Mark read error:", error)
|
||||
return NextResponse.json({ error: "Failed to mark as read" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT
|
||||
c.id,
|
||||
c.updated_at,
|
||||
cp_me.last_read_at,
|
||||
u.id AS other_user_id,
|
||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||
u.email AS other_user_email,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
||||
FROM conversations c
|
||||
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
||||
JOIN conversation_participants cp ON cp.conversation_id = c.id
|
||||
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
||||
WHERE c.id IN (
|
||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||
)
|
||||
ORDER BY c.updated_at DESC`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const conversations = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
otherUser: {
|
||||
id: row.other_user_id,
|
||||
name: row.other_user_name,
|
||||
email: row.other_user_email,
|
||||
avatar: row.other_user_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.other_user_name)}&background=1d4ed8&color=fff&size=128`,
|
||||
},
|
||||
lastMessage: row.last_message || "",
|
||||
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
|
||||
unread: parseInt(row.unread) || 0,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ conversations })
|
||||
} catch (error) {
|
||||
console.error("Conversations error:", error)
|
||||
return NextResponse.json({ error: "Failed to load conversations" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { userId } = await request.json()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (userId === user.id) {
|
||||
return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await query(
|
||||
`SELECT c.id FROM conversations c
|
||||
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1
|
||||
JOIN conversation_participants cp2 ON cp2.conversation_id = c.id AND cp2.user_id = $2
|
||||
LIMIT 1`,
|
||||
[user.id, userId],
|
||||
)
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return NextResponse.json({ conversationId: existing.rows[0].id })
|
||||
}
|
||||
|
||||
const convResult = await query(
|
||||
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||
)
|
||||
const conversationId = convResult.rows[0].id
|
||||
|
||||
await query(
|
||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||
[conversationId, user.id, userId],
|
||||
)
|
||||
|
||||
const otherUser = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const other = otherUser.rows[0]
|
||||
|
||||
return NextResponse.json({
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
otherUser: {
|
||||
id: other.id,
|
||||
name: other.name,
|
||||
email: other.email,
|
||||
avatar: other.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(other.name)}&background=1d4ed8&color=fff&size=128`,
|
||||
},
|
||||
lastMessage: "",
|
||||
lastMessageTime: "",
|
||||
unread: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Create conversation error:", error)
|
||||
return NextResponse.json({ error: "Failed to create conversation" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return "now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
||||
const end = new Date()
|
||||
let start: Date
|
||||
switch (period) {
|
||||
case "7days":
|
||||
start = new Date(end); start.setDate(start.getDate() - 7); break
|
||||
case "30days":
|
||||
start = new Date(end); start.setDate(start.getDate() - 30); break
|
||||
case "12months":
|
||||
start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
||||
case "6months":
|
||||
default:
|
||||
start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
|
||||
const end = new Date(currentStart)
|
||||
const diff = end.getTime() - currentStart.getTime()
|
||||
const start = new Date(end.getTime() - diff)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const periodLabels: Record<string, string> = {
|
||||
"7days": "Last 7 days",
|
||||
"30days": "Last 30 days",
|
||||
"6months": "Last 6 months",
|
||||
"12months": "Last 12 months",
|
||||
}
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLeadsInRange(start: Date, end: Date) {
|
||||
const result = await query(
|
||||
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||
l.notes, l.assigned_to, l.score,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $1 AND l.created_at <= $2
|
||||
ORDER BY l.created_at DESC`,
|
||||
[start.toISOString(), end.toISOString()]
|
||||
)
|
||||
return result.rows.map((r: any) => ({
|
||||
...r,
|
||||
status: stageToStatus(r.stage_name),
|
||||
}))
|
||||
}
|
||||
|
||||
function countStatuses(leads: any[]) {
|
||||
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
leads.forEach((l: any) => {
|
||||
const s = l.status as keyof typeof counts
|
||||
if (s in counts) counts[s]++
|
||||
})
|
||||
return counts
|
||||
}
|
||||
|
||||
function buildMonthlyBreakdown(leads: any[], period: string) {
|
||||
const { start, end } = getPeriodDateRange(period)
|
||||
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
||||
const current = new Date(start)
|
||||
const isMonthly = period === "6months" || period === "12months"
|
||||
|
||||
while (current <= end) {
|
||||
const label = isMonthly
|
||||
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
|
||||
const ps = new Date(current)
|
||||
const pe = isMonthly
|
||||
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
|
||||
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
|
||||
|
||||
const inPeriod = leads.filter((l: any) => {
|
||||
const d = new Date(l.created_at)
|
||||
return d >= ps && d <= pe
|
||||
})
|
||||
|
||||
const counts = countStatuses(inPeriod)
|
||||
result.push({ label, total: inPeriod.length, ...counts })
|
||||
|
||||
if (isMonthly) current.setMonth(current.getMonth() + 1)
|
||||
else current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
||||
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
||||
const pct = Math.round(((current - previous) / previous) * 100)
|
||||
return { pct: Math.abs(pct), up: pct >= 0 }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const period = searchParams.get("period") || "6months"
|
||||
const yearParam = searchParams.get("year")
|
||||
let start: Date, end: Date, prevRange: { start: Date; end: Date }
|
||||
if (yearParam) {
|
||||
const y = parseInt(yearParam)
|
||||
start = new Date(y, 0, 1)
|
||||
end = new Date(y, 11, 31, 23, 59, 59)
|
||||
prevRange = { start: new Date(y - 1, 0, 1), end: new Date(y - 1, 11, 31, 23, 59, 59) }
|
||||
} else {
|
||||
const r = getPeriodDateRange(period)
|
||||
start = r.start; end = r.end
|
||||
prevRange = getPreviousPeriodRange(period, start)
|
||||
}
|
||||
|
||||
const [currentLeads, prevLeads] = await Promise.all([
|
||||
fetchLeadsInRange(start, end),
|
||||
fetchLeadsInRange(prevRange.start, prevRange.end),
|
||||
])
|
||||
|
||||
const currentCounts = countStatuses(currentLeads)
|
||||
const prevCounts = countStatuses(prevLeads)
|
||||
|
||||
const totalLeads = currentLeads.length
|
||||
const closedLeads = currentCounts.closed
|
||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
||||
|
||||
const mappedLeads = currentLeads.map((r: any) => ({
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: r.status,
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
|
||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
|
||||
|
||||
const trends = {
|
||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
||||
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
||||
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
||||
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
||||
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
||||
conversionRate: computeTrend(conversionRate,
|
||||
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalLeads,
|
||||
openLeads: currentCounts.open,
|
||||
contactedLeads: currentCounts.contacted,
|
||||
pendingLeads: currentCounts.pending,
|
||||
closedLeads,
|
||||
ignoredLeads: currentCounts.ignored,
|
||||
conversionRate,
|
||||
monthlyBreakdown,
|
||||
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
trends,
|
||||
recentLeads: mappedLeads.slice(0, 10),
|
||||
statusDistribution: [
|
||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
|
||||
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
|
||||
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
|
||||
],
|
||||
periodLabel: periodLabels[period] ?? "Selected period",
|
||||
}
|
||||
|
||||
return NextResponse.json(stats)
|
||||
} catch (error) {
|
||||
console.error("Dashboard API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const { content } = await request.json()
|
||||
if (!content?.trim()) {
|
||||
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO customer_notes (customer_id, author_id, content)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[id, user.id, content.trim()]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Create note error:", error)
|
||||
return NextResponse.json({ error: "Failed to create note" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await query(
|
||||
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.avatar_url
|
||||
FROM customer_notes cn
|
||||
JOIN users u ON u.id = cn.author_id
|
||||
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
|
||||
ORDER BY cn.created_at DESC`,
|
||||
[id]
|
||||
)
|
||||
|
||||
const notes = result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
leadId: id,
|
||||
userId: r.user_id,
|
||||
authorName: `${r.first_name} ${r.last_name}`,
|
||||
authorAvatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
|
||||
note: r.content,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
|
||||
return NextResponse.json(notes)
|
||||
} catch (error) {
|
||||
console.error("Lead notes API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load notes" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await query(
|
||||
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.id = $1 AND l.deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const r = result.rows[0]
|
||||
const lead = {
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: stageToStatus(r.stage_name),
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
|
||||
return NextResponse.json(lead)
|
||||
} catch (error) {
|
||||
console.error("Lead detail API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
|
||||
if (period === "all") return null
|
||||
const end = new Date()
|
||||
let start: Date
|
||||
switch (period) {
|
||||
case "7days": start = new Date(end); start.setDate(start.getDate() - 7); break
|
||||
case "30days": start = new Date(end); start.setDate(start.getDate() - 30); break
|
||||
case "12months": start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
||||
case "6months": default: start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search") || ""
|
||||
const status = searchParams.get("status") || "all"
|
||||
const period = searchParams.get("period") || "all"
|
||||
|
||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL`
|
||||
|
||||
const params: any[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (period !== "all") {
|
||||
const range = getPeriodDateRange(period)
|
||||
if (range) {
|
||||
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
|
||||
params.push(range.start.toISOString(), range.end.toISOString())
|
||||
paramIdx += 2
|
||||
}
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
|
||||
params.push(`%${search}%`)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
sql += ` ORDER BY l.created_at DESC`
|
||||
|
||||
const result = await query(sql, params)
|
||||
|
||||
let leads = result.rows.map((r: any) => {
|
||||
const s = stageToStatus(r.stage_name)
|
||||
return {
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: s,
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
})
|
||||
|
||||
if (status !== "all") {
|
||||
leads = leads.filter((l: any) => l.status === status)
|
||||
}
|
||||
|
||||
return NextResponse.json(leads)
|
||||
} catch (error) {
|
||||
console.error("Leads API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import os from "os"
|
||||
|
||||
let prevCpu = process.cpuUsage()
|
||||
let prevTime = Date.now()
|
||||
|
||||
export async function GET() {
|
||||
const now = Date.now()
|
||||
const elapsed = now - prevTime
|
||||
const currentCpu = process.cpuUsage()
|
||||
|
||||
const user = currentCpu.user - prevCpu.user
|
||||
const sys = currentCpu.system - prevCpu.system
|
||||
const totalUs = user + sys
|
||||
|
||||
// CPU time (ms) / wall time (ms) * 100 = % of one core
|
||||
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
|
||||
|
||||
prevCpu = currentCpu
|
||||
prevTime = now
|
||||
|
||||
const mem = process.memoryUsage()
|
||||
|
||||
return NextResponse.json({
|
||||
rssMB: Math.round(mem.rss / 1024 / 1024),
|
||||
cpuPct,
|
||||
cores: os.cpus().length,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { avatar } = await request.json()
|
||||
if (!avatar || typeof avatar !== "string") {
|
||||
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||
[avatar, user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ avatar })
|
||||
} catch (error) {
|
||||
console.error("Avatar upload error:", error)
|
||||
return NextResponse.json({ error: "Failed to update avatar" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export async function GET() {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||
u.is_active AS active, u.created_at,
|
||||
u.is_active AS active, u.created_at, u.avatar_url,
|
||||
r.name AS role
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
@@ -20,7 +20,7 @@ export async function GET() {
|
||||
email: row.email,
|
||||
role: row.role.toLowerCase(),
|
||||
active: row.active,
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(`${row.first_name}+${row.last_name}`)}&background=1d4ed8&color=fff&size=128`,
|
||||
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${row.first_name}+${row.last_name}`)}&background=1d4ed8&color=fff&size=128`,
|
||||
createdAt: row.created_at,
|
||||
}))
|
||||
return NextResponse.json({ users }, { status: 200 })
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const currentUser = await getSessionUser()
|
||||
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const q = request.nextUrl.searchParams.get("q") || ""
|
||||
|
||||
if (!q.trim()) {
|
||||
return NextResponse.json({ users: [] })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
AND id != $1
|
||||
AND (LOWER(first_name || ' ' || last_name) LIKE LOWER($2)
|
||||
OR LOWER(email) LIKE LOWER($2))
|
||||
ORDER BY first_name ASC
|
||||
LIMIT 10`,
|
||||
[currentUser.id, `%${q}%`],
|
||||
)
|
||||
|
||||
const users = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.name)}&background=1d4ed8&color=fff&size=128`,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ users })
|
||||
} catch (error) {
|
||||
console.error("User search error:", error)
|
||||
return NextResponse.json({ error: "Search failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
--sidebar-ring: 224.3 76.3% 48%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
@@ -71,4 +72,253 @@
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Login page custom styles */
|
||||
.left-panel {
|
||||
background: #0a0a0f;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.right-panel {
|
||||
background: #111118;
|
||||
width: 420px;
|
||||
flex: none;
|
||||
position: relative;
|
||||
}
|
||||
.panel-divider {
|
||||
width: 1px;
|
||||
background: rgba(180, 192, 210, 0.1);
|
||||
flex: none;
|
||||
}
|
||||
.growth-word {
|
||||
position: relative;
|
||||
color: #1BB0CE;
|
||||
}
|
||||
.growth-word::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #1BB0CE;
|
||||
animation: pulseUnderline 2.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseUnderline {
|
||||
0%, 100% { background-color: #1BB0CE; }
|
||||
50% { background-color: rgba(180, 192, 210, 0.6); }
|
||||
}
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
gap: 0;
|
||||
}
|
||||
.stat {
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.stat::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #1BB0CE, rgba(180,192,210,1), #1BB0CE);
|
||||
background-size: 200% 100%;
|
||||
animation: statSweep 2.5s ease-in-out infinite;
|
||||
}
|
||||
.stat:nth-child(2)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
.stat:nth-child(3)::before {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
@keyframes statSweep {
|
||||
0% { background-position: 0% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1BB0CE;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: rgba(232,232,239,0.3);
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.testimonial {
|
||||
border-left: 2px solid rgba(27,176,206,0.25);
|
||||
background: rgba(27,176,206,0.06);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.stars-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.wave-canvas {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: #0a0a0f;
|
||||
}
|
||||
.accent-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #1BB0CE, rgba(232,232,239,0.15), transparent);
|
||||
animation: pulseAccent 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseAccent {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.input-sheen {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.input-sheen::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 120%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
|
||||
transform: rotate(-15deg);
|
||||
animation: sheenFloat 3.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.input-sheen-delayed::before {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
.input-sheen:focus-within::before {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes sheenFloat {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
.login-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #0d0d15, #151520, #0d0d15);
|
||||
background-size: 200% 200%;
|
||||
animation: bgShift 6s ease-in-out infinite;
|
||||
border: 1.5px solid rgba(180,192,210,0.15);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #e8e8ef;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input::placeholder {
|
||||
color: rgba(232,232,239,0.2);
|
||||
}
|
||||
.login-input:focus {
|
||||
border-color: #1BB0CE;
|
||||
outline: none;
|
||||
background: #111118;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes bgShift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(232,232,239,0.3);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.password-toggle:hover {
|
||||
color: rgba(232,232,239,0.5);
|
||||
}
|
||||
.login-checkbox {
|
||||
accent-color: #1BB0CE;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #1BB0CE;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 13px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.auth-btn:hover {
|
||||
background: #17a0bc;
|
||||
}
|
||||
.auth-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.auth-btn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
animation: btnSweep 2.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes btnSweep {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
.login-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(232,232,239,0.4);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.body-text { color: rgba(232,232,239,0.5); }
|
||||
.subheading-text { color: rgba(232,232,239,0.38); }
|
||||
.checkbox-text { color: rgba(232,232,239,0.38); }
|
||||
.footer-text { color: rgba(232,232,239,0.2); }
|
||||
|
||||
|
||||
+7
-2
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import { ThemeProvider } from "@/providers/theme-provider"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
@@ -9,8 +9,13 @@ const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Coast IT - CRM",
|
||||
title: "CRM",
|
||||
description: "Customer Relationship Management System",
|
||||
icons: {
|
||||
icon: "/logo/CompanyMiniLogo.png",
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
export const metadata = {
|
||||
title: 'Next.js',
|
||||
description: 'Generated by Next.js',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
return children
|
||||
}
|
||||
|
||||
+303
-328
@@ -1,286 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { motion } from "framer-motion"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { COMPANY_NAME } from "@/lib/constants"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
|
||||
function LoginForm() {
|
||||
const waves = [
|
||||
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
|
||||
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
|
||||
{ a: 12, f: 0.015, s: 0.025, y: 0.28, fill: "rgba(180,192,210,0.06)" },
|
||||
{ a: 26, f: 0.006, s: 0.010, y: 0.65, fill: "rgba(180,192,210,0.15)" },
|
||||
{ a: 10, f: 0.019, s: 0.030, y: 0.20, fill: "rgba(27,176,206,0.10)" },
|
||||
]
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [counts, setCounts] = useState({ leads: 0, conversion: 0, users: 0 })
|
||||
const counterStarted = useRef(false)
|
||||
const [testimonialIndex, setTestimonialIndex] = useState(0)
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
||||
author: "Marcus Johnson, Sales Lead at Coast IT",
|
||||
},
|
||||
{
|
||||
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
||||
author: "Dillen van der Merwe, Madman of Coast IT",
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [testimonials.length])
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
let animId: number
|
||||
let time = 0
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement!
|
||||
const rect = parent.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = 200 * dpr
|
||||
canvas.style.width = rect.width + "px"
|
||||
canvas.style.height = "200px"
|
||||
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
resize()
|
||||
window.addEventListener("resize", resize)
|
||||
|
||||
const draw = () => {
|
||||
const w = canvas.width / (window.devicePixelRatio || 1)
|
||||
const h = 200
|
||||
ctx!.clearRect(0, 0, w, h)
|
||||
|
||||
for (const wave of waves) {
|
||||
ctx!.beginPath()
|
||||
ctx!.moveTo(0, h)
|
||||
for (let x = 0; x <= w; x++) {
|
||||
const y = wave.y * h + Math.sin(x * wave.f + time * wave.s) * wave.a
|
||||
ctx!.lineTo(x, y)
|
||||
}
|
||||
ctx!.lineTo(w, h)
|
||||
ctx!.closePath()
|
||||
ctx!.fillStyle = wave.fill
|
||||
ctx!.fill()
|
||||
}
|
||||
|
||||
time += 1
|
||||
animId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId)
|
||||
window.removeEventListener("resize", resize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
|
||||
if (canvases.length === 0) return
|
||||
|
||||
const stars = Array.from({ length: 312 }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
r: 0.2 + Math.random() * 0.6,
|
||||
baseOpacity: 0.12 + Math.random() * 0.43,
|
||||
speed: 0.003 + Math.random() * 0.015,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
driftX: (Math.random() - 0.5) * 0.00003,
|
||||
driftY: (Math.random() - 0.5) * 0.00003,
|
||||
}))
|
||||
|
||||
let animId: number
|
||||
let time = 0
|
||||
|
||||
const resize = () => {
|
||||
for (const c of canvases) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = c.getBoundingClientRect()
|
||||
c.width = rect.width * dpr
|
||||
c.height = rect.height * dpr
|
||||
const ctx = c.getContext("2d")
|
||||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
resize()
|
||||
const ros = canvases.map((c) => {
|
||||
const ro = new ResizeObserver(() => resize())
|
||||
ro.observe(c.parentElement!)
|
||||
return ro
|
||||
})
|
||||
window.addEventListener("resize", resize)
|
||||
|
||||
const draw = () => {
|
||||
time += 1
|
||||
for (const c of canvases) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = c.width / dpr
|
||||
const h = c.height / dpr
|
||||
const ctx = c.getContext("2d")
|
||||
if (!ctx) continue
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
for (const star of stars) {
|
||||
const x = ((star.x + time * star.driftX) % 1) * w
|
||||
const y = ((star.y + time * star.driftY) % 1) * h
|
||||
const opacity = star.baseOpacity * (0.5 + 0.5 * Math.sin(time * star.speed + star.phase))
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, star.r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(255,255,255,${opacity})`
|
||||
ctx.fill()
|
||||
|
||||
if (star.r > 0.5) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(255,255,255,${opacity * 0.06})`
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId)
|
||||
ros.forEach((ro) => ro.disconnect())
|
||||
window.removeEventListener("resize", resize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (counterStarted.current) return
|
||||
counterStarted.current = true
|
||||
const targets = { leads: 1247, conversion: 40, users: 83 }
|
||||
const steps = 150
|
||||
|
||||
const delayTimer = setTimeout(() => {
|
||||
let step = 0
|
||||
const interval = setInterval(() => {
|
||||
step++
|
||||
if (step >= steps) {
|
||||
clearInterval(interval)
|
||||
setCounts(targets)
|
||||
return
|
||||
}
|
||||
setCounts({
|
||||
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
|
||||
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
|
||||
users: Math.min(Math.floor((targets.users / steps) * step), targets.users),
|
||||
})
|
||||
}, 15)
|
||||
}, 400)
|
||||
|
||||
return () => clearTimeout(delayTimer)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
router.push("/dashboard")
|
||||
if (res.ok) {
|
||||
router.push("/dashboard")
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || "Invalid email or password.")
|
||||
}
|
||||
} catch {
|
||||
setError("Connection error. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.left-panel {
|
||||
background: #0a0a0f;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.right-panel {
|
||||
background: #111118;
|
||||
width: 420px;
|
||||
flex: none;
|
||||
position: relative;
|
||||
}
|
||||
.panel-divider {
|
||||
width: 1px;
|
||||
background: rgba(180, 192, 210, 0.1);
|
||||
flex: none;
|
||||
}
|
||||
.growth-word {
|
||||
position: relative;
|
||||
color: #1BB0CE;
|
||||
}
|
||||
.growth-word::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #1BB0CE;
|
||||
animation: pulseUnderline 2.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseUnderline {
|
||||
0%, 100% { background-color: #1BB0CE; }
|
||||
50% { background-color: rgba(180, 192, 210, 0.6); }
|
||||
}
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
gap: 0;
|
||||
}
|
||||
.stat {
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.stat::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #1BB0CE, rgba(180,192,210,1), #1BB0CE);
|
||||
background-size: 200% 100%;
|
||||
animation: statSweep 2.5s ease-in-out infinite;
|
||||
}
|
||||
.stat:nth-child(2)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
.stat:nth-child(3)::before {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
@keyframes statSweep {
|
||||
0% { background-position: 0% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1BB0CE;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: rgba(232,232,239,0.3);
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.testimonial {
|
||||
border-left: 2px solid rgba(27,176,206,0.25);
|
||||
background: rgba(27,176,206,0.06);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.stars-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.wave-canvas {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: #0a0a0f;
|
||||
}
|
||||
.accent-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #1BB0CE, rgba(232,232,239,0.15), transparent);
|
||||
animation: pulseAccent 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseAccent {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.input-sheen {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.input-sheen::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 120%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
|
||||
transform: rotate(-15deg);
|
||||
animation: sheenFloat 3.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.input-sheen-delayed::before {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
.input-sheen:focus-within::before {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes sheenFloat {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
.login-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #0d0d15, #151520, #0d0d15);
|
||||
background-size: 200% 200%;
|
||||
animation: bgShift 6s ease-in-out infinite;
|
||||
border: 1.5px solid rgba(180,192,210,0.15);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #e8e8ef;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input::placeholder {
|
||||
color: rgba(232,232,239,0.2);
|
||||
}
|
||||
.login-input:focus {
|
||||
border-color: #1BB0CE;
|
||||
outline: none;
|
||||
background: #111118;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes bgShift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(232,232,239,0.3);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.password-toggle:hover {
|
||||
color: rgba(232,232,239,0.5);
|
||||
}
|
||||
.login-checkbox {
|
||||
accent-color: #1BB0CE;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #1BB0CE;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 13px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.auth-btn:hover {
|
||||
background: #17a0bc;
|
||||
}
|
||||
.auth-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.auth-btn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
animation: btnSweep 2.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes btnSweep {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
.login-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(232,232,239,0.4);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.body-text { color: rgba(232,232,239,0.5); }
|
||||
.subheading-text { color: rgba(232,232,239,0.38); }
|
||||
.checkbox-text { color: rgba(232,232,239,0.38); }
|
||||
.footer-text { color: rgba(232,232,239,0.2); }
|
||||
`}</style>
|
||||
|
||||
<div className="flex min-h-screen bg-[#0a0a0f]">
|
||||
<div className="flex min-h-screen bg-[#0a0a0f]">
|
||||
<div className="left-panel flex flex-1 flex-col p-12">
|
||||
<div className="relative z-10">
|
||||
<img
|
||||
@@ -317,13 +265,26 @@ function LoginForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 testimonial">
|
||||
<p className="text-[12px] italic" style={{ color: "#0a0a0f" }}>
|
||||
“This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.”
|
||||
</p>
|
||||
<p className="text-[11px] mt-1" style={{ color: "#0a0a0f" }}>
|
||||
Marcus Johnson, Sales Lead at Coast IT
|
||||
</p>
|
||||
<div className="relative z-10 testimonial" style={{ background: "rgba(255,255,255,0.5)", borderRadius: "12px 12px 12px 12px", padding: "16px" }}>
|
||||
<div style={{ position: "absolute", right: "-12px", bottom: "24px", width: "24px", height: "24px", borderRadius: "50%", background: "#0a0a0f", zIndex: 1 }} />
|
||||
<div className="transition-opacity duration-500" key={testimonialIndex}>
|
||||
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
|
||||
“{testimonials[testimonialIndex].text}”
|
||||
</p>
|
||||
<p className="text-xs font-bold mt-2" style={{ color: "#0a0a0f" }}>
|
||||
{testimonials[testimonialIndex].author}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1.5 mt-3">
|
||||
{testimonials.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setTestimonialIndex(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${i === testimonialIndex ? "w-5 bg-[#1BB0CE]" : "w-1.5 bg-[#1BB0CE]/30"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} className="wave-canvas" />
|
||||
@@ -343,69 +304,84 @@ function LoginForm() {
|
||||
Sign in to your account to continue
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="login-label">
|
||||
Email
|
||||
</label>
|
||||
<div className="input-sheen">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="login-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-[6px]">
|
||||
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
||||
Password
|
||||
</label>
|
||||
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline">
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<div className="input-sheen input-sheen-delayed">
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="login-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="password-toggle"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="login-checkbox"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={remember}
|
||||
onCheckedChange={(checked) => setRemember(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Remember me
|
||||
<span className="text-[13px] checkbox-text">
|
||||
Remember me
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
<button type="submit" className="auth-btn" disabled={loading}>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[11px] mt-4 footer-text">
|
||||
By signing in, you agree to our{" "}
|
||||
@@ -420,6 +396,5 @@ function LoginForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface IntervalData {
|
||||
label: string
|
||||
@@ -17,9 +19,35 @@ interface LeadsPerMonthChartProps {
|
||||
const NEW_LEADS = "#0d9488"
|
||||
const CLOSED = "#c9a96e"
|
||||
|
||||
export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
const [chartData, setChartData] = useState<IntervalData[]>(initialData)
|
||||
const [hovered, setHovered] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setChartData(initialData)
|
||||
}, [initialData])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchYearData() {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard?year=${year}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setChartData(data.leadsPerMonth || [])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const thisYear = new Date().getFullYear()
|
||||
if (year !== thisYear || initialData.length === 0) {
|
||||
fetchYearData()
|
||||
} else {
|
||||
setChartData(initialData)
|
||||
}
|
||||
}, [year, initialData])
|
||||
|
||||
const width = 880
|
||||
const height = 620
|
||||
const padding = { top: 128, right: 24, bottom: 48, left: 44 }
|
||||
@@ -27,38 +55,51 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
const chartH = height - padding.top - padding.bottom
|
||||
|
||||
const maxVal = useMemo(() => {
|
||||
if (data.length === 0) return 1
|
||||
const max = Math.max(...data.map((d) => Math.max(d.leads, d.closed)))
|
||||
if (chartData.length === 0) return 1
|
||||
const max = Math.max(...chartData.map((d) => Math.max(d.leads, d.closed)))
|
||||
return Math.max(Math.ceil(max / 7) * 7, 1)
|
||||
}, [data])
|
||||
}, [chartData])
|
||||
|
||||
const ticks = useMemo(() => {
|
||||
const steps = 4
|
||||
return Array.from({ length: steps + 1 }, (_, i) => Math.round((maxVal / steps) * i))
|
||||
}, [maxVal])
|
||||
|
||||
const groupW = chartW / data.length
|
||||
const groupW = chartW / Math.max(chartData.length, 1)
|
||||
const barW = groupW * 0.28
|
||||
const gap = groupW * 0.06
|
||||
|
||||
const yScale = (v: number) => chartH - (v / maxVal) * chartH
|
||||
|
||||
const active = hovered
|
||||
const activeDatum = active !== null ? data[active] : null
|
||||
const activeDatum = active !== null ? chartData[active] : null
|
||||
|
||||
const totalNew = data.reduce((s, d) => s + d.leads, 0)
|
||||
const totalClosed = data.reduce((s, d) => s + d.closed, 0)
|
||||
const totalNew = chartData.reduce((s, d) => s + d.leads, 0)
|
||||
const totalClosed = chartData.reduce((s, d) => s + d.closed, 0)
|
||||
const closeRate = totalNew > 0 ? Math.round((totalClosed / totalNew) * 100) : 0
|
||||
|
||||
if (data.length === 0) {
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Leads Per Month</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Leads Per Month</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setYear((y) => y - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="min-w-[60px] text-center text-sm font-medium">{year}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setYear((y) => Math.min(y + 1, currentYear))} disabled={year >= currentYear}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
|
||||
No data for this period
|
||||
No data for {year}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -90,6 +131,15 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: CLOSED }} />
|
||||
<span className="text-xs text-muted-foreground">Closed</span>
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-1 rounded-lg border px-2 py-1">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setYear((y) => y - 1)}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="min-w-[50px] text-center text-sm font-semibold">{year}</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setYear((y) => Math.min(y + 1, currentYear))} disabled={year >= currentYear}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -145,7 +195,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
))}
|
||||
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
{chartData.map((d, i) => {
|
||||
const groupX = i * groupW
|
||||
const isActive = active === i
|
||||
const newH = chartH - yScale(d.leads)
|
||||
@@ -158,7 +208,6 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{/* Hover highlight band */}
|
||||
<rect
|
||||
x={groupX}
|
||||
y={0}
|
||||
@@ -168,7 +217,6 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
rx={6}
|
||||
/>
|
||||
|
||||
{/* New Leads bar */}
|
||||
<rect
|
||||
x={groupX + groupW / 2 - barW - gap / 2}
|
||||
y={yScale(d.leads)}
|
||||
@@ -181,7 +229,6 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
style={{ transition: "opacity 0.15s ease" }}
|
||||
/>
|
||||
|
||||
{/* Closed bar */}
|
||||
<rect
|
||||
x={groupX + groupW / 2 + gap / 2}
|
||||
y={yScale(d.closed)}
|
||||
@@ -194,7 +241,6 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
style={{ transition: "opacity 0.15s ease" }}
|
||||
/>
|
||||
|
||||
{/* Month label */}
|
||||
<text
|
||||
x={groupX + groupW / 2}
|
||||
y={chartH + 26}
|
||||
@@ -211,13 +257,12 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{activeDatum && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0"
|
||||
style={{
|
||||
left: `${((active! + 0.5) / data.length) * 100}%`,
|
||||
transform: active! > data.length - 2
|
||||
left: `${((active! + 0.5) / chartData.length) * 100}%`,
|
||||
transform: active! > chartData.length - 2
|
||||
? "translate(-100%, 0)"
|
||||
: "translate(-12%, 0)",
|
||||
transition: "left 0.15s ease",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export function StatCardSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-6 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
|
||||
@@ -10,62 +10,47 @@ interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: LucideIcon
|
||||
variant?: "default" | "blue" | "amber" | "purple" | "emerald" | "zinc"
|
||||
description?: string
|
||||
index?: number
|
||||
trend?: { pct: number; up: boolean }
|
||||
sparklineField?: "total" | "open" | "contacted" | "pending" | "closed" | "ignored"
|
||||
monthlyBreakdown?: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[]
|
||||
conversionRate?: number
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: { bg: "bg-muted", text: "text-foreground" },
|
||||
blue: { bg: "bg-teal-500/10", text: "text-teal-600 dark:text-teal-400" },
|
||||
amber: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400" },
|
||||
purple: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
||||
emerald: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400" },
|
||||
zinc: { bg: "bg-zinc-500/10", text: "text-zinc-600 dark:text-zinc-400" },
|
||||
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
||||
"Total Leads": { bg: "bg-cyan-500/10", text: "text-cyan-600 dark:text-cyan-400", glow: "via-[rgba(6,182,212,0.25)]", accent: "#06b6d4" },
|
||||
"Open Leads": { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", glow: "via-[rgba(59,130,246,0.25)]", accent: "#3b82f6" },
|
||||
"Contacted": { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", glow: "via-[rgba(245,158,11,0.25)]", accent: "#f59e0b" },
|
||||
"Pending": { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", glow: "via-[rgba(139,92,246,0.25)]", accent: "#8b5cf6" },
|
||||
"Closed": { bg: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400", glow: "via-[rgba(234,179,8,0.25)]", accent: "#eab308" },
|
||||
"Conversion Rate": { bg: "bg-rose-500/10", text: "text-rose-600 dark:text-rose-400", glow: "via-[rgba(244,63,94,0.25)]", accent: "#f43f5e" },
|
||||
}
|
||||
|
||||
const cardGradients: Record<string, string> = {
|
||||
"Total Leads": "from-[#0d9488] to-[#5eead4]",
|
||||
"Open Leads": "from-[#14b8a6] to-[#5eead4]",
|
||||
"Contacted": "from-[#c9a96e] to-[#e8d5a3]",
|
||||
"Pending": "from-[#94a3b8] to-[#cbd5e1]",
|
||||
"Closed": "from-[#c9a96e] to-[#e8d5a3]",
|
||||
"Conversion Rate": "from-[#f43f5e] to-[#fb7185]",
|
||||
function computeGoal(max: number): number {
|
||||
if (max <= 0) return 100
|
||||
return Math.ceil(max / 100) * 100 || 100
|
||||
}
|
||||
|
||||
const trendBadges: Record<string, { label: string; up: boolean }> = {
|
||||
"Total Leads": { label: "12%", up: true },
|
||||
"Open Leads": { label: "8%", up: true },
|
||||
"Contacted": { label: "5%", up: true },
|
||||
"Pending": { label: "3%", up: false },
|
||||
"Closed": { label: "15%", up: true },
|
||||
"Conversion Rate": { label: "2%", up: true },
|
||||
function smoothPath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return ""
|
||||
if (points.length === 1) return `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
||||
let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1]
|
||||
const p1 = points[i]
|
||||
const p2 = points[i + 1]
|
||||
const cp1x = p1.x - (p2.x - p0.x) * 0.15
|
||||
const cp1y = p1.y - (p2.y - p0.y) * 0.15
|
||||
const cp2x = p1.x + (p2.x - p0.x) * 0.15
|
||||
const cp2y = p1.y + (p2.y - p0.y) * 0.15
|
||||
d += `C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const sparklines: Record<string, string> = {
|
||||
"Total Leads": "M0,28 L10,22 L20,18 L30,20 L40,12 L50,8 L60,6",
|
||||
"Open Leads": "M0,28 L10,24 L20,26 L30,20 L40,22 L50,18 L60,14",
|
||||
"Contacted": "M0,28 L10,20 L20,16 L30,18 L40,10 L50,6 L60,4",
|
||||
"Pending": "M0,28 L10,26 L20,24 L30,22 L40,20 L50,18 L60,16",
|
||||
"Closed": "M0,28 L10,24 L20,18 L30,14 L40,10 L50,6 L60,2",
|
||||
"Conversion Rate": "M0,28 L10,22 L20,20 L30,16 L40,12 L50,8 L60,4",
|
||||
}
|
||||
|
||||
const sparklineColors: Record<string, string> = {
|
||||
"Total Leads": "#0d9488",
|
||||
"Open Leads": "#14b8a6",
|
||||
"Contacted": "#c9a96e",
|
||||
"Pending": "#94a3b8",
|
||||
"Closed": "#c9a96e",
|
||||
"Conversion Rate": "#f43f5e",
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon: Icon, variant = "default", description, index = 0 }: StatCardProps) {
|
||||
const style = variantStyles[variant]
|
||||
const gradient = cardGradients[title] ?? "from-[#0d9488] to-[#5eead4]"
|
||||
const sparkPath = sparklines[title] ?? "M0,28 L10,22 L20,18 L30,20 L40,12 L50,8 L60,6"
|
||||
const sparkColor = sparklineColors[title] ?? "#0d9488"
|
||||
const badge = trendBadges[title]
|
||||
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
||||
const color = cardColors[title] ?? cardColors["Total Leads"]
|
||||
|
||||
const isNumeric = typeof value === "number"
|
||||
const [display, setDisplay] = useState(0)
|
||||
@@ -89,6 +74,39 @@ export function StatCard({ title, value, icon: Icon, variant = "default", descri
|
||||
return () => clearInterval(timer)
|
||||
}, [target, isNumeric])
|
||||
|
||||
function buildSparklineSvg(): { path: string; area: string; goal: number; gridLines: { y: number }[] } {
|
||||
if (!monthlyBreakdown || !sparklineField) return { path: "", area: "", goal: 100, gridLines: [] }
|
||||
const values = monthlyBreakdown.map((m) => m[sparklineField]).reverse()
|
||||
const max = Math.max(...values, 0)
|
||||
const goal = computeGoal(max)
|
||||
const dataRange = Math.max(max, 1)
|
||||
const range = goal <= 10 ? Math.max(dataRange * 2, 10) : Math.min(goal, Math.max(dataRange * 3, 10))
|
||||
const w = values.length - 1 || 1
|
||||
const pad = 4
|
||||
const graphW = 60 - pad * 2
|
||||
const graphH = 28
|
||||
|
||||
const points = values.map((v, i) => ({
|
||||
x: pad + (i / w) * graphW,
|
||||
y: graphH - (v / range) * (graphH - 3) - 1,
|
||||
}))
|
||||
|
||||
const smooth = smoothPath(points)
|
||||
const area = points.length > 0
|
||||
? `${smooth} L${points[points.length - 1].x.toFixed(1)},${graphH} L${points[0].x.toFixed(1)},${graphH} Z`
|
||||
: ""
|
||||
|
||||
const steps = 4
|
||||
const gridLines = Array.from({ length: steps - 1 }, (_, i) => ({
|
||||
y: graphH - ((i + 1) / steps) * (graphH - 3) - 1,
|
||||
}))
|
||||
|
||||
return { path: smooth, area, goal, gridLines }
|
||||
}
|
||||
|
||||
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
||||
const sparkColor = color.accent
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -96,8 +114,8 @@ export function StatCard({ title, value, icon: Icon, variant = "default", descri
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className="relative"
|
||||
>
|
||||
<Card className="group hover:shadow-md transition-all duration-200">
|
||||
<CardContent className="p-6">
|
||||
<Card className="group h-full hover:shadow-md transition-all duration-200">
|
||||
<CardContent className="p-6 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
@@ -107,49 +125,71 @@ export function StatCard({ title, value, icon: Icon, variant = "default", descri
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{badge && (
|
||||
{trend && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-0.5 text-xs font-semibold",
|
||||
badge.up ? "text-emerald-500" : "text-rose-500"
|
||||
trend.up ? "text-emerald-500" : "text-rose-500"
|
||||
)}>
|
||||
{badge.up ? "↑" : "↓"} {badge.label}
|
||||
{trend.up ? "↑" : "↓"} {trend.pct}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl shrink-0", style.bg)}>
|
||||
<Icon className={cn("h-6 w-6", style.text)} />
|
||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl shrink-0", color.bg)}>
|
||||
<Icon className={cn("h-6 w-6", color.text)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<svg width="60" height="28" viewBox="0 0 60 28" className="opacity-60 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<defs>
|
||||
<linearGradient id={`spark-fill-${title.replace(/\s/g, "")}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={sparkColor} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={sparkColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={`${sparkPath} L60,28 L0,28 Z`} fill={`url(#spark-fill-${title.replace(/\s/g, "")})`} />
|
||||
<path d={sparkPath} fill="none" stroke={sparkColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
{sparkPath && (
|
||||
<div className="mt-auto relative">
|
||||
<svg width="60" height="28" viewBox="0 0 60 28" className="opacity-60 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<defs>
|
||||
<linearGradient id={`spark-fill-${title.replace(/\s/g, "")}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={sparkColor} stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor={sparkColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id={`glow-${title.replace(/\s/g, "")}`}>
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{gridLines.map((gl, i) => (
|
||||
<line key={i} x1="0" y1={gl.y} x2="60" y2={gl.y} stroke="currentColor" strokeOpacity="0.08" strokeWidth="1" strokeDasharray="2,2" />
|
||||
))}
|
||||
<path d={sparkArea} fill={`url(#spark-fill-${title.replace(/\s/g, "")})`} />
|
||||
<path
|
||||
d={sparkPath}
|
||||
fill="none"
|
||||
stroke={sparkColor}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="animate-pulse"
|
||||
filter={`url(#glow-${title.replace(/\s/g, "")})`}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute -bottom-0.5 right-0 text-[9px] font-medium text-muted-foreground/60">
|
||||
/{goal}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{title === "Conversion Rate" && (
|
||||
{title === "Conversion Rate" && conversionRate !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-[#0d9488] to-[#5eead4]" style={{ width: "22%" }} />
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-[#f43f5e] to-[#fda4af]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">22% conversion rate</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{conversionRate}% conversion rate</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{(title === "Closed" || title === "Conversion Rate") && (
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent to-transparent",
|
||||
title === "Closed" ? "via-[rgba(201,169,110,0.2)]" : "via-[rgba(244,63,94,0.2)]"
|
||||
)} />
|
||||
)}
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent to-transparent",
|
||||
color.glow
|
||||
)} />
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { Topbar } from "./topbar"
|
||||
import { SystemMonitor } from "./system-monitor"
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode
|
||||
@@ -34,6 +35,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<SystemMonitor />
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={toggleSidebar}
|
||||
|
||||
@@ -104,9 +104,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label === "Chats" && unreadChatCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
|
||||
{unreadChatCount}
|
||||
</span>
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444]" />
|
||||
)}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
@@ -126,7 +124,12 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
<div className="relative shrink-0">
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label === "Chats" && unreadChatCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse shadow-[0_0_8px_#ef4444]" />
|
||||
)}
|
||||
</div>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
const RAM_LIMIT_MB = 8192
|
||||
const CPU_LIMIT_PCT = 400 // 4 cores * 100%
|
||||
|
||||
export function SystemMonitor() {
|
||||
const [rssMB, setRssMB] = useState(0)
|
||||
const [cpuPct, setCpuPct] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/system/monitor")
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
setRssMB(data.rssMB)
|
||||
setCpuPct(data.cpuPct)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
const interval = setInterval(fetchStats, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const ramOver = rssMB > RAM_LIMIT_MB
|
||||
const cpuOver = cpuPct > CPU_LIMIT_PCT
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 z-[9999] flex items-center gap-3 px-3 py-1 text-[11px] font-mono bg-black/80 rounded-br-lg select-none">
|
||||
<span className={ramOver ? "text-red-400" : "text-green-400"}>
|
||||
RAM: {rssMB}MB / 8192MB
|
||||
</span>
|
||||
<span className={cpuOver ? "text-red-400" : "text-green-400"}>
|
||||
CPU: {cpuPct}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useUser } from "@/providers/user-provider";
|
||||
import { useNotifications } from "@/providers/notification-provider";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
@@ -15,14 +15,21 @@ export function NoteForm({ leadId }: NoteFormProps) {
|
||||
const [note, setNote] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!note.trim()) return
|
||||
setSubmitting(true)
|
||||
setTimeout(() => {
|
||||
console.log("Note added:", { leadId, note })
|
||||
try {
|
||||
await fetch(`/api/leads/${leadId}/notes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: note }),
|
||||
})
|
||||
setNote("")
|
||||
setSubmitting(false)
|
||||
}, 500)
|
||||
window.location.reload()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+6
-6
@@ -23,7 +23,7 @@ export const users: User[] = [
|
||||
id: "u2",
|
||||
name: "Marcus Johnson",
|
||||
email: "marcus@coastalit.com",
|
||||
role: "sales_user",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128",
|
||||
createdAt: "2025-02-01T09:00:00Z",
|
||||
@@ -32,7 +32,7 @@ export const users: User[] = [
|
||||
id: "u3",
|
||||
name: "Emily Rodriguez",
|
||||
email: "emily@coastalit.com",
|
||||
role: "sales_user",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128",
|
||||
createdAt: "2025-02-15T10:00:00Z",
|
||||
@@ -41,7 +41,7 @@ export const users: User[] = [
|
||||
id: "u4",
|
||||
name: "David Kim",
|
||||
email: "david@coastalit.com",
|
||||
role: "sales_user",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128",
|
||||
createdAt: "2025-03-01T08:00:00Z",
|
||||
@@ -50,7 +50,7 @@ export const users: User[] = [
|
||||
id: "u5",
|
||||
name: "Jessica Patel",
|
||||
email: "jessica@coastalit.com",
|
||||
role: "sales_user",
|
||||
role: "sales",
|
||||
active: false,
|
||||
avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128",
|
||||
createdAt: "2025-03-10T09:00:00Z",
|
||||
@@ -59,7 +59,7 @@ export const users: User[] = [
|
||||
id: "u6",
|
||||
name: "Alex Thompson",
|
||||
email: "alex@coastalit.com",
|
||||
role: "sales_user",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128",
|
||||
createdAt: "2025-04-01T08:00:00Z",
|
||||
@@ -68,7 +68,7 @@ export const users: User[] = [
|
||||
id: "u7",
|
||||
name: "Rachel Williams",
|
||||
email: "rachel@coastalit.com",
|
||||
role: "sales_user",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128",
|
||||
createdAt: "2025-04-15T10:00:00Z",
|
||||
|
||||
+17
-10
@@ -51,9 +51,9 @@ export async function verifyToken(token: string) {
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const result = await query(
|
||||
`SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
||||
u.password_change_required,
|
||||
u.password_change_required, u.avatar_url,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
@@ -67,9 +67,9 @@ export async function getUserByEmail(email: string) {
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
const result = await query(
|
||||
`SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
||||
u.password_change_required,
|
||||
u.password_change_required, u.avatar_url,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
@@ -83,8 +83,8 @@ export async function getUserByUsername(username: string) {
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const result = await query(
|
||||
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||
u.is_active,
|
||||
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||
u.is_active, u.avatar_url,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
@@ -169,8 +169,15 @@ export function mapDbUserToSessionUser(
|
||||
dbUser: Record<string, unknown>,
|
||||
): SessionUser {
|
||||
const roleName = dbUser.role_name as string;
|
||||
const mappedRole =
|
||||
roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales";
|
||||
|
||||
const roleMapping: Record<string, string> = {
|
||||
SUPER_ADMIN: "super_admin",
|
||||
ADMIN: "admin",
|
||||
SALES_USER: "sales",
|
||||
DEVELOPER: "sales",
|
||||
};
|
||||
|
||||
const avatarUrl = dbUser.avatar_url as string | null
|
||||
|
||||
return {
|
||||
id: dbUser.id as string,
|
||||
@@ -178,8 +185,8 @@ export function mapDbUserToSessionUser(
|
||||
email: dbUser.email as string,
|
||||
firstName: dbUser.first_name as string,
|
||||
lastName: dbUser.last_name as string,
|
||||
role: roleName,
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||
role: roleMapping[roleName] || roleName.toLowerCase(),
|
||||
avatar: avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||
`${dbUser.first_name}+${dbUser.last_name}`,
|
||||
)}&background=1d4ed8&color=fff&size=128`,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ const publicRoutes = [
|
||||
"/login",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/system",
|
||||
"/_next/static",
|
||||
"/_next/image",
|
||||
"/favicon.ico",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from "react"
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect, ReactNode } from "react"
|
||||
import { Notification, NotificationType } from "@/types"
|
||||
import { conversations } from "@/data/chats"
|
||||
import { leads } from "@/data/leads"
|
||||
|
||||
interface NotificationContextValue {
|
||||
@@ -71,10 +70,28 @@ function seedInitialNotifications(): Notification[] {
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const [notifications, setNotifications] = useState<Notification[]>(seedInitialNotifications)
|
||||
|
||||
const unreadChatCount = useMemo(
|
||||
() => conversations.reduce((sum, c) => sum + c.unread, 0),
|
||||
[]
|
||||
)
|
||||
const [unreadChatCount, setUnreadChatCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function fetchUnread() {
|
||||
try {
|
||||
const res = await fetch("/api/conversations")
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
if (!cancelled) {
|
||||
const list = data.conversations || []
|
||||
const total = list.reduce((sum: number, c: any) => sum + (c.unread || 0), 0)
|
||||
setUnreadChatCount(total)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
fetchUnread()
|
||||
const id = setInterval(fetchUnread, 5000)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const unreadCount = useMemo(
|
||||
() => notifications.filter((n) => !n.read).length,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export type LeadStatus =
|
||||
| "pending"
|
||||
| "closed"
|
||||
| "ignored";
|
||||
export type UserRole = "admin" | "sales";
|
||||
export type UserRole = "super_admin" | "admin" | "sales";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user