Compare commits

...

107 Commits

Author SHA1 Message Date
Hannah_Bagga c9c855579b Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Waiting to run
2026-07-10 14:44:01 +02:00
Hannah_Bagga 3a348e3616 Dark green hover fixed 2026-07-10 14:43:49 +02:00
Ace dba4c84cd5 Added finishing touch on other languages
Build & Auto-Repair / build (push) Has been cancelled
2026-07-08 14:24:03 +02:00
Ace d77ff2b965 Added logic to the 4 languages so it can search each language at a time
Build & Auto-Repair / build (push) Has been cancelled
2026-07-07 10:08:21 +02:00
Ace 0bc3ca58ed Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-07 10:03:38 +02:00
Ace d793604e92 Added 4 Languages English, Afrikaans, Xhosa, Zulu, tested but brought leads down to 3 leads only will see how to make it more without losing effiency of the model. 2026-07-07 10:03:35 +02:00
TroodonEnjoyer bc83af8e00 Started PostgreSQL — data directory was uninitialized, ran initdb, set password, switched to md5 auth, all 24 migrations applied.
Build & Auto-Repair / build (push) Has been cancelled
Hardened db.ts — added statement_timeout: 30000, idle_in_transaction_session_timeout: 10000, created transaction() helper (BEGIN/COMMIT/ROLLBACK).
Multi-step API routes wrapped in transactions — Events POST, Conversations POST use atomic blocks.
Fixed leads pagination — status filter pushed into SQL WHERE (no client-side filtering after LIMIT/OFFSET), added SELECT COUNT(*) for total.
Rewrote dashboard — SQL aggregations (COUNT + GROUP BY + date_trunc) replace loading all leads into memory.
Optimized conversations GET — merged duplicate correlated subqueries into single LEFT JOIN LATERAL.
Created migration 021_performance_indexes.sql — 8 missing indexes + set_session_user_context() function caching current_user_hierarchy_level() as session variable (avoids per-query RLS join).
Fixed build error — duplicate const other in conversations route. Also fixed a TypeScript never error in dashboard breakdown map.
Verified — npx tsc --noEmit clean, npm test (13 pass, 7 integration skip gracefully), npx next build succeeds.
2026-07-06 13:36:48 +02:00
Hannah_Bagga 80dee367e8 fixed theme setting
Build & Auto-Repair / build (push) Has been cancelled
2026-07-05 21:54:39 +02:00
Hannah_Bagga da99b695be just some things i needed to get back into the CRM
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 16:05:03 +02:00
caitlin dd6767980a search button theme fix in ai assistant
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 16:04:53 +02:00
Ace c1c4afadbb Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 13:26:58 +02:00
Ace 37679a7a60 Added Ai load fix 2026-07-02 13:26:56 +02:00
Hannah_Bagga c0cb715ced ai assistant page fixed
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 21:07:42 +02:00
Hannah_Bagga 1269f6cce8 Revert "Removed quick tips, recent prompts Messages today, Tokens used"
Build & Auto-Repair / build (push) Has been cancelled
This reverts commit 370f935cbb.
2026-07-01 14:36:47 +02:00
Hannah_Bagga 370f935cbb Removed quick tips, recent prompts Messages today, Tokens used
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 14:24:32 +02:00
Ace faf9dd551a Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:16:59 +02:00
Ace f67d9377a0 trying to fix AI chat 2026-07-01 13:16:57 +02:00
Hannah_Bagga 38fb3a47a8 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:14:35 +02:00
Hannah_Bagga bc422edcf7 colour scheme problem fixed 2026-07-01 13:13:31 +02:00
TroodonEnjoyer da5f8360bc Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:07:32 +02:00
TroodonEnjoyer 37af1febcc Automate DB migrations + fix audit trigger + drop forced password change
- scripts/run-migrations.mjs: auto-applies unapplied .sql files on
  npm run dev/setup via psql, tracks state in _migrations table
- 020_fixes.sql: sets password_change_required=FALSE for all users,
  fixes audit_password_change UUID cast, re-enables trigger
- package.json: hook migrations into dev:precheck
- setup.mjs: hook into npm run setup
- run_all.sql: add 020_fixes.sql to migration order
2026-07-01 13:07:15 +02:00
caitlin 84632e043f Merge origin/main into main
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:40:53 +02:00
Ace b2bb6d94f5 added the search button for the AI
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:29:06 +02:00
caitlin 566fa3df88 Fixed select a job category for lightmode 2026-07-01 12:21:16 +02:00
Ace 16710e3019 Getting the 10 syncs and adding my AI that works but has issues
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:16:22 +02:00
Ace e920d4925a Updated the to use targeted jobs but its weird 2026-07-01 12:15:37 +02:00
caitlin e8d80c3a16 login logo fix that i broke
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:04:05 +02:00
caitlin 77cccba8a1 Fixed Dark Mode
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 11:43:21 +02:00
Hannah_Bagga 7a5e833722 GIFs work now
Build & Auto-Repair / build (push) Has been cancelled
2026-06-30 15:11:15 +02:00
Hannah_Bagga 1e8f979cf9 AI Assistant page looks better.
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 22:00:05 +02:00
Hannah_Bagga 98145f0264 light and dark mode fixed and default mode is the default unless you pick Spidey mode
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 19:28:35 +02:00
caitlin 7a722a7d7e Merge remote-tracking branch 'origin/main' into master
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 15:38:18 +02:00
caitlin 61e9fac352 Fixed Light Mode 2026-06-29 15:26:44 +02:00
Ace 878627a6ba Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 15:08:09 +02:00
Ace 2c3a8e5333 Added qwen to serve as an repair agent. 2026-06-29 15:06:03 +02:00
Hannah_Bagga d870fb3ac7 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 14:52:13 +02:00
Hannah_Bagga f6d1d91fc6 AI Chat page looks better. Caitlin is my witness 2026-06-29 14:51:59 +02:00
TroodonEnjoyer bc3e345a27 Fix login session persistence + add admin accounts
- Fix JWT secret regenerating on every HMR cycle (globalThis cache)
- Replace unreliable response.cookies.set() with manual Set-Cookie header
- Remove jwt-secret.ts abstraction — JWT_SECRET back inline in auth.ts
- Sidebar logo links directly to /dashboard instead of server-redirect /
- Add 3 admin users to seed: ewan/caitlin/dillen (ADMIN role)
- Fix bcrypt hash corruption from PowerShell $ interpolation
- Remove plaintext passwords from codebase
- Fix audit_password_change trigger UUID type error
2026-06-29 14:42:16 +02:00
caitlin 3c0e7d76ca Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 10:42:58 +02:00
TroodonEnjoyer 39fb39db12 Fixed the login again? 2026-06-26 22:30:54 +02:00
Ace cd37d5a987 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 21:36:17 +02:00
Ace d138c60203 Fixing calander 2026-06-26 21:36:09 +02:00
caitlin 4d7a59c278 Changes in lightmode and dark mode header 2026-06-26 21:31:20 +02:00
TroodonEnjoyer aac9817ee7 Fix double login: set cookie on response object, invalidate sessions on dev restart
- Move cookie setting from cookies().set() to response.cookies.set() in
  login/logout routes (Next.js 15 ambient cookie merge is unreliable)
- Generate random JWT_SECRET at module scope in auth.ts for dev mode so
  every server start invalidates all prior sessions
- Replace blank page on auth failure with redirect to /login in dashboard layout
- Preserve ?redirect= param from middleware in login form
- Wrap login page in Suspense boundary for useSearchParams()

Fixes: double-login bug, session persistence across restarts, blank page on
session expiry
2026-06-26 21:29:36 +02:00
Hannah_Bagga fa97abb5b6 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 20:59:21 +02:00
Hannah_Bagga 3998285fd5 Fixed the gif picker just need to add in gifs 2026-06-26 20:58:57 +02:00
Hannah_Bagga 4c1db42873 Chat page fits whole screen now 2026-06-26 20:46:20 +02:00
Hannah_Bagga c428435f2f Removed video call, fixed logo, CRM changes with color theme 2026-06-26 20:30:46 +02:00
caitlin 886a7efe91 Ficed the leads per month in dev account 2026-06-26 19:52:02 +02:00
TroodonEnjoyer 1b5f244f28 Login possible fix and something else I can't remember 2026-06-26 19:39:45 +02:00
Hannah_Bagga 46a3216386 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:37 +02:00
Ace 8aba5c9ef4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:05 +02:00
Ace 06181625c3 AI back please test out and tell me added, filter words for AI! 2026-06-26 19:31:07 +02:00
caitlin 08eba574f8 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:27:22 +02:00
Hannah_Bagga a876f2d769 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:23:02 +02:00
caitlin fbb37d6777 Fixed sidebar colur and Cpu and Ram 2026-06-26 19:22:57 +02:00
Hannah_Bagga 214c4a1852 Spiderman Theme Fixed 2026-06-26 19:22:54 +02:00
Ace c669ec0d04 added browser use for AI 2026-06-26 19:18:01 +02:00
Ace bf6fbe3ee4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 18:47:54 +02:00
Ace d6534eb338 Commit Test 2026-06-26 18:47:39 +02:00
Ace a08f23ab0f Fixed the messages, chats, voice note, voice call, stickers, gifs, emoji's , file attachments 2026-06-26 18:47:35 +02:00
TroodonEnjoyer bccba1434b ATTEMPTED to fix my databse stuff, security etc, and a Issue Cait had 2026-06-26 18:37:28 +02:00
caitlin fe09d15359 Fixed the route issue 2026-06-26 17:59:40 +02:00
caitlin 2b8f2e7b59 Resolve merge conflicts in chats/page.tsx 2026-06-26 17:16:18 +02:00
caitlin fb99b49686 Fix for route 2026-06-26 16:53:37 +02:00
caitlin 75746f4cab Merge remote-tracking branch 'origin/main' into master 2026-06-26 16:47:37 +02:00
JCBSComputer a022e3cca3 Merge origin/main into local (conflict resolved in chats/page.tsx) 2026-06-26 16:43:44 +02:00
JCBSComputer 68483e9b60 Calender & Linked Added 2026-06-26 16:38:18 +02:00
Hannah_Bagga 21868fe336 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 16:37:44 +02:00
Rene 77b246b565 Attachment fixed 2026-06-26 16:35:31 +02:00
Hannah_Bagga 34e64fe6c7 Deleted 2026-06-26 16:34:20 +02:00
Ace ffa595451e Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 16:30:50 +02:00
Ace bd581739f9 Fuckups fixed 2026-06-26 16:30:48 +02:00
caitlin ec8207fca7 Update login page styling and sidebar collapse logo 2026-06-26 16:22:28 +02:00
TroodonEnjoyer ec4de03cf5 Restore bug report button in topbar 2026-06-26 15:32:04 +02:00
Hannah_Bagga dfba947aa4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 15:22:36 +02:00
TroodonEnjoyer 9bd795e39a Lazy imports for browser-use deps + restore missing bug-report route
browser-use-service/main.py: Made browser_use and langchain_ollama imports lazy (imported only in functions that use them) so the scraper starts without these optional packages.
2026-06-26 15:20:54 +02:00
Hannah_Bagga 66ae711cf0 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
# Conflicts:
#	src/app/(dashboard)/chats/page.tsx
2026-06-26 15:20:42 +02:00
Hannah_Bagga 827b0598e6 voice note and sticker pack and gifs 2026-06-26 15:18:09 +02:00
Ace 650116872b added Chnages on 2026-06-26 14:37:50 +02:00
Chariah 5e3c12fa2d Merge remote-tracking branch 'origin/main' 2026-06-26 14:31:46 +02:00
Chariah 7a76841309 Current state 2026-06-26 14:31:38 +02:00
Chariah 283c06f0cf Merge remote-tracking branch 'origin/main' 2026-06-26 14:25:21 +02:00
Chariah 60892d6151 Current state 2026-06-26 14:24:59 +02:00
Rene 131d4c8f94 Adding Block extensions 2026-06-26 14:23:29 +02:00
Ace 1f448320be Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 13:07:44 +02:00
Ace 9ce9506e8e Update on auto setup 2026-06-26 13:07:40 +02:00
Hannah_Bagga ed2e1fc64d Web Background 2026-06-26 13:03:37 +02:00
Ace 5feb95187c adding a wizard for setup 2026-06-26 12:08:32 +02:00
Ace a4d2ad143b Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:48:54 +02:00
Ace da702d6beb Changed from user specific, to open for everyone 2026-06-26 11:48:51 +02:00
caitlin 33a4f9dfa9 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:48:02 +02:00
Hannah_Bagga 82e8ce8ae9 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:21:45 +02:00
Hannah_Bagga 343f814569 npm 2026-06-26 11:21:10 +02:00
TroodonEnjoyer 4c09635d30 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:14:08 +02:00
TroodonEnjoyer 20a1744e7f Security architecture upgrade + bug reporting system
Database & Security:
- Dual password storage: bcrypt (auth) + pgcrypto AES-256 (recovery)
- SUPER_ADMIN master key recovery system (master_keys table)
- Row Level Security on customers, leads, opportunities, communications, tasks
  - SALES_USER: own records only
  - ADMIN: all records
  - SUPER_ADMIN: all records (bypasses RLS)
- Immutable audit logs (DELETE/UPDATE blocked by triggers)
- New audit event types: BUG_CREATED, BUG_UPDATED, BUG_ASSIGNED,
  BUG_RESOLVED, LOGIN, LOGOUT
- Database export logging (database_export_logs table)
- Backup logging with pg_dump script (scripts/backup.ps1)
- Fixed audit constraint to allow new action types

Authentication:
- Random JWT secret generated on every dev server start
  (invalidates all prior sessions after restart)
- Session cookie is now session-only (no maxAge)
- setSessionContext() for RLS integration

Bug Reporting System:
- bug_reports table with RLS (insert by all, select/update by admin only)
- POST /api/bug-reports (any authenticated user)
- GET /api/bug-reports (admin/super_admin only)
- PATCH /api/bug-reports/:id (admin/super_admin only)
- POST /api/auth/recover (super_admin password recovery)
- Audit logging for all bug report actions

Other:
- Added 'dev' to UserRole type
- Bug report modal UI with severity selector
- Added bug report button to topbar
2026-06-26 11:13:28 +02:00
Ace c9df7787d5 Added in loading bot 2026-06-26 11:10:58 +02:00
caitlin fa39f2af9d Merge origin/main into master 2026-06-26 09:48:11 +02:00
Ace 9bbaf70145 Checks done 2026-06-25 16:04:05 +02:00
Hannah_Bagga 5668a63370 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-25 15:50:35 +02:00
Ace 3af622f53d added browser 2026-06-25 15:42:35 +02:00
Hannah_Bagga 1465016b56 ..... 2026-06-25 15:15:35 +02:00
Hannah_Bagga 906e37e845 .... 2026-06-25 15:15:15 +02:00
caitlin 52a489759f Working on the mcp, this helps connect it 2026-06-25 10:25:00 +02:00
Ace 1f032dc098 Added rene changes 2026-06-24 18:43:38 +02:00
Ace 1adb6544c2 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-24 17:35:58 +02:00
Ace 56ff9995a2 Restore file structure to last legit commit b245b35, remove remote CRM_ENVR-main files 2026-06-24 17:35:48 +02:00
306 changed files with 19167 additions and 29316 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Build & Auto-Repair
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Playwright
run: npx playwright install chromium firefox
- name: Build check
id: build
continue-on-error: true
run: npx tsc --noEmit
- name: Run self-healing setup
if: steps.build.outcome == 'failure'
run: node scripts/setup.mjs --self-heal
- name: Run code repair agent
if: steps.build.outcome == 'failure'
env:
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
run: node scripts/code-repair-agent.mjs --ci
- name: Re-check build after repair
id: rebuild
continue-on-error: true
run: npx tsc --noEmit
- name: Commit fixes
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
run: |
git config user.name "CRM Repair Bot"
git config user.email "bot@coastit.co.za"
git add -A
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
git push
- name: Build failed — report
if: steps.rebuild.outcome == 'failure'
run: |
echo "::error::Build still failing after auto-repair. Manual intervention required."
exit 1
+9 -3
View File
@@ -47,9 +47,6 @@ error-log
# vercel
.vercel
# rust
rust-ai/target/
# python
browser-use-service/venv/
browser-use-service/__pycache__/
@@ -58,3 +55,12 @@ browser-use-service/.env
# typescript
*.tsbuildinfo
next-env.d.ts
.git
.git_bak
node_modules
target
# rust build artifacts
rust-ai/target/
# browser-use-service generated files
browser-use-service/*.txt
+42
View File
@@ -0,0 +1,42 @@
{
"version": 2,
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
"templates": {
"data/stickers": {
"template": "stickers.ts",
"description": "Emoji-based sticker packs for media picker"
},
"data/constants": {
"template": "constants.ts",
"description": "App-wide constants (lead statuses, roles, event types)"
},
"lib/utils": {
"template": "utils.ts",
"description": "Utility functions (cn, formatters, helpers)"
},
"types/index": {
"template": "types-index.ts",
"description": "Core type definitions (User, Lead, DashboardStats)"
},
"hooks/use-media": {
"template": "hooks/use-media.ts",
"description": "Media query hook"
},
"hooks/use-debounce": {
"template": "hooks/use-debounce.ts",
"description": "Debounce hook"
},
"hooks/use-local-storage": {
"template": "hooks/use-local-storage.ts",
"description": "LocalStorage hook with SSR safety"
}
},
"fallbackTemplates": {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts"
}
}
@@ -0,0 +1,7 @@
// ── Generic Component Template ────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/components/* imports. Customize as needed.
export default function GenericComponent() {
return <div />;
}
+54
View File
@@ -0,0 +1,54 @@
// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
export const EVENT_STATUSES = [
"scheduled",
"completed",
"cancelled",
"rescheduled",
] as const;
export const AI_MODELS = {
chat: "llama3.2:3b",
scraper: "dolphin-llama3:8b",
coder: "qwen2.5-coder:1.5b-base",
} as const;
export const PAGINATION = {
defaultLimit: 50,
maxLimit: 200,
} as const;
export const DATE_FORMATS = {
short: "MMM d, yyyy",
long: "MMMM d, yyyy",
time: "h:mm a",
datetime: "MMM d, yyyy h:mm a",
} as const;
@@ -0,0 +1,17 @@
// ── Generic Data Module Template ─────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/data/* imports. Customize as needed.
export interface DataItem {
id: string;
name: string;
[key: string]: unknown;
}
export function getDataItems(): DataItem[] {
return [];
}
export function getDataItem(id: string): DataItem | undefined {
return undefined;
}
@@ -0,0 +1,10 @@
// ── Generic Hook Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/hooks/* imports. Customize as needed.
import { useState, useEffect } from "react";
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
const [value, setValue] = useState(initialValue);
return [value, setValue];
}
@@ -0,0 +1,15 @@
// ── useDebounce Hook ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
@@ -0,0 +1,29 @@
// ── useLocalStorage Hook ──────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
if (item) setStoredValue(JSON.parse(item));
} catch {
// corrupted data — use initial value
}
}, [key]);
const setValue = (value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch {
// storage full or disabled
}
};
return [storedValue, setValue];
}
@@ -0,0 +1,18 @@
// ── useMedia Hook ─────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useMedia(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
@@ -0,0 +1,7 @@
// ── Generic Lib Module Template ──────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/lib/* imports. Customize as needed.
export function libFunction(): void {
// Placeholder
}
+95
View File
@@ -0,0 +1,95 @@
// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
const reactionStickers: Sticker[] = [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
];
const animalStickers: Sticker[] = [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
];
const foodStickers: Sticker[] = [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🍰" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
];
const activityStickers: Sticker[] = [
{ id: "football", name: "Football", emoji: "⚽" },
{ id: "basketball", name: "Basketball", emoji: "🏀" },
{ id: "tennis", name: "Tennis", emoji: "🎾" },
{ id: "running", name: "Running", emoji: "🏃" },
{ id: "swimming", name: "Swimming", emoji: "🏊" },
{ id: "cycling", name: "Cycling", emoji: "🚴" },
{ id: "gym", name: "Gym", emoji: "🏋️" },
{ id: "yoga", name: "Yoga", emoji: "🧘" },
{ id: "music", name: "Music", emoji: "🎵" },
{ id: "party", name: "Party", emoji: "🎉" },
{ id: "gift", name: "Gift", emoji: "🎁" },
{ id: "trophy", name: "Trophy", emoji: "🏆" },
];
const stickerPacks: StickerPack[] = [
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
{ id: "animals", name: "Animals", stickers: animalStickers },
{ id: "food", name: "Food & Drink", stickers: foodStickers },
{ id: "activities", name: "Activities", stickers: activityStickers },
];
export function getStickerPacks(): StickerPack[] {
return stickerPacks;
}
export function getStickerPack(id: string): StickerPack | undefined {
return stickerPacks.find((p) => p.id === id);
}
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
const pack = getStickerPack(packId);
return pack?.stickers.find((s) => s.id === stickerId);
}
@@ -0,0 +1,10 @@
// ── Generic Types Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/types/* imports. Customize as needed.
export type GenericType = Record<string, unknown>;
export interface GenericInterface {
id: string;
[key: string]: unknown;
}
+141
View File
@@ -0,0 +1,141 @@
// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus =
| "new"
| "contacted"
| "qualified"
| "proposal"
| "won"
| "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
monthlyBreakdown: {
label: string;
total: number;
new: number;
contacted: number;
qualified: number;
proposal: number;
won: number;
lost: number;
}[];
leadsPerMonth: { label: string; leads: number; won: number }[];
recentLeads: Lead[];
statusDistribution: { name: string; value: number; color: string }[];
periodLabel: string;
trends: Record<string, { pct: number; up: boolean }>;
}
export interface Note {
id: string;
leadId: string;
userId: string;
authorName: string;
authorAvatar: string;
authorRole: UserRole;
note: string;
createdAt: string;
updatedAt: string;
}
export type NotificationType =
| "lead_created"
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added"
| "event_scheduled";
export interface Notification {
id: string;
type: NotificationType;
title: string;
description: string;
timestamp: string;
read: boolean;
link?: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string;
content: string;
timestamp: string;
}
export interface Conversation {
id: string;
participants: { id: string; name: string; avatar: string; role: string }[];
lastMessage: string;
lastMessageTime: string;
unread: number;
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "email" | "task" | "note";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
developerId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
description: string | null;
participantNotes: string | null;
eventType: EventType;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string;
}
@@ -0,0 +1,7 @@
// ── Generic Utils Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/utils/* imports. Customize as needed.
export function utilsFunction(): void {
// Placeholder
}
+76
View File
@@ -0,0 +1,76 @@
// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
return fn().catch((err) =>
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
);
}
-1
View File
@@ -1 +0,0 @@
rust-ai/target/** linguist-generated=true -diff -merge
-60
View File
@@ -1,60 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/node_modules_old
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# runtime data
data/
data/ai/
data/ai/jobs.jsonl
# logs
*.log
*.out
error-log
# vercel
.vercel
# rust
rust-ai/target/
# python
browser-use-service/venv/
browser-use-service/__pycache__/
browser-use-service/.env
# typescript
*.tsbuildinfo
next-env.d.ts
-44
View File
@@ -1,44 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Install
- Jose
- Node.js
- Add emoji pakages
- Install postgres for databases
- Typescript
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
-56
View File
@@ -1,56 +0,0 @@
# CRM Frontend - Quick Start
## Prerequisites
- Node.js >= 18.17 (recommended: 20.9+ or 22.x)
- npm 9+
## Install & Run
```bash
cd frontend
# Install dependencies
npm install
# Start dev server
npm run dev
```
The app runs at **http://localhost:3000**
## Pages
| Route | Description |
|-------|-------------|
| `/login` | Login page |
| `/` | Dashboard |
| `/leads` | Lead management table |
| `/leads/:id` | Lead detail view |
| `/users` | User management |
| `/settings` | Settings (Company, Theme, Notifications) |
## Tech Stack
- Next.js 15 + React 18
- TypeScript + Tailwind CSS v4
- shadcn/ui components (Radix primitives)
- TanStack Table v8
- Recharts
- Framer Motion
- Lucide Icons
## If Node.js is < 20.9
The eslint-config-next package requires Node.js >= 20.9. If your Node.js is older:
```bash
npm install --legacy-peer-deps
```
Or upgrade Node.js via [nvm](https://github.com/nvm-sh/nvm):
```bash
nvm install 22
nvm use 22
```
-293
View File
@@ -1,293 +0,0 @@
import http from "node:http"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(__dirname, "..")
// ── Load .env.local ──────────────────────────────────────────────
try {
const envPath = path.join(ROOT, ".env.local")
const envContent = fs.readFileSync(envPath, "utf-8")
for (const line of envContent.split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eqIdx = trimmed.indexOf("=")
if (eqIdx === -1) continue
const k = trimmed.substring(0, eqIdx).trim()
let v = trimmed.substring(eqIdx + 1).trim()
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
if (!process.env[k]) process.env[k] = v
}
console.log("Loaded .env.local")
} catch {
// .env.local may not exist, ignore
}
// ── Config from env ─────────────────────────────────────────────
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
const HOST = process.env.AI_HOST || "0.0.0.0"
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
const MODEL = process.env.AI_MODEL || "sam860/dolphin3-llama3.2:3b"
const DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
// ── Job loading ─────────────────────────────────────────────────
function loadJobs() {
try {
const content = fs.readFileSync(JOBS_PATH, "utf-8")
const jobs = content
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
try {
return JSON.parse(l)
} catch {
return null
}
})
.filter(Boolean)
console.log(`Loaded ${jobs.length} job categories`)
return jobs
} catch (e) {
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
return []
}
}
// ── ai.md management ────────────────────────────────────────────
function readInstructions() {
try {
return fs.readFileSync(AI_MD_PATH, "utf-8")
} catch {
return ""
}
}
function writeInstructions(content) {
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
return content
}
function appendToImprovementLog(entry) {
const current = readInstructions()
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
const logEntry = `\n- ${timestamp}${entry}`
const logSection = "\n## Improvement Log"
const logIndex = current.indexOf(logSection)
if (logIndex !== -1) {
const afterLog = current.substring(logIndex + logSection.length)
const nextSectionIndex = afterLog.search(/\n## /)
if (nextSectionIndex !== -1) {
const insertAt = logIndex + logSection.length + nextSectionIndex
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
writeInstructions(updated)
return updated
}
writeInstructions(current + logEntry + "\n")
return current + logEntry + "\n"
}
const updated = current + `\n${logSection}\n${logEntry}\n`
writeInstructions(updated)
return updated
}
// ── Chat handler ────────────────────────────────────────────────
async function handleChat(userMessage, userId, userRole) {
const jobs = loadedJobs
const instructions = readInstructions()
const jobList = jobs
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
.join("\n")
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
Available job categories to target:
${jobList}
Current instructions:
${instructions}
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: MODEL,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
stream: false,
options: { temperature: 0.7, num_predict: 1024 },
}),
})
if (!ollamaRes.ok) {
const text = await ollamaRes.text()
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
}
const data = await ollamaRes.json()
const responseText = data.message?.content || ""
// Try to persist to DB (best-effort)
try {
if (pgPool && userId) {
await pgPool.query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
)
}
} catch {
// table might not exist, ignore
}
return responseText
}
// ── PG pool (lazy init) ────────────────────────────────────────
let pgPool = null
async function initPg() {
if (!DATABASE_URL) return
try {
const { default: pg } = await import("pg")
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
await pgPool.query("SELECT 1")
console.log("Connected to PostgreSQL")
} catch (e) {
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
}
}
// ── Request router ─────────────────────────────────────────────
const loadedJobs = loadJobs()
function sendJSON(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json" })
res.end(JSON.stringify(data))
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = ""
req.on("data", (chunk) => (body += chunk))
req.on("end", () => {
try {
resolve(JSON.parse(body))
} catch {
reject(new Error("Invalid JSON"))
}
})
req.on("error", reject)
})
}
function parseURL(req) {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
return { pathname: url.pathname, searchParams: url.searchParams }
}
const server = http.createServer(async (req, res) => {
// CORS
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
const { pathname } = parseURL(req)
try {
// GET /health
if (req.method === "GET" && pathname === "/health") {
return sendJSON(res, 200, { status: "ok", model: MODEL })
}
// GET /ai/jobs
if (req.method === "GET" && pathname === "/ai/jobs") {
return sendJSON(res, 200, { jobs: loadedJobs })
}
// GET /ai/instructions
if (req.method === "GET" && pathname === "/ai/instructions") {
const instructions = readInstructions()
return sendJSON(res, 200, { success: true, instructions })
}
// POST /ai/instructions
if (req.method === "POST" && pathname === "/ai/instructions") {
const body = await parseBody(req)
if (body.content) {
writeInstructions(body.content)
} else if (body.entry) {
appendToImprovementLog(body.entry)
}
return sendJSON(res, 200, {
success: true,
instructions: readInstructions(),
})
}
// POST /ai/chat
if (req.method === "POST" && pathname === "/ai/chat") {
const startTime = Date.now()
const chunks = []
req.on("data", c => chunks.push(c))
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString()
try { fs.appendFileSync("C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-req-log.txt",
`${new Date().toISOString()} headers=${JSON.stringify(req.headers)} body=${rawBody}\n`) } catch {}
try {
const body = JSON.parse(rawBody)
// Continue processing
processRequest(req, res, body, startTime)
} catch {
sendJSON(res, 400, { error: "Invalid JSON" })
}
})
return
}
// Separate handler
async function processRequest(req, res, body, startTime) {
const { message, user_id, user_role } = body
if (!message) {
return sendJSON(res, 400, { error: "message is required" })
}
const validRoles = ["sales", "admin", "super_admin"]
if (user_role && !validRoles.includes(user_role)) {
return sendJSON(res, 403, { error: "Forbidden" })
}
const response = await handleChat(message, user_id || "", user_role || "sales")
return sendJSON(res, 200, { response })
}
// 404
sendJSON(res, 404, { error: "Not found" })
} catch (err) {
console.error("Request error:", err)
sendJSON(res, 500, { error: err.message })
}
})
// ── Start ───────────────────────────────────────────────────────
server.listen(PORT, HOST, () => {
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
console.log(` Model: ${MODEL}`)
console.log(` Ollama: ${OLLAMA_URL}`)
})
initPg()
@@ -1,445 +0,0 @@
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging
from datetime import datetime, timedelta
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from playwright.async_api import async_playwright
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3006", "http://127.0.0.1:3006"],
allow_methods=["POST"],
allow_headers=["*"],
)
PORT = int(os.getenv("PORT", "3008"))
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
FX_PROFILE = os.getenv('FX_PROFILE', '')
BROAD_KEYWORDS = [
"website", "web design", "web develop", "web dev",
"build my website", "build a website", "create a website",
"need web", "looking for web", "new website",
"landing page", "wordpress",
"need a website", "my website", "business website",
"need a designer", "help with my website",
"redesign", "update my website",
"looking for", "need a", "need an", "looking to",
"help me build", "create my", "build me",
"design my", "make me a", "would like",
"need someone", "hire a", "looking to hire",
"want someone", "need help with",
]
def kw_match(text: str) -> bool:
t = text.lower()
return any(kw in t for kw in BROAD_KEYWORDS)
FB_SEARCHES = [
"looking for web developer",
"need a website designed",
"need website built South Africa",
"need someone to build my website",
"need web designer",
"looking for someone to create website",
"need ecommerce website built",
"want to hire web developer",
"website quote please",
"need wordpress website",
"I need a website for my business",
"need a site for my business",
]
VIEWPORTS = [
{'width': 1280, 'height': 800},
{'width': 1366, 'height': 768},
{'width': 1440, 'height': 900},
{'width': 1536, 'height': 864},
{'width': 1920, 'height': 1080},
]
async def get_fb_cookies(profile_path: str | None = None):
cookie_db_path = profile_path or FX_PROFILE
if not cookie_db_path:
logger.warning("No profile path provided")
return []
cookie_db = os.path.join(cookie_db_path, 'cookies.sqlite')
if not os.path.exists(cookie_db):
logger.warning("Cookie DB not found at %s", cookie_db)
return []
tmp = os.path.join(os.path.dirname(cookie_db), f'cookies_tmp_{os.getpid()}.sqlite')
for attempt in range(3):
try:
shutil.copy2(cookie_db, tmp)
break
except PermissionError:
if attempt < 2:
await asyncio.sleep(0.5)
else:
logger.error("Failed to copy cookie DB after 3 attempts")
return []
try:
conn = sqlite3.connect(tmp)
c = conn.cursor()
c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'")
rows = c.fetchall()
conn.close()
try:
os.remove(tmp)
except Exception:
pass
return [{
"name": name, "value": value,
"domain": host if host.startswith('.') else f'.{host}',
"path": path,
"httpOnly": True, "secure": True, "sameSite": "Lax",
} for name, value, host, path in rows]
except Exception as e:
logger.error("Cookie DB read error: %s", e)
try:
os.remove(tmp)
except Exception:
pass
return []
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
def _parse_fb_date(block: list[str]) -> str:
for l in block:
l = l.strip()
m = re.match(r'(\d+)\s*(h|hr|hrs|hour|hours)\s*ago', l)
if m:
hours = int(m.group(1))
d = datetime.now() - timedelta(hours=hours)
return d.strftime('%Y-%m-%d')
if l.lower() == 'yesterday':
d = datetime.now() - timedelta(days=1)
return d.strftime('%Y-%m-%d')
low = l.lower()
if low in WEEKDAY_ORDER:
day_idx = WEEKDAY_ORDER.index(low)
today_idx = datetime.now().weekday()
days_ago = (today_idx - day_idx) % 7
d = datetime.now() - timedelta(days=days_ago)
return d.strftime('%Y-%m-%d')
for fmt in ['%Y-%m-%d', '%d %B %Y', '%B %d %Y', '%d %b %Y', '%b %d %Y', '%d %b', '%b %d']:
try:
dt = datetime.strptime(l, fmt)
return dt.strftime('%Y-%m-%d')
except ValueError:
pass
return datetime.now().strftime('%Y-%m-%d')
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
lines = [l.strip() for l in raw.split('\n')]
blocks = []
cur = []
fb_run = 0
for l in lines:
if 'facebook' in l.lower():
fb_run += 1
if cur and fb_run >= 2:
blocks.append(cur)
cur = []
else:
fb_run = 0
if l and len(l) >= 15:
words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) >= 2:
cur.append(l)
if cur:
blocks.append(cur)
posts = []
seen_texts = set()
for block in blocks:
if not block:
continue
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
if not kw_indices:
continue
i = kw_indices[0]
start = max(0, i - 2)
end = min(len(block), i + 5)
snippet = ' '.join(block[start:end])
if len(snippet) < 40:
continue
dekey = snippet[:80]
if dekey in seen_texts:
continue
seen_texts.add(dekey)
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": block[start] if start < i else '',
"url": url,
"source": "facebook",
"date": _parse_fb_date(block),
})
return posts
async def human_scroll(page, steps: int = None, total_delay: float = None):
steps = steps or random.randint(2, 5)
total_delay = total_delay or random.uniform(6, 18)
step_delay = total_delay / steps
for i in range(steps):
scroll_dist = random.randint(200, 600)
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
await page.wait_for_timeout(int(step_delay * 1000))
if random.random() < 0.3:
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 300)})")
await page.wait_for_timeout(random.randint(1000, 3000))
async def random_idle(page):
action = random.random()
if action < 0.15:
try:
elems = await page.query_selector_all('a, span, div[role="button"]')
if elems:
el = random.choice(elems[:20])
box = await el.bounding_box()
if box:
x = box['x'] + box['width'] / 2 + random.randint(-20, 20)
y = box['y'] + box['height'] / 2 + random.randint(-10, 10)
await page.mouse.move(x, y, steps=random.randint(20, 40))
await page.wait_for_timeout(random.randint(500, 2000))
except Exception:
pass
async def search_facebook(page, query: str) -> list[dict]:
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
# Accidental overscroll: 10% chance to jump to bottom and back
if random.random() < 0.1:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await page.wait_for_timeout(random.randint(1000, 3000))
await page.evaluate("window.scrollBy(0, -300)")
await page.wait_for_timeout(random.randint(1000, 3000))
if random.random() < 0.2:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
if random.random() < 0.3:
await random_idle(page)
raw = await page.evaluate('document.body.innerText')
except Exception as e:
logger.warning("Facebook search failed: %s", e)
return []
return _extract_posts_from_text(raw, url)
def cleanup_chrome():
import subprocess, signal
try:
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
except Exception:
pass
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
cleanup_chrome()
fb_cookies = await get_fb_cookies(profile_path)
if not fb_cookies:
logger.warning("No Facebook cookies available")
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No cookies available"}
try:
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=True,
args=['--disable-blink-features=AutomationControlled']
)
viewport = random.choice(VIEWPORTS)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
viewport=viewport,
)
await context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', { get: () => false });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
window.chrome = { runtime: {} };
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
""")
for c in fb_cookies:
try:
await context.add_cookies([c])
except Exception as e:
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
page = await context.new_page()
# Navigate through google first for a legitimate Referer header
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(random.randint(1000, 3000))
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url
if '/login' in url.lower():
logger.warning("Facebook login page detected — account flagged")
await browser.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": "login_page", "error": "Facebook login page detected"}
# Browse feed like a human before searching
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
# False start: 30% chance to idle and leave without scraping (skipped when force=true)
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await browser.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(3, 6))
for i, query in enumerate(searches):
try:
posts = await search_facebook(page, query)
all_posts.extend(posts)
# Between searches: random break with possible small scroll
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
# Tab switch: 15% chance after 2nd-3rd search
if i == random.randint(1, 2) and random.random() < 0.15:
new_page = await context.new_page()
await new_page.goto('https://www.messenger.com/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
await new_page.close()
await page.wait_for_timeout(random.randint(1000, 3000))
except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e)
# Idle before closing — human would pause
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await browser.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
logger.error("Facebook scrape failed: %s", e)
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
async def ask_ollama(prompt: str) -> str:
import httpx
async with httpx.AsyncClient(timeout=120) as c:
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
"model": CLASSIFY_MODEL,
"messages": [
{"role": "system", "content": "You classify forum posts. Return only valid JSON."},
{"role": "user", "content": prompt}
],
"stream": False,
"options": {"temperature": 0.05, "num_predict": 1024},
})
r.raise_for_status()
data = r.json()
return data["message"]["content"]
async def classify_leads(results: list[dict]) -> list[dict]:
if not results:
return []
briefs = [r["title"][:200] for r in results]
prompt = f"""Classify each post as LEAD or NOT.
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
LEAD examples: "Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"
NOT LEAD:
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
- Recruiting employees, hiring staff, looking for business partners
- Selling products, promoting services, affiliate offers
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
Return a JSON array like ["yes","no","yes"] matching the order above."""
ai_succeeded = False
try:
raw = await ask_ollama(prompt)
raw = raw.strip()
if raw.startswith("```"):
first_nl = raw.find('\n')
if first_nl != -1:
raw = raw[first_nl + 1:]
if raw.endswith("```"):
raw = raw[:-3]
raw = raw.strip()
answers = json.loads(raw)
if isinstance(answers, list) and len(answers) == len(results):
ai_succeeded = True
filtered = []
for i, ans in enumerate(answers):
if isinstance(ans, str) and ans.lower() == 'yes':
filtered.append(results[i])
if filtered:
return filtered[:10]
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
return []
except Exception as e:
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
if not ai_succeeded:
strict_keywords = [
"website", "web design", "web develop", "web dev",
"build my website", "build a website", "create a website",
"need web", "looking for web", "new website",
"landing page", "wordpress",
"need a website", "my website", "business website",
"need a designer", "help with my website",
"redesign", "update my website",
]
filtered = []
for r in results:
t = r['title'].lower()
if not any(kw in t for kw in strict_keywords):
continue
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
'affordable web', 'web hosting', 'free website',
'limited time', 'special offer', 'sign up now',
'offer']):
continue
filtered.append(r)
return filtered[:10]
return []
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/scrape/facebook")
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
result = await scrape_facebook(profile_path, force)
return result
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=PORT)
@@ -1,164 +0,0 @@
# CRM Database Schema — Enterprise RBAC + CRM
## Architecture Overview
Enterprise-grade PostgreSQL schema implementing **hierarchical Role-Based Access Control (RBAC)**
with a complete CRM system. Designed for production deployments with strict security requirements,
audit compliance, and scalability to millions of records.
---
## 1. Hierarchical RBAC Model
Four roles arranged in a strict hierarchy by `hierarchy_level` (lower = higher privilege):
| Role | Level | Authority |
|------|-------|-----------|
| **SUPER_ADMIN** | 1 | Unrestricted — create/delete users, assign roles, override all permissions |
| **ADMIN** | 2 | Operational — ban/suspend users, review reports, moderate activity. **Cannot create accounts.** |
| **SALES_USER** | 3 | CRM operations — leads, customers, opportunities, communications |
| **DEVELOPER** | 4 | Technical + CRM read access for post-sale project visibility |
### Enforcement via PostgreSQL Triggers
- **`enforce_create_user()`** (BEFORE INSERT ON users): Only SUPER_ADMIN (`hierarchy_level = 1`)
can create new user accounts. Bootstrap case (`created_by IS NULL`) is allowed for initial setup.
- **`enforce_role_assignment()`** (BEFORE INSERT ON user_roles): Only SUPER_ADMIN can assign roles.
Prevents privilege escalation by checking hierarchy levels.
### Permission Model
Granular permissions stored in `permissions` table with `(resource, action)` pairs.
`role_permissions` maps roles to permissions. The `has_permission(user_id, resource, action)`
function provides application-layer authorization checks.
---
## 2. Ban & Suspension System
Two separate tables for enforcement flexibility:
- **`banned_users`**: Permanent or time-limited bans. Supports reversal (by SUPER_ADMIN).
Admins can create bans; SUPER_ADMIN can reverse or permanently delete banned users.
- **`suspended_users`**: Time-limited suspensions (always has expiry). Admins can create
and lift suspensions.
Both tables are fully audited via dedicated trigger functions.
---
## 3. Session & Login Security
- **`sessions`**: Stores hashed session tokens with expiry. Only one active session per
token hash.
- **`login_attempts`**: Captures every login attempt (successful or failed) with IP address
and user agent. Used for brute-force detection and audit.
- Users have `failed_login_attempts` and `locked_until` for account lockout policies.
---
## 4. Password Security
- All passwords hashed with **bcrypt (cost factor 12)**
- `password_change_required` forces password change on first login (TRUE for all non-root
test accounts)
- Password hashes are opaque to the database — validation is application-layer only
---
## 5. UUID Primary Keys
- All tables use `UUID PRIMARY KEY DEFAULT uuid_generate_v4()`
- Fixed UUIDs used for seed data and system entities for referential transparency
- Prevents enumeration attacks and supports distributed ID generation
---
## 6. Audit Trail
Every mutation is logged to `audit_logs` via `audit_trigger_function()`:
- Captures `(table_name, record_id, action, old_data, new_data, changed_by, ip_address)`
- Ban actions, suspension actions, and successful logins have dedicated audit triggers
- Indexed on `(table_name, record_id)` for per-record lookup
Dedicated audit views:
- `v_active_bans` — Currently active bans with user details
- `v_active_suspensions` — Currently active suspensions
- `v_user_permissions` — Flattened permission report per user
---
## 7. Third Normal Form (3NF)
Customer model uses the **discriminator pattern**:
```
customers (parent — shared columns: status, owner, tags, score)
├── customer_type = 'individual' → individual_customers
└── customer_type = 'company' → company_customers
```
All lookup tables are normalized. Contact information, addresses, and notes are
separate 1:N child tables.
---
## 8. Indexing Strategy
- **Core indexes**: FK columns, status/sort/date columns, unique constraints
- **Partial indexes**: `WHERE deleted_at IS NULL` on soft-delete tables,
`WHERE is_active = TRUE` on bans/suspensions, `WHERE due_date < NOW()` on overdue invoices
- **Full-text search**: GIN indexes on customers, leads, products, communications, tasks
- **Composite indexes**: Common query patterns (e.g., `opportunities(owner_id, stage_id)`)
---
## 9. Soft Delete
- `deleted_at TIMESTAMPTZ` on all business tables
- Partial unique indexes ignore soft-deleted rows (e.g., `WHERE deleted_at IS NULL`)
- Audit logs capture the full row snapshot before soft-delete
---
## 10. Test Accounts
| Username | Password | Role |
|----------|----------|------|
| `superadmin_demo` | `[REDACTED]` | SUPER_ADMIN |
| `admin_demo` | `[REDACTED]` | ADMIN |
| `sales_demo` | `[REDACTED]` | SALES_USER |
| `dev_demo` | `[REDACTED]` | DEVELOPER |
---
## 11. Migration Instructions
```bash
# Create database
psql -U postgres -c "CREATE DATABASE crm;"
# Run full migration
psql -U postgres -d crm -f database/migrations/run_all.sql
# Or step by step:
psql -U postgres -d crm -f database/migrations/001_schema.sql
psql -U postgres -d crm -f database/migrations/002_seed.sql
```
---
## 12. Security Rules Summary
| Rule | Enforcement |
|------|-------------|
| Only SUPER_ADMIN creates accounts | Trigger `trg_enforce_create_user` |
| Only SUPER_ADMIN assigns roles | Trigger `trg_enforce_role_assignment` |
| No privilege escalation | Level check in `enforce_role_assignment()` |
| bcrypt password hashing | Application-layer (cost=12) |
| Unique usernames/emails | Partial unique indexes |
| Force password change on first login | `password_change_required` column |
| Audit every login/logout | Trigger `trg_audit_login` on `login_attempts` |
| Audit every ban action | Trigger `trg_audit_ban` on `banned_users` |
| Audit every suspension action | Trigger `trg_audit_suspension` on `suspended_users` |
File diff suppressed because it is too large Load Diff
@@ -1,401 +0,0 @@
-- ============================================================================
-- CRM Database Seed Data — RBAC + Test Accounts + Reference Data
-- ============================================================================
-- ============================================================================
-- FIXED UUIDs FOR REFERENTIAL INTEGRITY
-- ============================================================================
-- Role UUIDs
-- SUPER_ADMIN=1, ADMIN=2, SALES_USER=3, DEVELOPER=4
-- User UUIDs: SUPER_ADMIN=u0001, ADMIN=u0002, SALES=u0003, DEV=u0004
-- ============================================================================
-- 1. ROLES
-- ============================================================================
INSERT INTO roles (id, name, display_name, description, hierarchy_level, is_system) VALUES
('00000001-0000-0000-0000-000000000000', 'SUPER_ADMIN', 'Super Administrator',
'Highest authority. Full system access. Can create/delete users, assign roles, override all permissions.',
1, TRUE),
('00000002-0000-0000-0000-000000000000', 'ADMIN', 'Administrator',
'Operational administration. Can ban/suspend users, review reports, moderate activity. Cannot create accounts.',
2, TRUE),
('00000003-0000-0000-0000-000000000000', 'SALES_USER', 'Sales Representative',
'Day-to-day CRM operations. Manage leads, customers, opportunities, and communications.',
3, TRUE),
('00000004-0000-0000-0000-000000000000', 'DEVELOPER', 'Developer',
'Internal development/testing with CRM read access. API testing, diagnostics, debugging. Can view projects and customer data post-sale.',
4, TRUE)
ON CONFLICT (name) DO NOTHING;
-- ============================================================================
-- 2. PERMISSIONS — organised by category
-- ============================================================================
-- USER MANAGEMENT (SUPER_ADMIN only)
INSERT INTO permissions (id, resource, action, description, category) VALUES
('a0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'),
('a0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'),
('a0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'),
('a0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'),
('a0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'),
('a0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'),
('a0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'),
('a0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'),
('a0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'),
('a0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management')
ON CONFLICT DO NOTHING;
-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN)
INSERT INTO permissions (id, resource, action, description, category) VALUES
('a0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'),
('a0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'),
('a0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'),
('a0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'),
('a0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'),
('a0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management')
ON CONFLICT DO NOTHING;
-- CRM CORE (SALES_USER + MANAGERS)
INSERT INTO permissions (id, resource, action, description, category) VALUES
('a0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'),
('a0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'),
('a0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'),
('a0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'),
('a0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'),
('a0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'),
('a0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'),
('a0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'),
('a0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'),
('a0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'),
('a0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'),
('a0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'),
('a0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'),
('a0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'),
('a0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'),
('a0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'),
('a0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM')
ON CONFLICT DO NOTHING;
-- REPORTING & INCIDENTS (ADMIN)
INSERT INTO permissions (id, resource, action, description, category) VALUES
('a0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'),
('a0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'),
('a0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'),
('a0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'),
('a0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'),
('a0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'),
('a0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting')
ON CONFLICT DO NOTHING;
-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN)
INSERT INTO permissions (id, resource, action, description, category) VALUES
('a0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'),
('a0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'),
('a0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'),
('a0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'),
('a0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'),
('a0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System')
ON CONFLICT DO NOTHING;
-- DEVELOPER PERMISSIONS
INSERT INTO permissions (id, resource, action, description, category) VALUES
('a0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'),
('a0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'),
('a0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'),
('a0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'),
('a0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'),
('a0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer')
ON CONFLICT DO NOTHING;
-- ============================================================================
-- 3. ROLE-PERMISSION MAPPING
-- ============================================================================
-- SUPER_ADMIN — ALL permissions
INSERT INTO role_permissions (role_id, permission_id)
SELECT '00000001-0000-0000-0000-000000000000', id FROM permissions
ON CONFLICT DO NOTHING;
-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
-- Bans & Suspensions
('00000002-0000-0000-0000-000000000000', 'a0020001-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0020004-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0020005-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0020006-0000-0000-0000-000000000000', NULL),
-- User read + disable
('00000002-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0010009-0000-0000-0000-000000000000', NULL),
-- Reporting & Incidents
('00000002-0000-0000-0000-000000000000', 'a0040001-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0040002-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0040003-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0040004-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0040005-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0040006-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0040007-0000-0000-0000-000000000000', NULL),
-- CRM read access
('00000002-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL),
-- Audit read
('00000002-0000-0000-0000-000000000000', 'a0050002-0000-0000-0000-000000000000', NULL)
ON CONFLICT DO NOTHING;
-- SALES_USER — CRM operations
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
('00000003-0000-0000-0000-000000000000', 'a0030001-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030003-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030005-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030007-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030008-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030009-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030011-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030012-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030013-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030015-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
ON CONFLICT DO NOTHING;
-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility)
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
-- Developer technical permissions
('00000004-0000-0000-0000-000000000000', 'a0060001-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0060002-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0060003-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0060004-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0060005-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0060006-0000-0000-0000-000000000000', NULL),
-- User self-view
('00000004-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
-- CRM access (matching SALES_USER level for post-sale project visibility)
('00000004-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
ON CONFLICT DO NOTHING;
-- ============================================================================
-- 4. TEST ACCOUNTS
-- ============================================================================
-- Passwords (bcrypt, cost=12):
-- superadmin_demo / SuperAdmin@2026
-- admin_demo / AdminAccess@2026
-- sales_demo / SalesAccess@2026
-- dev_demo / DevTesting@2026
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
'$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC',
'Super', 'Admin', TRUE, FALSE),
('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@coastit.co.za',
'$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy',
'System', 'Admin', TRUE, TRUE),
('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@coastit.co.za',
'$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W',
'Sales', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
'Dev', 'User', TRUE, TRUE)
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
-- Set created_by for other users (SUPER_ADMIN created them)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
AND created_by IS NULL;
-- ============================================================================
-- 5. ROLE ASSIGNMENTS
-- ============================================================================
-- Assign SUPER_ADMIN role to superadmin_demo (bootstrap — assigned_by NULL)
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
('00000000-0000-0000-0000-000000000001', '00000001-0000-0000-0000-000000000000', NULL)
ON CONFLICT DO NOTHING;
-- Assign remaining roles (SUPER_ADMIN assigned them)
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING;
-- ============================================================================
-- 6. REFERENCE DATA
-- ============================================================================
-- Customer Statuses
INSERT INTO customer_statuses (id, name, description, color, sort_order, is_default) VALUES
('c0000000-0000-0000-0000-000000000001', 'Active', 'Customer in good standing', '#22C55E', 1, TRUE),
('c0000000-0000-0000-0000-000000000002', 'Inactive', 'No recent activity', '#A0AEC0', 2, FALSE),
('c0000000-0000-0000-0000-000000000003', 'At Risk', 'Showing signs of churn', '#F59E0B', 3, FALSE),
('c0000000-0000-0000-0000-000000000004', 'Churned', 'Relationship ended', '#EF4444', 4, FALSE),
('c0000000-0000-0000-0000-000000000005', 'VIP', 'High-value priority customer', '#8B5CF6', 0, FALSE)
ON CONFLICT DO NOTHING;
-- Lead Sources
INSERT INTO lead_sources (id, name, description) VALUES
('d0000000-0000-0000-0000-000000000001', 'Website', 'Organic website traffic or web forms'),
('d0000000-0000-0000-0000-000000000002', 'Referral', 'Referred by existing customer'),
('d0000000-0000-0000-0000-000000000003', 'LinkedIn', 'LinkedIn outreach'),
('d0000000-0000-0000-0000-000000000004', 'Email Campaign', 'Email marketing campaign'),
('d0000000-0000-0000-0000-000000000005', 'Trade Show', 'Event or trade show'),
('d0000000-0000-0000-0000-000000000006', 'Cold Call', 'Outbound cold calling'),
('d0000000-0000-0000-0000-000000000007', 'Partner', 'Channel partner referral'),
('d0000000-0000-0000-0000-000000000008', 'Social Media', 'Social media platforms'),
('d0000000-0000-0000-0000-000000000009', 'Advertisement', 'Paid advertising'),
('d0000000-0000-0000-0000-000000000010', 'Other', 'Other sources')
ON CONFLICT DO NOTHING;
-- Lead Stages
INSERT INTO lead_stages (id, name, description, sort_order, probability) VALUES
('e0000000-0000-0000-0000-000000000001', 'New', 'Newly captured lead', 1, 5),
('e0000000-0000-0000-0000-000000000002', 'Contacted', 'Initial contact made', 2, 10),
('e0000000-0000-0000-0000-000000000003', 'Qualified', 'Meets qualification criteria', 3, 25),
('e0000000-0000-0000-0000-000000000004', 'Interested', 'Shown interest', 4, 40),
('e0000000-0000-0000-0000-000000000005', 'Demo Scheduled', 'Product demo scheduled', 5, 60),
('e0000000-0000-0000-0000-000000000006', 'Negotiation', 'In price/terms negotiation', 6, 75),
('e0000000-0000-0000-0000-000000000007', 'Closed Won', 'Successfully converted', 7, 100),
('e0000000-0000-0000-0000-000000000008', 'Closed Lost', 'Lead was lost', 8, 0)
ON CONFLICT DO NOTHING;
-- Deal Stages
INSERT INTO deal_stages (id, name, description, sort_order, probability) VALUES
('f0000000-0000-0000-0000-000000000001', 'Prospecting', 'Initial qualification', 1, 10),
('f0000000-0000-0000-0000-000000000002', 'Discovery', 'Understanding needs', 2, 20),
('f0000000-0000-0000-0000-000000000003', 'Proposal', 'Solution proposed', 3, 40),
('f0000000-0000-0000-0000-000000000004', 'Negotiation', 'Commercial discussions', 4, 60),
('f0000000-0000-0000-0000-000000000005', 'Closing', 'Final stage before decision', 5, 80),
('f0000000-0000-0000-0000-000000000006', 'Won', 'Deal closed won', 6, 100),
('f0000000-0000-0000-0000-000000000007', 'Lost', 'Deal closed lost', 7, 0)
ON CONFLICT DO NOTHING;
-- Communication Types
INSERT INTO communication_types (id, name, description, icon) VALUES
('b0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'),
('b0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'),
('b0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'),
('b0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'),
('b0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'),
('b0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'),
('b0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text')
ON CONFLICT DO NOTHING;
-- Task Priorities
INSERT INTO task_priorities (id, name, color, sort_order) VALUES
('c0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1),
('c0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2),
('c0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3),
('c0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4)
ON CONFLICT DO NOTHING;
-- Invoice Statuses
INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES
('d0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1),
('d0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2),
('d0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3),
('d0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4),
('d0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5),
('d0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6),
('d0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7)
ON CONFLICT DO NOTHING;
-- Product Categories
INSERT INTO product_categories (id, name, description, sort_order) VALUES
('e0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1),
('e0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2),
('e0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3),
('e0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4),
('e0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5)
ON CONFLICT DO NOTHING;
-- ============================================================================
-- 7. SAMPLE CRM DATA (for demo purposes)
-- ============================================================================
-- Sample Products
INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES
('f0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00),
('f0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00),
('f0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00),
('f0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00),
('f0000000-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00),
('f0000000-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00)
ON CONFLICT DO NOTHING;
-- Sample Customers
INSERT INTO customers (id, customer_type, status_id, owner_id, source, tags, score) VALUES
('a0000000-0000-0000-0000-000000000001', 'company', 'c0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000003', 'Website', ARRAY['tech','enterprise'], 85),
('a0000000-0000-0000-0000-000000000002', 'individual', 'c0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000003', 'Referral', ARRAY['retail'], 60),
('a0000000-0000-0000-0000-000000000003', 'company', 'c0000000-0000-0000-0000-000000000005',
'00000000-0000-0000-0000-000000000003', 'LinkedIn', ARRAY['finance','enterprise','vip'], 95),
('a0000000-0000-0000-0000-000000000004', 'individual', 'c0000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000003', 'Email Campaign', ARRAY['education'], 15)
ON CONFLICT DO NOTHING;
-- Company details
INSERT INTO company_customers (customer_id, company_name, registration_number, tax_id, website, industry, company_size, annual_revenue) VALUES
('a0000000-0000-0000-0000-000000000001', 'TechCorp International', 'REG-2024-001', 'TAX-987654', 'https://techcorp.example.com', 'Technology', '500-1000', 50000000.00),
('a0000000-0000-0000-0000-000000000003', 'FinServ Group', 'REG-2024-002', 'TAX-123456', 'https://finserv.example.com', 'Financial Services', '1000-5000', 250000000.00)
ON CONFLICT DO NOTHING;
-- Individual details
INSERT INTO individual_customers (customer_id, first_name, last_name, job_title, company_name) VALUES
('a0000000-0000-0000-0000-000000000002', 'Michael', 'Brown', 'Store Owner', 'Browns Retail Shop'),
('a0000000-0000-0000-0000-000000000004', 'Sarah', 'Davis', 'Professor', 'State University')
ON CONFLICT DO NOTHING;
-- Contact Information
INSERT INTO contact_information (customer_id, type, value, is_primary) VALUES
('a0000000-0000-0000-0000-000000000001', 'email', 'contact@techcorp.example.com', TRUE),
('a0000000-0000-0000-0000-000000000001', 'phone', '+1-555-0200', TRUE),
('a0000000-0000-0000-0000-000000000002', 'email', 'michael.brown@email.example.com', TRUE),
('a0000000-0000-0000-0000-000000000002', 'mobile', '+1-555-0201', TRUE),
('a0000000-0000-0000-0000-000000000003', 'email', 'info@finserv.example.com', TRUE),
('a0000000-0000-0000-0000-000000000003', 'phone', '+1-555-0202', TRUE),
('a0000000-0000-0000-0000-000000000004', 'email', 'sarah.davis@email.example.com', TRUE)
ON CONFLICT DO NOTHING;
-- Sample Leads
INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES
('c0010000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004',
'00000000-0000-0000-0000-000000000003', 'DataFlow Analytics', 'David Miller', 'david@dataflow.example.com', 'high', 70),
('c0010000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003',
'00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45)
ON CONFLICT DO NOTHING;
-- Sample Opportunities
INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES
('d0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005',
'00000000-0000-0000-0000-000000000003', 'TechCorp - Annual Renewal', 120000.00, 80, '2024-12-31'),
('d0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003',
'00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15')
ON CONFLICT DO NOTHING;
-- Sample Tasks
INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES
('e0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'd0010000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
'Prepare renewal proposal for TechCorp', 'c0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'),
('e0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'd0010000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
'Schedule FinServ executive meeting', 'c0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days')
ON CONFLICT DO NOTHING;
@@ -1,82 +0,0 @@
-- ============================================================================
-- 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);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
-- 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';
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
@@ -1,2 +0,0 @@
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;
@@ -1,87 +0,0 @@
-- 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');
@@ -1,13 +0,0 @@
-- AI Sales Assistant tables
CREATE TABLE IF NOT EXISTS ai_conversations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'sales',
message TEXT NOT NULL,
response TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id);
CREATE INDEX IF NOT EXISTS idx_ai_conversations_created ON ai_conversations(created_at DESC);
@@ -1,23 +0,0 @@
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
link TEXT,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE;
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
lead_assigned BOOLEAN NOT NULL DEFAULT TRUE,
lead_status BOOLEAN NOT NULL DEFAULT TRUE,
note_added BOOLEAN NOT NULL DEFAULT FALSE,
daily_digest BOOLEAN NOT NULL DEFAULT FALSE,
weekly_report BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -1,22 +0,0 @@
CREATE TABLE IF NOT EXISTS company_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_name VARCHAR(255) NOT NULL DEFAULT '',
company_email VARCHAR(255) NOT NULL DEFAULT '',
company_phone VARCHAR(50) NOT NULL DEFAULT '',
company_website VARCHAR(255) NOT NULL DEFAULT '',
company_address TEXT NOT NULL DEFAULT '',
updated_by UUID REFERENCES users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address)
VALUES ('Coastal IT Solutions', 'info@coastalit.com', '(555) 123-4567', 'https://coastalit.com', '123 Business Ave, Suite 100, San Francisco, CA 94105')
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS user_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
timezone VARCHAR(100) NOT NULL DEFAULT 'america-los_angeles',
date_format VARCHAR(10) NOT NULL DEFAULT 'mdy',
items_per_page INTEGER NOT NULL DEFAULT 20,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -1,4 +0,0 @@
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_id UUID;
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id) WHERE context_type IS NOT NULL;
@@ -1,34 +0,0 @@
CREATE TABLE IF NOT EXISTS facebook_accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
label VARCHAR(100) NOT NULL,
profile_path TEXT NOT NULL,
cookie_file TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_scrape_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_error_at TIMESTAMPTZ,
last_error_message TEXT,
consecutive_failures INT NOT NULL DEFAULT 0,
flagged BOOLEAN NOT NULL DEFAULT FALSE,
flagged_at TIMESTAMPTZ,
flagged_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fb_accounts_active ON facebook_accounts(is_active);
CREATE INDEX IF NOT EXISTS idx_fb_accounts_flagged ON facebook_accounts(flagged);
CREATE TABLE IF NOT EXISTS facebook_scrape_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
account_id UUID NOT NULL REFERENCES facebook_accounts(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
success BOOLEAN NOT NULL DEFAULT FALSE,
leads_found INT NOT NULL DEFAULT 0,
error_message TEXT,
detected_flag VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account ON facebook_scrape_logs(account_id, created_at DESC);
@@ -1,38 +0,0 @@
-- ============================================================================
-- CRM Database — Full Migration Runner
-- ============================================================================
-- Usage: psql -U postgres -d crm -f run_all.sql
-- ============================================================================
BEGIN;
\set ON_ERROR_STOP on
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
\i 001_schema.sql
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
\i 002_seed.sql
\echo '=== Running 003_chat.sql (Chat Tables) ==='
\i 003_chat.sql
\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ==='
\i 004_avatar_url.sql
\echo '=== Running 005_last_read_at.sql (Last Read At Column) ==='
\i 005_last_read_at.sql
\echo '=== Running 006_test_leads.sql (Test Lead Data) ==='
\i 006_test_leads.sql
\echo '=== Running 007_ai_features.sql (AI Features) ==='
\i 007_ai_features.sql
\echo '=== Running 008_notifications.sql (Notifications + Preferences) ==='
\i 008_notifications.sql
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\i 009_settings.sql
\echo '=== Migration Complete ==='
COMMIT;
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-71
View File
@@ -1,71 +0,0 @@
# Scrapers - Configuration & Status
## Facebook Scraper
**File:** `rust-ai/src/main.rs`
**Lines:** ~70-85
Target URL (line 72):
```rust
let url = "https://www.facebook.com/search/top/?q=need%20website%20create";
```
**Status:** Uses direct connection (no proxy) — your home IP. Facebook blocks datacenter IPs. May work from a residential connection.
**Test with curl:**
```
curl.exe -s "https://www.facebook.com/search/top/?q=need%20website%20create" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" --max-time 10 2>$null
```
**Expected log output when blocked:**
```
ERROR crm_ai: Facebook scraper error: error sending request for url (https://www.facebook.com/search/top/?q=need%20website%20create)
```
## Reddit Scraper
**File:** `rust-ai/src/main.rs`
**Lines:** ~90-120
Uses `old.reddit.com` (older design that allows scraping without captchas).
Search queries (currently 6, lines 93-100):
- `r/southafrica` — "need website", "web developer"
- Global — '"need a website"', "website quote"
- `r/forhire` — "website"
- `r/smallbusiness` — "website"
**Status:** Working. Reddit results appear as `INFO LEAD:` entries in the server log.
**Test with curl:**
```
curl.exe -s "https://old.reddit.com/r/southafrica/search?q=need+website&sort=new&restrict_sr=on&t=week" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
```
**Expected log output when working:**
```
INFO crm_ai: LEAD: [Post Title] -> https://old.reddit.com/r/.../...
```
## Background Loop
**File:** `rust-ai/src/main.rs`
**Lines:** ~370-383
Both scrapers run together every 60-180 seconds on a `spawn_blocking` thread.
## What You Need to Do
### Facebook
- Try running from your home PC instead — your residential IP may not be blocked
- If still blocked, you need residential proxies (BrightData, IPRoyal, Oxylabs)
- Configure them in the `PROXIES` array at line 25
### Reddit
- Works out of the box via `old.reddit.com`
- If it stops working, Reddit IP-blocked you — use proxies or switch to Playwright
### To add more search queries
Edit the `searches` array in `run_reddit_scraper()` (line 93).
-18
View File
@@ -1,18 +0,0 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: false,
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "ui-avatars.com",
},
],
},
}
export default nextConfig
File diff suppressed because it is too large Load Diff
-67
View File
@@ -1,67 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
"dev:next": "next dev -p 3006",
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
"dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && python main.py",
"build": "next build",
"start": "next start -p 3006",
"lint": "eslint"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.15.0",
"jose": "^6.2.3",
"lucide-react": "^0.468.0",
"next": "^15.0.7",
"next-themes": "^0.4.4",
"pg": "^8.21.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.0",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"vaul": "^1.1.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"concurrently": "^10.0.3",
"eslint": "^9",
"eslint-config-next": "15.0.4",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}
@@ -1,8 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

@@ -1,2 +0,0 @@
[target.x86_64-pc-windows-gnu]
linker = "C:\\Users\\Hannah Kaur Bagga\\.rustup\\toolchains\\stable-x86_64-pc-windows-gnu\\lib\\rustlib\\x86_64-pc-windows-gnu\\bin\\rust-lld.exe"
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "crm-ai"
version = "0.1.0"
edition = "2021"
description = "AI Sales Assistant backend for Coast IT CRM"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.5", features = ["cors"] }
dotenvy = "0.15"
rand = "0.8"
jsonwebtoken = "9"
-43
View File
@@ -1,43 +0,0 @@
# CRM AI Service — Self-Knowledge
## Identity
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals.
## Capabilities
- Give sales tips and strategies per job category
- Generate cold email and outreach templates
- Handle objections with proven rebuttals
- Analyse prospect behaviour and suggest next steps
- Remember past conversations via PostgreSQL (`ai_conversations` table)
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
## Architecture
```
User → Next.js → Rust (:3001) → Ollama (:11434)
PostgreSQL
```
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
## Facebook Scraper (in code but not yet active)
The Rust binary has proxy + user-agent rotation ready in `main.rs`.
To activate: call `run_facebook_scraper()` from the main loop.
Proxies and user agents are defined as constants at the top of `main.rs`.
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
4. You can also create entries here in `rust-ai/src/AI.md` at the code level
## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers
- Use short paragraphs and bullet points
- Never mention being an AI or language model
- If you don't know something, say so honestly
- Prioritise the user's role: salespeople need speed, admins need control
-670
View File
@@ -1,670 +0,0 @@
use axum::{
extract::State,
http::{HeaderMap, Method, StatusCode},
routing::{get, post},
Json, Router,
};
use tower_http::cors::{CorsLayer, AllowOrigin, Any};
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
use uuid::Uuid;
use rand::Rng;
use chrono::Timelike;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
// ── JWT Claims ────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct Claims {
#[serde(rename = "userId")]
user_id: String,
role: String,
}
fn verify_jwt(token: &str, secret: &str) -> Option<Claims> {
let key = DecodingKey::from_secret(secret.as_bytes());
let validation = Validation::new(Algorithm::HS256);
decode::<Claims>(token, &key, &validation).ok().map(|d| d.claims)
}
// ── Rate limiter ──────────────────────────────────────────────
struct RateLimiter {
buckets: Mutex<HashMap<String, Vec<u64>>>,
max_requests: usize,
window_secs: u64,
}
impl RateLimiter {
fn new(max_requests: usize, window_secs: u64) -> Self {
Self {
buckets: Mutex::new(HashMap::new()),
max_requests,
window_secs,
}
}
async fn check(&self, key: &str) -> bool {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let mut buckets = self.buckets.lock().await;
let timestamps = buckets.entry(key.to_string()).or_default();
timestamps.retain(|t| now - *t <= self.window_secs);
if timestamps.len() >= self.max_requests {
false
} else {
timestamps.push(now);
true
}
}
}
// ── Shared state ───────────────────────────────────────────────
struct AppState {
db: sqlx::PgPool,
ollama_url: String,
model: String,
jobs: Vec<Job>,
leads: Arc<Mutex<LeadStore>>,
http_client: reqwest::Client,
jwt_secret: String,
rate_limiter: RateLimiter,
}
#[derive(Debug, Clone, Serialize)]
struct Lead {
title: String,
url: String,
source: String,
found_at: u64,
author: String,
date: String,
content: String,
}
struct LeadStore {
leads: Vec<Lead>,
max_size: usize,
}
#[derive(Debug, Deserialize)]
struct ScrapeResponse {
success: bool,
leads: Vec<ScrapeLead>,
flagged: bool,
flag_reason: Option<String>,
error: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct ScrapeLead {
title: String,
url: String,
author: String,
date: String,
content: String,
source: Option<String>,
}
impl LeadStore {
fn new(max_size: usize) -> Self {
Self { leads: Vec::new(), max_size }
}
fn push(&mut self, lead: Lead) {
if !self.leads.iter().any(|l| l.url == lead.url) {
self.leads.insert(0, lead);
self.leads.truncate(self.max_size);
}
}
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
self.leads.iter()
.filter(|l| now.saturating_sub(l.found_at) <= max_age_secs)
.take(limit)
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Job {
job_title: String,
keywords: Vec<String>,
industry: String,
description: String,
}
#[derive(Debug, Deserialize)]
struct ChatRequest {
message: String,
}
#[derive(Debug, Serialize)]
struct ChatResponse {
response: String,
}
#[derive(Debug, Serialize)]
struct JobsResponse {
jobs: Vec<Job>,
}
#[derive(Debug, Serialize)]
struct HealthResponse {
status: String,
model: String,
}
// ── Ollama API types ───────────────────────────────────────────
#[derive(Debug, Serialize)]
struct OllamaChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct OllamaRequest {
model: String,
messages: Vec<OllamaChatMessage>,
stream: bool,
options: OllamaOptions,
}
#[derive(Debug, Serialize)]
struct OllamaOptions {
temperature: f32,
num_predict: u32,
}
#[derive(Debug, Deserialize)]
struct OllamaResponse {
message: Option<OllamaResponseMessage>,
}
#[derive(Debug, Deserialize)]
struct OllamaResponseMessage {
content: String,
}
// ── Helpers ────────────────────────────────────────────────────
fn truncate(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (StatusCode, String)> {
let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or("");
let token = auth_header.strip_prefix("Bearer ").unwrap_or("");
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
})?;
match claims.role.to_lowercase().as_str() {
"sales" | "admin" | "super_admin" => Ok(claims),
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
}
}
fn format_leads_output(leads: &[Lead]) -> String {
if leads.is_empty() {
return "No new requests found yet.".to_string();
}
leads
.iter()
.enumerate()
.map(|(i, l)| {
let author = if l.author.is_empty() { "Unknown" } else { &l.author };
let date = truncate(&l.date, 10);
format!(
"{}. {}\n {}\n {}\n {}",
i + 1,
author,
date,
l.title,
l.url
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
let job_list: Vec<String> = jobs
.iter()
.map(|j| format!("- {} ({}): {}", j.job_title, j.industry, j.description))
.collect();
let job_list_str = job_list.join("\n");
let lead_summary: Vec<String> = leads
.iter()
.map(|l| format!("{} | {} | {}", l.author, l.title, l.url))
.collect();
let lead_summary_str = if lead_summary.is_empty() {
"None yet.".to_string()
} else {
lead_summary.join("\n")
};
format!(
"You are a Sales AI Assistant for Coast IT CRM.\n\n\
Available job categories to target:\n{}\n\n\
Recent leads context:\n{}\n\n\
Rules:\n\
- When asked about leads, answer concisely under 150 words.\n\
- If asked to suggest a sales strategy, give brief actionable advice.\n\
- Be direct and professional. No fluff.",
job_list_str, lead_summary_str
)
}
// ── Chat handler ───────────────────────────────────────────────
async fn handle_chat(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
let claims = extract_claims(&headers, &state)?;
if !state.rate_limiter.check(&claims.user_id).await {
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
}
let msg_lower = req.message.to_lowercase();
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
let has_listing = msg_words.iter().any(|w| ["listings", "listing", "leads", "links", "lists"].contains(w));
let has_show = msg_lower.contains("show me") || msg_lower.contains("give me") || msg_lower.contains("pull");
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let base_url = "http://localhost:3008/scrape/facebook";
use std::fmt::Write;
let mut service_url = base_url.to_string();
if let Ok(Some((_, path))) = sqlx::query_as::<_, (uuid::Uuid, String)>(
"SELECT id, profile_path FROM facebook_accounts \
WHERE is_active = TRUE AND flagged = FALSE \
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
)
.fetch_optional(&state.db)
.await
{
let encoded: String = path.chars().map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(),
_ => format!("%{:02X}", c as u8),
}).collect();
write!(service_url, "?profile_path={}&force=true", encoded).unwrap();
info!("Calling Python scrape at: {}?profile_path=...&force=true", base_url);
} else {
warn!("No active Facebook account found for on-demand scrape");
}
let req_builder = state.http_client.post(&service_url);
match req_builder.send().await {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
info!("Python scrape response ({}): {} bytes", status, body.len());
if body.starts_with('{') {
match serde_json::from_str::<ScrapeResponse>(&body) {
Ok(scrape_resp) => {
info!("Scraped {} leads from Facebook", scrape_resp.leads.len());
if scrape_resp.leads.is_empty() && scrape_resp.error.is_some() {
warn!("Python returned error: {:?}", scrape_resp.error);
}
let mut store = state.leads.lock().await;
for item in &scrape_resp.leads {
store.push(Lead {
title: truncate(&item.title, 120),
url: item.url.clone(),
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
found_at: now,
author: truncate(&item.author, 60),
date: truncate(&item.date, 30),
content: truncate(&item.content, 300),
});
}
}
Err(e) => {
warn!("Failed to parse Python scrape response: {} body: {}", e, &body[..body.len().min(200)]);
}
}
} else {
warn!("Python returned non-JSON response: {}", &body[..body.len().min(200)]);
}
}
Err(e) => {
error!("Scraper request error: {} - URL: {}", e, base_url);
}
}
let recent_leads = state.leads.lock().await.recent(604800, 20);
let response = format_leads_output(&recent_leads);
let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&claims.role)
.bind(&req.message)
.bind(&response)
.execute(&state.db)
.await;
return Ok(Json(ChatResponse { response }));
}
let recent_leads = state.leads.lock().await.recent(604800, 20);
let system_prompt = build_system_prompt(&state.jobs, &recent_leads);
let message_text = req.message.clone();
let ollama_req = OllamaRequest {
model: state.model.clone(),
messages: vec![
OllamaChatMessage { role: "system".to_string(), content: system_prompt },
OllamaChatMessage { role: "user".to_string(), content: req.message.clone() },
],
stream: false,
options: OllamaOptions { temperature: 0.7, num_predict: 1024 },
};
let resp = state.http_client
.post(format!("{}/api/chat", state.ollama_url))
.json(&ollama_req)
.send()
.await
.map_err(|e| {
error!("Ollama request failed: {}", e);
(StatusCode::SERVICE_UNAVAILABLE, "AI service unavailable".to_string())
})?;
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
error!("Failed to parse Ollama response: {}", e);
(StatusCode::SERVICE_UNAVAILABLE, "AI response parse error".to_string())
})?;
let response_text = ollama_resp.message.map(|m| m.content).unwrap_or_default();
let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&claims.role)
.bind(&message_text)
.bind(&response_text)
.execute(&state.db)
.await;
Ok(Json(ChatResponse { response: response_text }))
}
// ── Jobs handler ───────────────────────────────────────────────
async fn handle_jobs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<JobsResponse>, (StatusCode, String)> {
let _claims = extract_claims(&headers, &state)?;
if !state.rate_limiter.check(&_claims.user_id).await {
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
}
Ok(Json(JobsResponse { jobs: state.jobs.clone() }))
}
// ── Health handler ─────────────────────────────────────────────
async fn handle_health(
State(state): State<Arc<AppState>>,
) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
model: state.model.clone(),
})
}
// ── Main ───────────────────────────────────────────────────────
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "crm_ai=info,tower_http=info".into()),
)
.init();
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
let jobs_path = std::env::var("JOBS_PATH").unwrap_or_else(|_| "data/ai/jobs.jsonl".to_string());
let jobs_content = fs::read_to_string(&jobs_path).unwrap_or_default();
let jobs: Vec<Job> = jobs_content.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect();
info!("Loaded {} job categories, model: {}, Ollama: {}", jobs.len(), model, ollama_url);
let db = PgPoolOptions::new()
.max_connections(20)
.connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Connected to PostgreSQL");
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
.expect("Failed to build HTTP client");
let lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
let state = Arc::new(AppState {
db,
ollama_url,
model,
jobs,
leads: lead_store.clone(),
http_client,
jwt_secret,
rate_limiter: RateLimiter::new(30, 60),
});
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list([
"http://localhost:3006".parse().unwrap(),
"http://127.0.0.1:3006".parse().unwrap(),
]))
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any);
let app = Router::new()
.route("/ai/chat", post(handle_chat))
.route("/ai/jobs", get(handle_jobs))
.route("/health", get(handle_health))
.layer(cors)
.with_state(state.clone());
let addr = format!("{}:{}", host, port);
info!("CRM AI server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("Failed to bind address");
let bg_leads = lead_store.clone();
let bg_db = state.db.clone();
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
{
Ok(c) => c,
Err(e) => {
error!("Failed to build background HTTP client: {} — scraper disabled", e);
return;
}
};
// Initial delay to let user on-demand requests take priority
tokio::time::sleep(Duration::from_secs(300)).await;
loop {
// 10% random cycle skip — makes pattern non-periodic
if rand::thread_rng().gen_range(0..100) < 10 {
info!("Skipping this scrape cycle (random 10% skip)");
let jitter = rand::thread_rng().gen_range(16200..19800);
tokio::time::sleep(Duration::from_secs(jitter)).await;
continue;
}
// Skip night hours (23:00 06:00)
let hour = chrono::Local::now().hour();
if hour < 6 || hour >= 23 {
info!("Night hours ({}) — skipping scrape", hour);
let jitter = rand::thread_rng().gen_range(16200..19800);
tokio::time::sleep(Duration::from_secs(jitter)).await;
continue;
}
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
// Pick next active un-flagged account
let account = sqlx::query_as::<_, (uuid::Uuid, String)>(
"SELECT id, profile_path FROM facebook_accounts \
WHERE is_active = TRUE AND flagged = FALSE \
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
)
.fetch_optional(&bg_db)
.await;
match account {
Ok(Some((account_id, profile_path))) => {
match client.post(&bg_url).query(&[("profile_path", &profile_path)]).send().await {
Ok(resp) => {
if resp.status().is_success() {
match resp.json::<ScrapeResponse>().await {
Ok(data) => {
let leads_count = data.leads.len() as i32;
if data.flagged {
let _ = sqlx::query(
"UPDATE facebook_accounts SET flagged = TRUE, flagged_at = NOW(), \
flagged_reason = $2, last_error_at = NOW(), \
last_error_message = $3, consecutive_failures = consecutive_failures + 1 \
WHERE id = $1"
)
.bind(account_id)
.bind(&data.flag_reason)
.bind(&data.error)
.execute(&bg_db)
.await;
warn!("Facebook account {} flagged: {:?}", account_id, data.flag_reason);
let reason = data.flag_reason.as_deref().unwrap_or("unknown");
let _ = sqlx::query(
"INSERT INTO notifications (user_id, type, title, description, link) \
SELECT id, 'warning', 'Facebook Account Flagged', \
$1 || ' - ' || COALESCE($2, 'unknown reason'), \
NULL \
FROM users u JOIN user_roles ur ON ur.user_id = u.id \
JOIN roles r ON r.id = ur.role_id \
WHERE r.name IN ('ADMIN', 'SUPER_ADMIN')"
)
.bind(&account_id.to_string())
.bind(reason)
.execute(&bg_db)
.await;
} else if data.success {
let _ = sqlx::query(
"UPDATE facebook_accounts SET last_scrape_at = NOW(), \
last_success_at = NOW(), consecutive_failures = 0, \
updated_at = NOW() WHERE id = $1"
)
.bind(account_id)
.execute(&bg_db)
.await;
let mut store = bg_leads.lock().await;
for item in &data.leads {
store.push(Lead {
title: truncate(&item.title, 120),
url: item.url.clone(),
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
found_at: now,
author: truncate(&item.author, 60),
date: truncate(&item.date, 30),
content: truncate(&item.content, 300),
});
}
info!("Scraped {} leads from Facebook account {}", leads_count, account_id);
} else {
// Increment failures; auto-flag if >= 3 consecutive
let _ = sqlx::query(
"UPDATE facebook_accounts SET last_error_at = NOW(), \
last_error_message = $2, consecutive_failures = consecutive_failures + 1, \
flagged = CASE WHEN consecutive_failures + 1 >= 3 THEN TRUE ELSE flagged END, \
flagged_reason = CASE WHEN consecutive_failures + 1 >= 3 THEN 'too_many_failures' ELSE flagged_reason END, \
flagged_at = CASE WHEN consecutive_failures + 1 >= 3 THEN NOW() ELSE flagged_at END, \
updated_at = NOW() WHERE id = $1"
)
.bind(account_id)
.bind(&data.error)
.execute(&bg_db)
.await;
warn!("Facebook scrape failed for account {}: {:?}", account_id, data.error);
}
let _ = sqlx::query(
"INSERT INTO facebook_scrape_logs \
(account_id, started_at, completed_at, success, leads_found, error_message, detected_flag) \
VALUES ($1, NOW() - interval '5 hours', NOW(), $2, $3, $4, $5)"
)
.bind(account_id)
.bind(data.success && !data.flagged)
.bind(leads_count)
.bind(&data.error)
.bind(&data.flag_reason)
.execute(&bg_db)
.await;
}
Err(e) => {
warn!("Failed to parse scraper JSON: {}", e);
}
}
} else {
warn!("Scraper returned status: {}", resp.status());
}
}
Err(e) => {
warn!("Scraper request failed: {}", e);
}
}
}
Ok(None) => {
info!("No active Facebook accounts available — skipping scrape cycle");
}
Err(e) => {
warn!("Failed to query Facebook accounts: {}", e);
}
}
let jitter = rand::thread_rng().gen_range(16200..19800); // 4.5h 5.5h
tokio::time::sleep(Duration::from_secs(jitter)).await;
}
});
axum::serve(listener, app)
.await
.expect("Server failed");
}
@@ -1,100 +0,0 @@
const express = require('express');
const { chromium } = require('playwright');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3007;
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.post('/scrape/indeed', async (req, res) => {
const results = [];
let browser;
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const queries = [
'need a website built',
'looking for someone to build my website',
'need web designer',
'looking for web developer',
'need help with my website',
'need ecommerce website',
'need wordpress website',
'looking for website designer',
];
for (const q of queries) {
const url = `https://www.indeed.com/jobs?q=${encodeURIComponent(q)}&sort=date`;
try {
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForTimeout(2000);
const items = await page.evaluate(() => {
const cards = document.querySelectorAll('.job_seen_beacon');
return Array.from(cards).slice(0, 8).map(card => {
const titleEl = card.querySelector('.jcs-JobTitle');
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
const snippetEl = card.querySelector('.job-snippet');
return {
title: titleEl?.textContent?.trim() || '',
url: titleEl?.href || '',
company: companyEl?.textContent?.trim() || '',
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
};
});
});
for (const item of items) {
if (item.title && item.url && !results.some(r => r.url === item.url)) {
results.push({
title: item.title,
url: item.url,
author: item.company,
date: '',
content: item.snippet,
source: 'indeed',
});
}
}
await page.close();
} catch (e) {
console.error(`Indeed failed ${q}:`, e.message);
}
}
await browser.close();
res.json(results.slice(0, 50));
} catch (e) {
if (browser) await browser.close().catch(() => {});
console.error('Indeed error:', e.message);
res.status(500).json({ error: e.message });
}
});
app.post('/scrape/all', async (req, res) => {
try {
const [indeed] = await Promise.allSettled([
fetch(`http://localhost:${PORT}/scrape/indeed`, { method: 'POST' }).then(r => r.json()).catch(() => []),
]);
const all = [
...(indeed.status === 'fulfilled' ? indeed.value : []),
];
console.log(`Scrape all: ${all.length} leads`);
res.json(all);
} catch (e) {
console.error('Scrape all error:', e.message);
res.status(500).json({ error: e.message });
}
});
app.listen(PORT, () => {
console.log(`Scraper service running on port ${PORT}`);
});
-872
View File
@@ -1,872 +0,0 @@
{
"name": "scraper-service",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scraper-service",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.48.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
@@ -1,13 +0,0 @@
{
"name": "scraper-service",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node index.js",
"install-playwright": "npx playwright install chromium"
},
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.48.0"
}
}
@@ -1,86 +0,0 @@
"use client"
import { useState, useCallback } from "react"
import { AIChat } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector"
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const handleJobSelect = useCallback((job: typeof selectedJob) => {
setSelectedJob(job)
}, [])
return (
<div className="flex h-[calc(100vh-3.5rem)]">
<div className="flex-1 flex flex-col min-w-0">
<div className="border-b border-[#2a2a35] px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
<Bot className="h-5 w-5 text-[#1BB0CE]" />
</div>
<div>
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
</div>
</div>
</div>
<div className="flex-1 flex">
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
<AIChat />
</div>
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
<div>
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Target className="h-3.5 w-3.5" />
Target Job
</h3>
<JobSelector onSelect={handleJobSelect} />
</div>
{selectedJob && (
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
<div className="flex items-center gap-1.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
</div>
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
<div className="flex flex-wrap gap-1">
{selectedJob.keywords.map((kw, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
{kw}
</span>
))}
</div>
</div>
)}
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
<Lightbulb className="h-3.5 w-3.5" />
Tips
</h4>
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for cold email templates for a specific job
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Request objection handling tips
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for outreach strategies per industry
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,109 +0,0 @@
"use client"
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 {
Users,
UserPlus,
PhoneCall,
Clock,
CheckCircle2,
TrendingUp,
ListFilter,
} from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
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 {
console.warn("Failed to fetch dashboard stats")
}
}
useEffect(() => {
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, 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 },
]
: []
return (
<div className="space-y-6 relative">
{/* Daily Bugle watermark */}
<div className="absolute top-12 right-4 pointer-events-none select-none z-0" style={{opacity: 0.15}}>
<span className="text-[96px] font-['Bangers',cursive] leading-none tracking-wider" style={{color: "#e62020"}}>
DAILY BUGLE
</span>
</div>
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[160px]">
<ListFilter className="mr-2 h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7days">Last 7 days</SelectItem>
<SelectItem value="30days">Last 30 days</SelectItem>
<SelectItem value="6months">Last 6 months</SelectItem>
<SelectItem value="12months">Last 12 months</SelectItem>
</SelectContent>
</Select>
</PageHeader>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
<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} monthlyBreakdown={stats.monthlyBreakdown} />
))
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
}
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} />
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div>
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
)
}
@@ -1,39 +0,0 @@
"use client"
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
import { ErrorBoundary } from "@/components/shared/error-boundary"
import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser()
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!user) return null
return <AppShell>{children}</AppShell>
}
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<UserProvider>
<NotificationProvider>
<ErrorBoundary>
<DashboardContent>{children}</DashboardContent>
</ErrorBoundary>
</NotificationProvider>
</UserProvider>
)
}
@@ -1,205 +0,0 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import Link from "next/link"
import { motion } from "framer-motion"
import { use } from "react"
import { PageHeader } from "@/components/shared/page-header"
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
import { NoteTimeline } from "@/components/notes/note-timeline"
import { NoteForm } from "@/components/notes/note-form"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
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, setLead] = useState<Lead | null>(null)
const [leadNotes, setLeadNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
const fetchNotes = useCallback(async () => {
const notesRes = await fetch(`/api/leads/${id}/notes`)
if (notesRes.ok) setLeadNotes(await notesRes.json())
}, [id])
useEffect(() => {
async function fetchData() {
try {
const [leadRes] = await Promise.all([
fetch(`/api/leads/${id}`),
fetchNotes(),
])
if (leadRes.ok) setLead(await leadRes.json())
} catch {
console.warn("Failed to fetch lead details")
}
setLoading(false)
}
fetchData()
}, [id, fetchNotes])
if (loading) {
return (
<div className="flex items-center justify-center h-[60vh]">
<p className="text-muted-foreground">Loading...</p>
</div>
)
}
if (!lead) {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-center">
<h2 className="text-2xl font-bold">Lead not found</h2>
<p className="mt-2 text-muted-foreground">The lead you are looking for does not exist.</p>
<Link href="/leads" className="mt-4 inline-block">
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Leads
</Button>
</Link>
</div>
</div>
)
}
return (
<div className="space-y-6">
<Link
href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to leads
</Link>
<PageHeader
title={
<div className="flex items-center gap-3">
<span>{lead.companyName}</span>
<LeadStatusBadge status={lead.status} />
</div>
}
description={lead.contactName}
>
<Select
value={lead.status}
onValueChange={async (v) => {
const previousStatus = lead.status
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
try {
const res = await fetch(`/api/leads/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: v }),
})
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
} catch {
setLead((prev) => prev ? { ...prev, status: previousStatus } : 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="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>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<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>
</div>
</div>
)
}
@@ -1,277 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Card, CardContent } from "@/components/ui/card"
import { ArrowLeft } from "lucide-react"
import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
contactName: z.string().min(1, "Contact name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
source: z.string().optional(),
description: z.string().optional(),
status: z.string(),
assignedUserId: z.string().optional(),
})
type LeadFormValues = z.infer<typeof leadFormSchema>
export default function CreateLeadPage() {
const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [])
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
defaultValues: {
companyName: "",
contactName: "",
email: "",
phone: "",
source: "",
description: "",
status: "open",
assignedUserId: "none",
},
})
async function onSubmit(values: LeadFormValues) {
setSaving(true)
try {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
}
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to create lead")
const data = await res.json()
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads")
} catch (e) {
console.error("Create lead error:", e)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6">
<Link
href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to leads
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight">Create New Lead</h1>
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
</div>
<Card>
<CardContent className="pt-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="companyName"
render={({ field }) => (
<FormItem>
<FormLabel>Company Name</FormLabel>
<FormControl>
<Input placeholder="Acme Corp" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactName"
render={({ field }) => (
<FormItem>
<FormLabel>Contact Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@acme.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input placeholder="(555) 123-4567" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="source"
render={({ field }) => (
<FormItem>
<FormLabel>Source</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select source" />
</SelectTrigger>
</FormControl>
<SelectContent>
{LEAD_SOURCES.map((source) => (
<SelectItem key={source} value={source}>
{source}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(LEAD_STATUSES).map(([key, { label }]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Brief description of the lead and their requirements..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="assignedUserId"
render={({ field }) => (
<FormItem>
<FormLabel>Assign To</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select user" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">Unassigned</SelectItem>
{users.filter((u) => u.active).map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.role})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Create Lead"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/leads")}
>
Cancel
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
)
}
@@ -1,57 +0,0 @@
"use client"
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 { 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)
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)
setLoading(true)
fetch(`/api/leads?${params.toString()}`)
.then((r) => r.json())
.then((data) => {
setLeadsData(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, [search, statusFilter, periodFilter])
return (
<div className="space-y-4">
<PageHeader
title="Leads"
description="Manage and track your sales leads"
/>
<div className="rounded-lg border bg-card">
<div className="p-4">
<LeadsTableToolbar
search={search}
onSearchChange={setSearch}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
periodFilter={periodFilter}
onPeriodFilterChange={setPeriodFilter}
onCreateClick={() => router.push("/leads/new")}
/>
</div>
<LeadsTable data={leadsData} loading={loading} />
</div>
</div>
)
}
@@ -1,143 +0,0 @@
"use client"
import { useRef } from "react"
import { PageHeader } from "@/components/shared/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { useUser } from "@/providers/user-provider"
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
import { toast } from "sonner"
export default function ProfilePage() {
const { user, updateAvatar } = useUser()
if (!user) return null
const fileInputRef = useRef<HTMLInputElement>(null)
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const validTypes = ["image/png", "image/jpeg"]
if (!validTypes.includes(file.type)) {
toast.error("Only PNG and JPEG files are allowed")
return
}
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 {
console.warn("Failed to save avatar")
toast.error("Failed to save avatar")
}
}
reader.readAsDataURL(file)
}
return (
<div className="space-y-6">
<PageHeader title="Profile" description="Your account information" />
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardContent className="flex flex-col items-center pt-8">
<div className="relative">
<Avatar className="h-24 w-24">
<AvatarImage src={user.avatar} />
<AvatarFallback className="text-2xl">{user.name.split(" ").map((n) => n[0]).join("")}</AvatarFallback>
</Avatar>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity hover:opacity-100"
>
<Camera className="h-6 w-6 text-white" />
</button>
<input
ref={fileInputRef}
type="file"
accept=".png,.jpeg,.jpg"
className="hidden"
onChange={handleAvatarChange}
/>
</div>
<h2 className="mt-4 text-xl font-semibold">{user.name}</h2>
<Badge variant="secondary" className="mt-1 capitalize">
{user.role}
</Badge>
<div className="mt-6 flex w-full flex-col gap-3 text-sm">
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
<Mail className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">{user.email}</span>
</div>
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
Joined {new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })}
</span>
</div>
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
<Shield className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground capitalize">{user.role} access</span>
</div>
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
<Activity className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">{user.active ? "Active" : "Inactive"}</span>
</div>
</div>
</CardContent>
</Card>
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Account Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Full Name</span>
<p className="text-sm font-medium">{user.name}</p>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Email</span>
<p className="text-sm font-medium">{user.email}</p>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Role</span>
<p className="text-sm font-medium capitalize">{user.role}</p>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Status</span>
<p className="text-sm font-medium">{user.active ? "Active" : "Inactive"}</p>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Member Since</span>
<p className="text-sm font-medium">
{new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">User ID</span>
<p className="text-sm font-medium font-mono">{user.id}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
@@ -1,43 +0,0 @@
"use client"
import { PageHeader } from "@/components/shared/page-header"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CompanySettingsForm } from "@/components/settings/company-settings-form"
import { UserPreferencesForm } from "@/components/settings/user-preferences-form"
import { ThemeSettings } from "@/components/settings/theme-settings"
import { NotificationSettings } from "@/components/settings/notification-settings"
import { Building2, User, Palette, Bell } from "lucide-react"
export default function SettingsPage() {
const tabs = [
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
]
return (
<div className="space-y-4">
<PageHeader
title="Settings"
description="Manage your CRM configuration and preferences"
/>
<Tabs defaultValue="company" className="space-y-6">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
<tab.component />
</TabsContent>
))}
</Tabs>
</div>
)
}
@@ -1,105 +0,0 @@
"use client"
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 [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 {
console.warn("Failed to fetch users in users page")
} 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">
<PageHeader
title="Users"
description="Manage team members and their roles"
>
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Add User
</Button>
</PageHeader>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
{stats.map((s) => (
<div key={s.label} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}
>
<UsersIcon className={`h-5 w-5 ${s.textColor}`} />
</div>
<div>
<p className="text-2xl font-bold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</div>
</div>
</div>
))}
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 pl-9"
/>
</div>
<div className="rounded-lg border bg-card">
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
</div>
<UserFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
onUserCreated={fetchUsers}
/>
</div>
)
}
@@ -1,8 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
// This route handler has a known issue with Next.js 15 fetch augmentation
// on this platform. The client-side code calls the AI server directly
// via browser fetch (CORS is open). This handler returns a fallback.
export async function POST(request: NextRequest) {
return NextResponse.json({ error: "AI service unavailable on server. Use client-side mode." }, { status: 503 })
}
@@ -1,20 +0,0 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { fetchJobs } from "@/lib/ai"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (!["sales", "admin", "super_admin"].includes(user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const jobs = await fetchJobs()
return NextResponse.json({ jobs })
} catch {
console.warn("Failed to fetch AI jobs in API route")
return NextResponse.json({ jobs: [] })
}
}
@@ -1,120 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import {
comparePassword,
getUserByEmail,
getUserByUsername,
mapDbUserToSessionUser,
recordLoginAttempt,
incrementFailedAttempts,
resetFailedAttempts,
isAccountLocked,
createSession,
} from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email, username, password } = await request.json()
const credential = email || username
if (!credential || !password) {
return NextResponse.json(
{ error: "Email/Username and password are required." },
{ status: 400 }
)
}
if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json(
{ error: "Credentials cannot be empty." },
{ status: 400 }
)
}
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
const userAgent = request.headers.get("user-agent") || null
// Try to find user by email first, then by username
let dbUser =
email || credential.includes("@")
? await getUserByEmail(credential)
: null
if (!dbUser) {
dbUser = await getUserByUsername(credential)
}
if (!dbUser) {
await recordLoginAttempt(
null,
credential,
ipAddress,
userAgent,
false,
"User not found"
)
return NextResponse.json(
{ error: "Invalid email/username or password." },
{ status: 401 }
)
}
const lockStatus = await isAccountLocked(dbUser)
if (lockStatus.locked) {
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
false,
lockStatus.reason
)
return NextResponse.json(
{ error: lockStatus.reason },
{ status: 423 }
)
}
const valid = await comparePassword(password, dbUser.password_hash)
if (!valid) {
await incrementFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
false,
"Invalid password"
)
return NextResponse.json(
{ error: "Invalid email/username or password." },
{ status: 401 }
)
}
await resetFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
true
)
await createSession(dbUser.id, dbUser.role_name)
const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 })
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
}
@@ -1,15 +0,0 @@
import { NextResponse } from "next/server"
import { destroySession } from "@/lib/auth"
export async function POST() {
try {
await destroySession()
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Logout error:", error)
return NextResponse.json(
{ error: "Logout failed." },
{ status: 500 }
)
}
}
@@ -1,18 +0,0 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
return NextResponse.json({ user }, { status: 200 })
} catch (error) {
console.error("Auth me error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
}
@@ -1,162 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
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 { searchParams } = new URL(_request.url)
const limit = parseInt(searchParams.get("limit") || "100", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const before = searchParams.get("before") || ""
// Verify user is a participant
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id]
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
let msgSql = `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`
const msgParams: any[] = [id]
if (before) {
msgSql += ` AND m.created_at < $2`
msgParams.push(before)
}
msgSql += ` ORDER BY m.created_at ASC`
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
msgParams.push(limit, offset)
const [msgResult, otherReadResult] = await Promise.all([
query(msgSql, msgParams),
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: avatarSvgUrl(row.sender_name),
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
// Verify user is a participant
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id]
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
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]
const senderName = `${user.firstName} ${user.lastName}`
const otherResult = await query(
`SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
[id, user.id],
)
for (const row of otherResult.rows) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
[row.user_id, `${senderName} sent a message`, id],
)
}
return NextResponse.json({
message: {
id: msg.id,
conversationId: id,
senderId: user.id,
senderName,
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" })
}
@@ -1,33 +0,0 @@
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],
)
await query(
`UPDATE notifications SET is_read = TRUE
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
[user.id, 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 })
}
}
@@ -1,131 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
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
LIMIT 50`,
[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: avatarSvgUrl(row.other_user_name),
},
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: avatarSvgUrl(other.name),
},
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()
}
@@ -1,213 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
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, userId?: string, isAdmin?: boolean) {
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
${isAdmin ? "" : "AND l.assigned_to = $3"}
ORDER BY l.created_at DESC`,
isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), userId]
)
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 isAdmin = user.role === "admin" || user.role === "super_admin"
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, user.id, isAdmin),
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
])
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: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : 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 })
}
}
@@ -1,86 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
const result = await query(
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
AND (assigned_to = $2 OR EXISTS (
SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $2 AND r.name IN ('ADMIN', 'SUPER_ADMIN')
))`,
[leadId, userId]
)
return result.rows.length > 0
}
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
if (!await checkLeadAccess(id, user.id)) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
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
if (!await checkLeadAccess(id, user.id)) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
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
LIMIT $2 OFFSET $3`,
[id, limit, offset]
)
const notes = result.rows.map((r: any) => ({
id: r.id,
leadId: id,
userId: r.user_id,
authorName: `${r.first_name} ${r.last_name}`,
authorAvatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
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 })
}
}
@@ -1,158 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
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 isAdmin = user.role === "admin" || user.role === "super_admin"
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
AND ($2 = true OR l.assigned_to = $3)`,
[id, isAdmin, user.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: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : 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 })
}
}
function statusToStageName(status: string): string {
switch (status) {
case "open": return "New"
case "contacted": return "Contacted"
case "pending": return "Qualified"
case "closed": return "Closed Won"
case "ignored": return "Closed Lost"
default: return "New"
}
}
export async function PATCH(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 isAdmin = user.role === "admin" || user.role === "super_admin"
// Verify access
const accessCheck = await query(
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
AND ($2 = true OR assigned_to = $3)`,
[id, isAdmin, user.id]
)
if (accessCheck.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) }
if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
if (body.phone !== undefined) { fields.push(`phone = $${idx++}`); values.push(body.phone) }
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
if (body.assignedUserId !== undefined) {
const isAdmin = user.role === "admin" || user.role === "super_admin"
if (!isAdmin) {
// non-admin cannot reassign
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
}
fields.push(`assigned_to = $${idx++}`)
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
}
if (body.status !== undefined) {
const stageName = statusToStageName(body.status)
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
if (stageResult.rows.length > 0) {
fields.push(`stage_id = $${idx++}`)
values.push(stageResult.rows[0].id)
}
}
if (body.score !== undefined) {
const score = Number(body.score)
if (!isFinite(score) || score < 0 || score > 100) {
return NextResponse.json({ error: "Score must be a number between 0 and 100" }, { status: 400 })
}
fields.push(`score = $${idx++}`)
values.push(score)
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
const sql = `UPDATE leads SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
const result = await query(sql, values)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
return NextResponse.json({ success: true, id: result.rows[0].id })
} catch (error) {
console.error("Lead PATCH error:", error)
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 })
}
}
@@ -1,190 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
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"
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const isAdmin = user.role === "admin" || user.role === "super_admin"
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++
}
if (!isAdmin) {
sql += ` AND l.assigned_to = $${paramIdx}`
params.push(user.id)
paramIdx++
}
sql += ` ORDER BY l.created_at DESC`
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
params.push(limit, offset)
paramIdx += 2
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: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : 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 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Non-admin users can only assign leads to themselves; admin/super_admin can assign to anyone
let assignedUserId = body.assignedUserId
if (!isAdmin) {
assignedUserId = user.id
} else if (assignedUserId === "none" || !assignedUserId) {
assignedUserId = null
}
const stageResult = await query(
"SELECT id FROM lead_stages WHERE name = $1",
[body.status === "open" ? "New" : "Contacted"]
)
const stageId = stageResult.rows[0]?.id || 1
const result = await query(
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING id`,
[
body.companyName,
body.contactName,
body.email,
body.phone || null,
body.description || null,
body.source || null,
stageId,
assignedUserId,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Leads POST error:", error)
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
const result = await query(
"UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id",
[id, isAdmin, user.id]
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Leads DELETE error:", error)
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 })
}
}
@@ -1,49 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function PATCH(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(
`UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 RETURNING id`,
[id, user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Notification PATCH error:", error)
return NextResponse.json({ error: "Failed to mark notification as read" }, { status: 500 })
}
}
export async function DELETE(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(
`DELETE FROM notifications WHERE id = $1 AND user_id = $2 RETURNING id`,
[id, user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Notification DELETE error:", error)
return NextResponse.json({ error: "Failed to dismiss notification" }, { status: 500 })
}
}
@@ -1,69 +0,0 @@
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 lead_assigned, lead_status, note_added, daily_digest, weekly_report
FROM notification_preferences
WHERE user_id = $1`,
[user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({
leadAssigned: true,
leadStatus: true,
noteAdded: false,
dailyDigest: false,
weeklyReport: true,
})
}
const r = result.rows[0]
return NextResponse.json({
leadAssigned: r.lead_assigned,
leadStatus: r.lead_status,
noteAdded: r.note_added,
dailyDigest: r.daily_digest,
weeklyReport: r.weekly_report,
})
} catch (error) {
console.error("Preferences GET error:", error)
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
await query(
`INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (user_id)
DO UPDATE SET lead_assigned = $2, lead_status = $3, note_added = $4,
daily_digest = $5, weekly_report = $6, updated_at = NOW()`,
[
user.id,
body.leadAssigned ?? true,
body.leadStatus ?? true,
body.noteAdded ?? false,
body.dailyDigest ?? false,
body.weeklyReport ?? true,
],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Preferences PATCH error:", error)
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
}
}
@@ -1,93 +0,0 @@
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 id, type, title, description, link, is_read, created_at, context_id, context_type
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50`,
[user.id],
)
const notifications = result.rows.map((r: any) => ({
id: r.id,
type: r.type,
title: r.title,
description: r.description,
link: r.link,
read: r.is_read,
timestamp: r.created_at,
contextId: r.context_id,
contextType: r.context_type,
}))
const unreadResult = await query(
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
[user.id],
)
return NextResponse.json({
notifications,
unreadCount: parseInt(unreadResult.rows[0].count, 10),
})
} catch (error) {
console.error("Notifications GET error:", error)
return NextResponse.json({ error: "Failed to load notifications" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { type, title, description, link, userId } = await request.json()
const isAdmin = user.role === "admin" || user.role === "super_admin"
const targetUserId = (userId && isAdmin) ? userId : user.id
const result = await query(
`INSERT INTO notifications (user_id, type, title, description, link)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, type, title, description, link, is_read, created_at`,
[targetUserId, type, title, description || null, link || null],
)
const notif = result.rows[0]
return NextResponse.json({
id: notif.id,
type: notif.type,
title: notif.title,
description: notif.description,
link: notif.link,
read: notif.is_read,
timestamp: notif.created_at,
}, { status: 201 })
} catch (error) {
console.error("Notifications POST error:", error)
return NextResponse.json({ error: "Failed to create notification" }, { status: 500 })
}
}
export async function PATCH() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
await query(
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
[user.id],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Notifications PATCH error:", error)
return NextResponse.json({ error: "Failed to mark all as read" }, { status: 500 })
}
}
@@ -1,68 +0,0 @@
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 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`)
const row = result.rows[0]
if (!row) {
return NextResponse.json({
companyName: "Coastal IT Solutions",
companyEmail: "info@coastalit.com",
companyPhone: "(555) 123-4567",
companyWebsite: "https://coastalit.com",
companyAddress: "123 Business Ave, Suite 100, San Francisco, CA 94105",
})
}
return NextResponse.json({
companyName: row.company_name || "",
companyEmail: row.company_email || "",
companyPhone: row.company_phone || "",
companyWebsite: row.company_website || "",
companyAddress: row.company_address || "",
})
} catch (error) {
console.error("Company settings GET error:", error)
return NextResponse.json({ error: "Failed to load company settings" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json()
await query(
`UPDATE company_settings SET
company_name = $1, company_email = $2, company_phone = $3,
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
[
body.companyName || "",
body.companyEmail || "",
body.companyPhone || "",
body.companyWebsite || "",
body.companyAddress || "",
user.id,
],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Company settings PATCH error:", error)
return NextResponse.json({ error: "Failed to save company settings" }, { status: 500 })
}
}
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.isActive !== undefined) {
fields.push(`is_active = $${idx++}`)
values.push(body.isActive)
}
if (body.label !== undefined) {
fields.push(`label = $${idx++}`)
values.push(body.label.trim())
}
if (body.profilePath !== undefined) {
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
values.push(body.profilePath.trim())
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
idx++
}
if (body.unflag === true) {
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
await query(
`UPDATE facebook_accounts SET ${fields.join(", ")} WHERE id = $${idx}`,
values
)
const updated = await query(
`SELECT id, label, profile_path, is_active, flagged, flagged_reason,
consecutive_failures, updated_at
FROM facebook_accounts WHERE id = $1`,
[id]
)
return NextResponse.json(updated.rows[0] || { success: true })
} catch (error) {
console.error("Facebook accounts PATCH error:", error)
return NextResponse.json({ error: "Failed to update Facebook account" }, { status: 500 })
}
}
@@ -1,66 +0,0 @@
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 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const accounts = await query(
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
fa.last_error_message, fa.consecutive_failures,
fa.flagged, fa.flagged_at, fa.flagged_reason,
fa.created_at, fa.updated_at,
COALESCE(sl.leads_found, 0) AS last_leads_found,
sl.success AS last_success,
sl.detected_flag AS last_detected_flag
FROM facebook_accounts fa
LEFT JOIN LATERAL (
SELECT leads_found, success, detected_flag
FROM facebook_scrape_logs
WHERE account_id = fa.id
ORDER BY created_at DESC
LIMIT 1
) sl ON TRUE
ORDER BY fa.created_at ASC`
)
return NextResponse.json(accounts.rows)
} catch (error) {
console.error("Facebook accounts GET error:", error)
return NextResponse.json({ error: "Failed to load Facebook accounts" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { label, profilePath } = await request.json()
if (!label?.trim() || !profilePath?.trim()) {
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
}
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
const result = await query(
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
VALUES ($1, $2, $3)
RETURNING id, label, profile_path, is_active, created_at`,
[label.trim(), profilePath.trim(), cookieFile]
)
return NextResponse.json(result.rows[0], { status: 201 })
} catch (error) {
console.error("Facebook accounts POST error:", error)
return NextResponse.json({ error: "Failed to create Facebook account" }, { status: 500 })
}
}
@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const accountId = searchParams.get("accountId")
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
let sql = `SELECT sl.id, sl.account_id, fa.label AS account_label,
sl.started_at, sl.completed_at, sl.success,
sl.leads_found, sl.error_message, sl.detected_flag,
sl.created_at
FROM facebook_scrape_logs sl
JOIN facebook_accounts fa ON fa.id = sl.account_id`
const params: any[] = []
let paramIdx = 1
if (accountId) {
sql += ` WHERE sl.account_id = $${paramIdx++}`
params.push(accountId)
}
sql += ` ORDER BY sl.created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`
params.push(limit, offset)
const result = await query(sql, params)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Facebook scrape logs GET error:", error)
return NextResponse.json({ error: "Failed to load scrape logs" }, { status: 500 })
}
}
@@ -1,60 +0,0 @@
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 timezone, date_format, items_per_page FROM user_preferences WHERE user_id = $1`,
[user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({
timezone: "america-los_angeles",
dateFormat: "mdy",
itemsPerPage: 20,
})
}
const r = result.rows[0]
return NextResponse.json({
timezone: r.timezone,
dateFormat: r.date_format,
itemsPerPage: r.items_per_page,
})
} catch (error) {
console.error("Preferences GET error:", error)
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
await query(
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id)
DO UPDATE SET timezone = $2, date_format = $3, items_per_page = $4, updated_at = NOW()`,
[
user.id,
body.timezone || "america-los_angeles",
body.dateFormat || "mdy",
body.itemsPerPage || 20,
],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Preferences PATCH error:", error)
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
}
}
@@ -1,36 +0,0 @@
import { NextResponse } from "next/server"
import os from "os"
import { getSessionUser } from "@/lib/auth"
let prevCpu = process.cpuUsage()
let prevTime = Date.now()
export async function GET() {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
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,
})
}
@@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super admins can delete users" }, { status: 403 })
}
const { id } = await params
if (id === sessionUser.id) {
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
}
await query(
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
[id]
)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Error deleting user:", error)
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 })
}
}
@@ -1,40 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
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 })
}
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
if (!allowed) {
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
}
// Approximate decoded size: base64 is ~4/3 of original
const base64Data = avatar.split(",")[1] || ""
const estimatedBytes = Math.round(base64Data.length * 0.75)
if (estimatedBytes > MAX_AVATAR_BYTES) {
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { 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 })
}
}
@@ -1,99 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
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.avatar_url,
r.name AS role
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL
ORDER BY u.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
)
const users = result.rows.map((row: any) => ({
id: row.id,
name: `${row.first_name} ${row.last_name}`,
email: row.email,
role: row.role.toLowerCase(),
active: row.active,
avatar: avatarSvgUrl(`${row.first_name} ${row.last_name}`),
createdAt: row.created_at,
}))
return NextResponse.json({ users }, { status: 200 })
} catch (error) {
console.error("Error fetching users:", error)
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super admins can create users" }, { status: 403 })
}
const { name, email, password, role, active } = await request.json()
if (!name || !email || !password || !role) {
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 })
}
const validRoles = ["sales", "admin", "super_admin", "dev"]
if (!validRoles.includes(role)) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
}
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
const username = email.split("@")[0]
const passwordHash = await hashPassword(password)
const result = await query(
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
)
const roleId = (
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
).rows[0]?.id
if (roleId) {
await query(
`INSERT INTO user_roles (user_id, role_id, assigned_by)
VALUES ($1, $2, $3)`,
[result.rows[0].id, roleId, sessionUser.id]
)
}
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error: any) {
console.error("Error creating user:", error)
if (error?.constraint === "uq_users_username" || error?.constraint === "uq_users_email") {
return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 })
}
return NextResponse.json({ error: error?.message || "Failed to create user" }, { status: 500 })
}
}
@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
try {
const currentUser = await getSessionUser()
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (currentUser.role !== "admin" && currentUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
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: avatarSvgUrl(row.name),
}))
return NextResponse.json({ users })
} catch (error) {
console.error("User search error:", error)
return NextResponse.json({ error: "Search failed" }, { status: 500 })
}
}
-837
View File
@@ -1,837 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@keyframes loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
:root {
--background: 210 40% 98%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 0 100% 40%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 217.2 91.2% 59.8%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 224.3 76.3% 48%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 0 100% 53%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 0 100% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.ocean {
--primary: 187 75% 42%;
--primary-foreground: 210 40% 98%;
--ring: 187 75% 42%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 187 75% 42%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 187 30% 94%;
--sidebar-accent-foreground: 187 50% 20%;
--sidebar-border: 187 20% 88%;
--sidebar-ring: 187 75% 42%;
}
.dark.ocean {
--primary: 187 75% 50%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 187 75% 50%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 187 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 187 75% 50%;
}
.forest {
--primary: 142 76% 36%;
--primary-foreground: 210 40% 98%;
--ring: 142 76% 36%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 142 76% 36%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 142 30% 94%;
--sidebar-accent-foreground: 142 50% 20%;
--sidebar-border: 142 20% 88%;
--sidebar-ring: 142 76% 36%;
}
.dark.forest {
--primary: 142 76% 44%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 142 76% 44%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 142 76% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 142 76% 44%;
}
.sunset {
--primary: 24 95% 53%;
--primary-foreground: 210 40% 98%;
--ring: 24 95% 53%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 24 95% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 24 30% 94%;
--sidebar-accent-foreground: 24 50% 20%;
--sidebar-border: 24 20% 88%;
--sidebar-ring: 24 95% 53%;
}
.dark.sunset {
--primary: 24 95% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 24 95% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 24 95% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 24 95% 58%;
}
.midnight {
--primary: 230 75% 55%;
--primary-foreground: 210 40% 98%;
--ring: 230 75% 55%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 230 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 230 30% 94%;
--sidebar-accent-foreground: 230 50% 20%;
--sidebar-border: 230 20% 88%;
--sidebar-ring: 230 75% 55%;
}
.dark.midnight {
--primary: 230 75% 62%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 230 75% 62%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 230 75% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 230 75% 62%;
}
.rose {
--primary: 346 77% 50%;
--primary-foreground: 210 40% 98%;
--ring: 346 77% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 346 77% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 346 30% 94%;
--sidebar-accent-foreground: 346 50% 20%;
--sidebar-border: 346 20% 88%;
--sidebar-ring: 346 77% 50%;
}
.dark.rose {
--primary: 346 77% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 346 77% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 346 77% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 346 77% 58%;
}
.amber {
--primary: 38 92% 50%;
--primary-foreground: 210 40% 98%;
--ring: 38 92% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 38 92% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 38 30% 94%;
--sidebar-accent-foreground: 38 50% 20%;
--sidebar-border: 38 20% 88%;
--sidebar-ring: 38 92% 50%;
}
.dark.amber {
--primary: 38 92% 56%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 38 92% 56%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 38 92% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 38 92% 56%;
}
.violet {
--primary: 262 83% 58%;
--primary-foreground: 210 40% 98%;
--ring: 262 83% 58%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 262 83% 58%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 262 30% 94%;
--sidebar-accent-foreground: 262 50% 20%;
--sidebar-border: 262 20% 88%;
--sidebar-ring: 262 83% 58%;
}
.dark.violet {
--primary: 262 83% 65%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 262 83% 65%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 262 83% 68%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 262 83% 65%;
}
.slate {
--primary: 215 20% 45%;
--primary-foreground: 210 40% 98%;
--ring: 215 20% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 215 20% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 215 20% 94%;
--sidebar-accent-foreground: 215 50% 20%;
--sidebar-border: 215 20% 88%;
--sidebar-ring: 215 20% 45%;
}
.dark.slate {
--primary: 215 20% 60%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 215 20% 60%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 215 20% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 215 20% 60%;
}
.ruby {
--primary: 351 85% 45%;
--primary-foreground: 210 40% 98%;
--ring: 351 85% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 351 85% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 351 30% 94%;
--sidebar-accent-foreground: 351 50% 20%;
--sidebar-border: 351 20% 88%;
--sidebar-ring: 351 85% 45%;
}
.dark.ruby {
--primary: 351 85% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 351 85% 52%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 351 85% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 351 85% 52%;
}
.ocean {
--primary: 187 75% 42%;
--primary-foreground: 210 40% 98%;
--ring: 187 75% 42%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 187 75% 42%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 187 30% 94%;
--sidebar-accent-foreground: 187 50% 20%;
--sidebar-border: 187 20% 88%;
--sidebar-ring: 187 75% 42%;
}
.dark.ocean {
--primary: 187 75% 50%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 187 75% 50%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 187 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 187 75% 50%;
}
.forest {
--primary: 142 76% 36%;
--primary-foreground: 210 40% 98%;
--ring: 142 76% 36%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 142 76% 36%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 142 30% 94%;
--sidebar-accent-foreground: 142 50% 20%;
--sidebar-border: 142 20% 88%;
--sidebar-ring: 142 76% 36%;
}
.dark.forest {
--primary: 142 76% 44%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 142 76% 44%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 142 76% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 142 76% 44%;
}
.sunset {
--primary: 24 95% 53%;
--primary-foreground: 210 40% 98%;
--ring: 24 95% 53%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 24 95% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 24 30% 94%;
--sidebar-accent-foreground: 24 50% 20%;
--sidebar-border: 24 20% 88%;
--sidebar-ring: 24 95% 53%;
}
.dark.sunset {
--primary: 24 95% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 24 95% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 24 95% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 24 95% 58%;
}
.midnight {
--primary: 230 75% 55%;
--primary-foreground: 210 40% 98%;
--ring: 230 75% 55%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 230 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 230 30% 94%;
--sidebar-accent-foreground: 230 50% 20%;
--sidebar-border: 230 20% 88%;
--sidebar-ring: 230 75% 55%;
}
.dark.midnight {
--primary: 230 75% 62%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 230 75% 62%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 230 75% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 230 75% 62%;
}
.rose {
--primary: 346 77% 50%;
--primary-foreground: 210 40% 98%;
--ring: 346 77% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 346 77% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 346 30% 94%;
--sidebar-accent-foreground: 346 50% 20%;
--sidebar-border: 346 20% 88%;
--sidebar-ring: 346 77% 50%;
}
.dark.rose {
--primary: 346 77% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 346 77% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 346 77% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 346 77% 58%;
}
.amber {
--primary: 38 92% 50%;
--primary-foreground: 210 40% 98%;
--ring: 38 92% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 38 92% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 38 30% 94%;
--sidebar-accent-foreground: 38 50% 20%;
--sidebar-border: 38 20% 88%;
--sidebar-ring: 38 92% 50%;
}
.dark.amber {
--primary: 38 92% 56%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 38 92% 56%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 38 92% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 38 92% 56%;
}
.violet {
--primary: 262 83% 58%;
--primary-foreground: 210 40% 98%;
--ring: 262 83% 58%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 262 83% 58%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 262 30% 94%;
--sidebar-accent-foreground: 262 50% 20%;
--sidebar-border: 262 20% 88%;
--sidebar-ring: 262 83% 58%;
}
.dark.violet {
--primary: 262 83% 65%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 262 83% 65%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 262 83% 68%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 262 83% 65%;
}
.slate {
--primary: 215 20% 45%;
--primary-foreground: 210 40% 98%;
--ring: 215 20% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 215 20% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 215 20% 94%;
--sidebar-accent-foreground: 215 50% 20%;
--sidebar-border: 215 20% 88%;
--sidebar-ring: 215 20% 45%;
}
.dark.slate {
--primary: 215 20% 60%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 215 20% 60%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 215 20% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 215 20% 60%;
}
.ruby {
--primary: 351 85% 45%;
--primary-foreground: 210 40% 98%;
--ring: 351 85% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 351 85% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 351 30% 94%;
--sidebar-accent-foreground: 351 50% 20%;
--sidebar-border: 351 20% 88%;
--sidebar-ring: 351 85% 45%;
}
.dark.ruby {
--primary: 351 85% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 351 85% 52%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 351 85% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 351 85% 52%;
}
@layer base {
* {
@apply border-border;
}
body {
@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;
width: 100%;
height: 100%;
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); }
-40
View File
@@ -1,40 +0,0 @@
import type { Metadata, Viewport } from "next"
import { Inter } from "next/font/google"
import { ThemeProvider } from "@/providers/theme-provider"
import { Toaster } from "@/components/ui/sonner"
import "./globals.css"
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
}
export const metadata: Metadata = {
title: "CRM",
description: "Customer Relationship Management System",
icons: {
icon: "/logo/CompanyMiniLogo.png",
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} min-h-screen antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
)
}
@@ -1,7 +0,0 @@
export default function LoginLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}
@@ -1,406 +0,0 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { COMPANY_NAME } from "@/lib/constants"
import { Eye, EyeOff, Loader2 } from "lucide-react"
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 [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)
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
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 (
<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
src="/logo/CompanyLogo.png"
alt={COMPANY_NAME}
className="h-44 w-auto object-contain"
/>
</div>
<div className="relative z-10 flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
Your Agency&apos;s{" "}
<span className="growth-word">Growth</span>{" "}
Starts Here
</h1>
<p className="mt-4 text-[13px] leading-[1.7] max-w-[360px] mx-auto body-text">
Manage leads, track conversions, and grow your web development business with our CRM.
</p>
<div className="stats-row">
<div className="stat">
<div className="stat-number">{counts.leads.toLocaleString()}</div>
<div className="stat-label">leads tracked</div>
</div>
<div className="stat">
<div className="stat-number">{counts.conversion}%</div>
<div className="stat-label">conversion lift</div>
</div>
<div className="stat">
<div className="stat-number">{counts.users}</div>
<div className="stat-label">active users</div>
</div>
</div>
</div>
</div>
<div className="relative z-10 testimonial" style={{ background: "rgba(255,255,255,0.7)", padding: "16px 20px 16px 16px", clipPath: "url(#testimonialClip)" }}>
<svg width="0" height="0" style={{ position: "absolute" }}>
<defs>
<clipPath id="testimonialClip" clipPathUnits="objectBoundingBox">
<path d="M0,0 L0.92,0 C0.96,0 1,0.03 1,0.08 C1,0.14 0.97,0.22 0.9,0.3 C0.85,0.36 0.83,0.42 0.83,0.5 C0.83,0.58 0.85,0.64 0.9,0.7 C0.97,0.78 1,0.86 1,0.92 C1,0.97 0.96,1 0.92,1 L0,1 Z" />
</clipPath>
</defs>
</svg>
<div className="transition-opacity duration-500" key={testimonialIndex}>
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
&ldquo;{testimonials[testimonialIndex].text}&rdquo;
</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" />
</div>
<div className="panel-divider" />
<div className="right-panel flex items-center justify-center p-10">
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
<div className="accent-line" />
<div className="w-full" style={{ maxWidth: 340 }}>
<h2 className="text-[24px] font-bold text-[#e8e8ef] tracking-[-0.3px] mb-[5px]">
Welcome back
</h2>
<p className="text-[13px] mb-[26px] subheading-text">
Sign in to your account to continue
</p>
{error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
{error}
</div>
)}
<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" onClick={() => alert("Please contact your administrator to reset your password.")}>
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"
/>
<span className="text-[13px] checkbox-text">
Remember me
</span>
</label>
<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{" "}
<button type="button" className="text-[#1BB0CE] hover:underline">
Terms of Service
</button>{" "}
and{" "}
<button type="button" className="text-[#1BB0CE] hover:underline">
Privacy Policy
</button>
</p>
</div>
</div>
</div>
)
}
@@ -1,30 +0,0 @@
"use client"
import Link from "next/link"
import { motion } from "framer-motion"
import { Button } from "@/components/ui/button"
import { FileQuestion } from "lucide-react"
export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center text-center"
>
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-muted">
<FileQuestion className="h-12 w-12 text-muted-foreground" />
</div>
<h1 className="mt-6 text-4xl font-bold">404</h1>
<p className="mt-2 text-lg text-muted-foreground">Page not found</p>
<p className="mt-1 text-sm text-muted-foreground">
The page you are looking for does not exist.
</p>
<Link href="/dashboard" className="mt-6">
<Button>Go Home</Button>
</Link>
</motion.div>
</div>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation"
export default function RootPage() {
redirect("/dashboard")
}
@@ -1,200 +0,0 @@
"use client"
import { useState, useRef, useEffect, Fragment } from "react"
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex)
return parts.map((part, i) => {
if (part.startsWith("http://") || part.startsWith("https://")) {
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a>
}
return <Fragment key={i}>{part}</Fragment>
})
}
interface ChatMessage {
role: "user" | "assistant"
content: string
}
export function AIChat() {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetch(`${AI_API}/ai/jobs`)
.then((r) => r.json())
.then((data) => {
if (data.jobs?.length) {
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages([
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `${n}`).join("\n")}\n\nWhat would you like to know?`,
},
])
} else {
setMessages([
{
role: "assistant",
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
},
])
}
})
.catch(() => {
setMessages([
{
role: "assistant",
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
},
])
})
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
const AI_API = process.env.NEXT_PUBLIC_AI_URL || "http://127.0.0.1:3001"
const checkOllama = async () => {
try {
const res = await fetch(`${AI_API}/health`)
const data = await res.json()
setOllamaStatus(data.status === "ok")
} catch {
setOllamaStatus(false)
}
}
useEffect(() => { checkOllama() }, [])
const sendMessage = async () => {
const msg = input.trim()
if (!msg || loading) return
setInput("")
setError("")
setMessages((prev) => [...prev, { role: "user", content: msg }])
setLoading(true)
try {
const res = await fetch(`${AI_API}/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: msg }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `Error ${res.status}`)
}
const data = await res.json()
setMessages((prev) => [...prev, { role: "assistant", content: data.response }])
} catch (err) {
const errMsg = err instanceof Error ? err.message : "AI service unavailable"
setError(errMsg)
setMessages((prev) => [
...prev,
{ role: "assistant", content: `⚠️ Error: ${errMsg}. Make sure Ollama is running with the model loaded.` },
])
} finally {
setLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
return (
<div className="flex flex-col h-full">
{ollamaStatus === false && (
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
<AlertCircle className="h-3.5 w-3.5 flex-none" />
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
{msg.role === "assistant" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
<Bot className="h-4 w-4 text-[#1BB0CE]" />
</div>
)}
<div
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
msg.role === "user"
? "bg-[#1BB0CE] text-white"
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
}`}
>
{linkifyText(msg.content)}
</div>
{msg.role === "user" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
<User className="h-4 w-4 text-white" />
</div>
)}
</div>
))}
{loading && (
<div className="flex gap-3 justify-start">
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
<Bot className="h-4 w-4 text-[#1BB0CE]" />
</div>
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-[#2a2a35] p-4">
{error && (
<div className="mb-2 text-xs text-red-400 flex items-center gap-1.5">
<AlertCircle className="h-3 w-3" />
{error}
</div>
)}
<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="flex-1 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] placeholder-[#6a6a75] resize-none outline-none focus:border-[#1BB0CE]/50"
/>
<button
type="button"
onClick={sendMessage}
disabled={loading || !input.trim()}
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</button>
</div>
</div>
</div>
)
}
@@ -1,74 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import { Briefcase, ChevronDown, Loader2 } from "lucide-react"
interface Job {
job_title: string
keywords: string[]
industry: string
description: string
}
interface JobSelectorProps {
onSelect: (job: Job | null) => void
}
export function JobSelector({ onSelect }: JobSelectorProps) {
const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<Job | null>(null)
useEffect(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
.then((data) => setJobs(data.jobs || []))
.catch(() => setJobs([]))
.finally(() => setLoading(false))
}, [])
const handleSelect = (job: Job) => {
setSelected(job)
setOpen(false)
onSelect(job)
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] hover:border-[#1BB0CE]/50 transition-colors"
>
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" />
<span className="flex-1 text-left truncate">
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
</span>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronDown className="h-3.5 w-3.5 text-[#6a6a75]" />}
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#15151e] border border-[#2a2a35] rounded-lg shadow-xl max-h-60 overflow-y-auto">
{jobs.map((job, i) => (
<button
key={i}
type="button"
onClick={() => handleSelect(job)}
className="w-full text-left px-3 py-2.5 text-sm text-[#c8c8d0] hover:bg-[#1a1a24] hover:text-[#e8e8ef] transition-colors border-b border-[#1a1a24] last:border-0"
>
<div className="font-medium">{job.job_title}</div>
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} {job.description}</div>
</button>
))}
{jobs.length === 0 && !loading && (
<div className="px-3 py-4 text-xs text-[#6a6a75] text-center">No job categories loaded</div>
)}
</div>
</>
)}
</div>
)
}
@@ -1,205 +0,0 @@
"use client"
import { useState } from "react"
import { motion } from "framer-motion"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface StatusData {
name: string
value: number
color: string
}
interface LeadStatusChartProps {
data: StatusData[]
}
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = ((deg - 90) * Math.PI) / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
function arcPath(cx: number, cy: number, oR: number, iR: number, start: number, end: number) {
const gap = 3
const s = start + gap / 2
const e = end - gap / 2
const o1 = polar(cx, cy, oR, s)
const o2 = polar(cx, cy, oR, e)
const i1 = polar(cx, cy, iR, e)
const i2 = polar(cx, cy, iR, s)
const lg = e - s > 180 ? 1 : 0
return `M${o1.x} ${o1.y} A${oR} ${oR} 0 ${lg} 1 ${o2.x} ${o2.y} L${i1.x} ${i1.y} A${iR} ${iR} 0 ${lg} 0 ${i2.x} ${i2.y}Z`
}
export function LeadStatusChart({ data }: LeadStatusChartProps) {
const [hov, setHov] = useState<number | null>(null)
const total = data.reduce((s, d) => s + d.value, 0)
if (total === 0) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Lead Status</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
No data for this period
</div>
</CardContent>
</Card>
)
}
let cum = 0
const segs = data.map((d) => {
const start = cum
const deg = (d.value / total) * 360
cum += deg
return { ...d, start, end: cum, deg, pct: Math.round((d.value / total) * 100) }
})
const active = hov !== null ? segs[hov] : null
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
className="h-full"
>
<Card className="h-full relative overflow-hidden">
{/* Spider watermark */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.05]">
<svg width="220" height="220" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="90" y1="0" x2="90" y2="180" stroke="#CC0000" strokeWidth="1" />
<line x1="0" y1="90" x2="180" y2="90" stroke="#CC0000" strokeWidth="1" />
<line x1="30" y1="30" x2="150" y2="150" stroke="#0033CC" strokeWidth="1" />
<line x1="150" y1="30" x2="30" y2="150" stroke="#0033CC" strokeWidth="1" />
<circle cx="90" cy="90" r="40" stroke="#CC0000" strokeWidth="1" fill="none" />
<circle cx="90" cy="90" r="60" stroke="#0033CC" strokeWidth="0.8" fill="none" strokeDasharray="4 4" />
<circle cx="90" cy="90" r="80" stroke="#CC0000" strokeWidth="0.5" fill="none" strokeDasharray="2 6" />
</svg>
</div>
<CardHeader>
<CardTitle>Lead Status</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col relative z-10">
<div className="flex flex-1 flex-col items-center justify-center">
{/* Donut */}
<svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}>
<defs>
{segs.map((s, i) => (
<radialGradient key={i} id={`chart-grad-${i}`} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={s.color} stopOpacity="1" />
<stop offset="100%" stopColor={s.color} stopOpacity="0.75" />
</radialGradient>
))}
</defs>
{/* Background ring */}
<circle cx="160" cy="160" r="105" fill="none" stroke="hsl(var(--muted))" strokeWidth="52" />
{/* Segments */}
{segs.map((s, i) => {
const isH = hov === i
const oR = isH ? 137 : 130
const iR = isH ? 73 : 80
return (
<path
key={i}
d={arcPath(160, 160, oR, iR, s.start, s.end)}
fill={`url(#chart-grad-${i})`}
opacity={hov !== null && !isH ? 0.3 : 1}
style={{
cursor: "pointer",
transition: "all 0.22s cubic-bezier(.4,0,.2,1)",
filter: isH ? `drop-shadow(0 0 10px ${s.color}99)` : "none",
}}
onMouseEnter={() => setHov(i)}
onMouseLeave={() => setHov(null)}
/>
)
})}
{/* Center circle */}
<circle cx="160" cy="160" r="66" fill="hsl(var(--card))" />
<circle cx="160" cy="160" r="66" fill="none" stroke="hsl(var(--border))" strokeWidth="1" />
{/* Center text */}
{active ? (
<>
<circle cx="160" cy="160" r="66" fill={active.color + "15"} />
<text x="160" y="148" textAnchor="middle" fill="hsl(var(--foreground))" fontSize="30" fontWeight="700" fontFamily="-apple-system,sans-serif">
{active.value}
</text>
<text x="160" y="166" textAnchor="middle" fill="hsl(var(--muted-foreground))" fontSize="12" fontFamily="-apple-system,sans-serif">
{active.name}
</text>
<text x="160" y="184" textAnchor="middle" fill={active.color} fontSize="13" fontWeight="600" fontFamily="-apple-system,sans-serif">
{active.pct}%
</text>
</>
) : (
<>
<text x="160" y="152" textAnchor="middle" fill="hsl(var(--foreground))" fontSize="34" fontWeight="700" fontFamily="-apple-system,sans-serif">
{total}
</text>
<text x="160" y="172" textAnchor="middle" fill="hsl(var(--muted-foreground))" fontSize="12" fontFamily="-apple-system,sans-serif">
Total Leads
</text>
</>
)}
</svg>
{/* Legend */}
<div className="mt-6 grid w-full grid-cols-2 gap-2">
{segs.map((s, i) => (
<div
key={i}
onMouseEnter={() => setHov(i)}
onMouseLeave={() => setHov(null)}
className="flex cursor-pointer items-center gap-2.5 rounded-xl p-2.5 transition-all duration-200"
style={{
background: hov === i ? `${s.color}18` : "hsl(var(--muted) / 0.3)",
border: `1px solid ${hov === i ? s.color + "50" : "hsl(var(--border))"}`,
gridColumn: i === segs.length - 1 && segs.length % 2 !== 0 ? "1 / -1" : undefined,
maxWidth: i === segs.length - 1 && segs.length % 2 !== 0 ? "50%" : undefined,
margin: i === segs.length - 1 && segs.length % 2 !== 0 ? "0 auto" : undefined,
width: i === segs.length - 1 && segs.length % 2 !== 0 ? "100%" : undefined,
}}
>
<div
className="h-2.5 w-2.5 shrink-0 rounded-full transition-shadow duration-200"
style={{
background: s.color,
boxShadow: hov === i ? `0 0 8px ${s.color}` : "none",
}}
/>
<div className="min-w-0 flex-1">
<div
className="text-xs font-medium transition-colors duration-200"
style={{ color: hov === i ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))" }}
>
{s.name}
</div>
<div className="mt-0.5 text-[11px]" style={{ color: "hsl(var(--muted-foreground) / 0.7)" }}>
{s.value} &middot; {s.pct}%
</div>
</div>
<div
className="text-xs font-semibold transition-colors duration-200"
style={{ color: hov === i ? s.color : "hsl(var(--muted-foreground) / 0.5)" }}
>
{s.pct}%
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</motion.div>
)
}
@@ -1,311 +0,0 @@
"use client"
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
leads: number
closed: number
}
interface LeadsPerMonthChartProps {
data: IntervalData[]
}
const NEW_LEADS = "#CC0000"
const CLOSED = "#0033CC"
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 {
console.warn("Failed to fetch year data in leads per month chart")
}
}
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 }
const chartW = width - padding.left - padding.right
const chartH = height - padding.top - padding.bottom
const maxVal = useMemo(() => {
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)
}, [chartData])
const ticks = useMemo(() => {
const steps = 4
return Array.from({ length: steps + 1 }, (_, i) => Math.round((maxVal / steps) * i))
}, [maxVal])
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 ? chartData[active] : null
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
const currentYear = new Date().getFullYear()
if (chartData.length === 0) {
return (
<Card className="h-full">
<CardHeader>
<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 {year}
</div>
</CardContent>
</Card>
)
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="h-full"
>
<Card className="h-full">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Leads Per Month</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{totalNew} new &middot; {totalClosed} closed &middot; {closeRate}% close rate
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: NEW_LEADS }} />
<span className="text-xs text-muted-foreground">New Leads</span>
</div>
<div className="flex items-center gap-1.5">
<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>
<CardContent className="flex flex-1 flex-col">
<div className="relative flex-1">
<svg
viewBox={`0 0 ${width} ${height}`}
width="100%"
height="100%"
className="block overflow-visible"
>
<defs>
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FF1111" />
<stop offset="100%" stopColor={NEW_LEADS} />
</linearGradient>
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1144FF" />
<stop offset="100%" stopColor={CLOSED} />
</linearGradient>
<filter id="shadowNew">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={NEW_LEADS} floodOpacity="0.4" />
</filter>
<filter id="shadowClosed">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={CLOSED} floodOpacity="0.4" />
</filter>
</defs>
<g transform={`translate(${padding.left}, ${padding.top})`}>
{/* Grid lines */}
{ticks.map((t, i) => (
<g key={i}>
<line
x1={0}
x2={chartW}
y1={yScale(t)}
y2={yScale(t)}
stroke="hsl(var(--border))"
strokeDasharray={t === 0 ? "0" : "3 5"}
strokeWidth={1}
/>
<text
x={-12}
y={yScale(t)}
fill="hsl(var(--muted-foreground))"
fontSize={12}
textAnchor="end"
dominantBaseline="middle"
>
{t}
</text>
</g>
))}
{/* Bars */}
{chartData.map((d, i) => {
const groupX = i * groupW
const isActive = active === i
const newH = chartH - yScale(d.leads)
const closedH = chartH - yScale(d.closed)
return (
<g
key={d.label}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: "pointer" }}
>
<rect
x={groupX}
y={0}
width={groupW}
height={chartH}
fill={isActive ? "hsl(var(--muted) / 0.5)" : "transparent"}
rx={6}
/>
<rect
x={groupX + groupW / 2 - barW - gap / 2}
y={yScale(d.leads)}
width={barW}
height={newH}
rx={4}
fill="url(#newLeadsGrad)"
filter={isActive ? "url(#shadowNew)" : undefined}
opacity={hovered !== null && !isActive ? 0.35 : 1}
style={{ transition: "opacity 0.15s ease" }}
/>
<rect
x={groupX + groupW / 2 + gap / 2}
y={yScale(d.closed)}
width={barW}
height={closedH}
rx={4}
fill="url(#closedGrad)"
filter={isActive ? "url(#shadowClosed)" : undefined}
opacity={hovered !== null && !isActive ? 0.35 : 1}
style={{ transition: "opacity 0.15s ease" }}
/>
<text
x={groupX + groupW / 2}
y={chartH + 26}
fill={isActive ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}
fontSize={12.5}
fontWeight={isActive ? 600 : 400}
textAnchor="middle"
>
{d.label}
</text>
</g>
)
})}
</g>
</svg>
{activeDatum && (
<div
className="pointer-events-none absolute top-0"
style={{
left: `${((active! + 0.5) / chartData.length) * 100}%`,
transform: active! > chartData.length - 2
? "translate(-100%, 0)"
: "translate(-12%, 0)",
transition: "left 0.15s ease",
}}
>
<div
className="min-w-[150px] rounded-xl border p-3 shadow-xl"
style={{
background: "hsl(var(--popover))",
borderColor: "hsl(var(--border))",
}}
>
<div className="mb-1.5 text-xs font-semibold" style={{ color: "hsl(var(--foreground))" }}>
{activeDatum.label}
</div>
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: NEW_LEADS }} />
New Leads
</span>
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.leads}</span>
</div>
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: CLOSED }} />
Closed
</span>
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.closed}</span>
</div>
<div
className="mt-1.5 border-t pt-1.5 text-[11px]"
style={{ borderColor: "hsl(var(--border))", color: "hsl(var(--muted-foreground))" }}
>
{activeDatum.leads > 0
? `${Math.round((activeDatum.closed / activeDatum.leads) * 100)}% close rate`
: "No leads"}
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
)
}
@@ -1,97 +0,0 @@
"use client"
import Link from "next/link"
import { motion } from "framer-motion"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Lead } from "@/types"
import { ArrowRight } from "lucide-react"
const statusStyles: Record<string, string> = {
open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
pending: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
closed: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
ignored: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
}
interface RecentLeadsTableProps {
leads: Lead[]
}
export function RecentLeadsTable({ leads }: RecentLeadsTableProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Leads</CardTitle>
<Link
href="/leads"
className="flex items-center gap-1 text-sm text-primary hover:underline"
>
View all <ArrowRight className="h-3 w-3" />
</Link>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">Company</th>
<th className="pb-3 font-medium">Contact</th>
<th className="hidden pb-3 font-medium md:table-cell">Status</th>
<th className="hidden pb-3 font-medium lg:table-cell">Assigned</th>
<th className="hidden pb-3 font-medium sm:table-cell">Date</th>
</tr>
</thead>
<tbody>
{leads.map((lead, i) => (
<motion.tr
key={lead.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.03 }}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="py-3">
<Link href={`/leads/${lead.id}`} className="font-medium hover:text-primary">
{lead.companyName}
</Link>
</td>
<td className="py-3 text-muted-foreground">{lead.contactName}</td>
<td className="hidden py-3 md:table-cell">
<Badge variant="outline" className={statusStyles[lead.status]}>
{lead.status.charAt(0).toUpperCase() + lead.status.slice(1)}
</Badge>
</td>
<td className="hidden py-3 lg:table-cell">
{lead.assignedUser ? (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={lead.assignedUser.avatar} />
<AvatarFallback>{lead.assignedUser.name[0]}</AvatarFallback>
</Avatar>
<span className="text-muted-foreground">{lead.assignedUser.name}</span>
</div>
) : (
<span className="text-muted-foreground/50">Unassigned</span>
)}
</td>
<td className="hidden py-3 text-muted-foreground sm:table-cell">
{new Date(lead.createdAt).toLocaleDateString()}
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</motion.div>
)
}
@@ -1,17 +0,0 @@
"use client"
export function StatCardSkeleton() {
return (
<div className="col-span-full flex flex-col items-center justify-center py-20">
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
THWIP!
</p>
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-3">
Loading your data...
</p>
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
<div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
</div>
</div>
)
}
@@ -1,234 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { LucideIcon } from "lucide-react"
interface StatCardProps {
title: string
value: string | number
icon: LucideIcon
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 cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
}
function computeGoal(max: number): number {
if (max <= 0) return 100
return Math.ceil(max / 100) * 100 || 100
}
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
}
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)
const target = typeof value === "number" ? value : 0
useEffect(() => {
if (!isNumeric) return
const duration = 1000
const steps = 40
const increment = target / steps
let current = 0
const timer = setInterval(() => {
current += increment
if (current >= target) {
setDisplay(target)
clearInterval(timer)
} else {
setDisplay(Math.floor(current))
}
}, duration / steps)
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
const isRed = index % 2 === 0
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="relative"
>
{/* Web overlay decorative */}
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.06]">
<svg width="100%" height="100%" viewBox="0 0 180 180" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wl-${i}`} x1="0" y1={i * 36} x2="180" y2={i * 36} stroke="#CC0000" strokeWidth="0.5" />
))}
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wc-${i}`} x1={i * 36} y1="0" x2={i * 36} y2="180" stroke="#CC0000" strokeWidth="0.5" />
))}
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wd-${i}`} x1="0" y1={i * 36} x2={180 - i * 36} y2="180" stroke="#0033CC" strokeWidth="0.5" />
))}
</svg>
</div>
{/* Comic action lines on hover */}
<div className="absolute -inset-2 pointer-events-none z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<svg width="100%" height="100%" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="100" y1="0" x2="100" y2="20" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="100" y1="180" x2="100" y2="200" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="0" y1="100" x2="20" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="180" y1="100" x2="200" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="30" y1="30" x2="44" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="170" y1="30" x2="156" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="30" y1="170" x2="44" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="170" y1="170" x2="156" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="100" cy="100" r="90" stroke="#CC0000" strokeWidth="0.5" strokeDasharray="4 4" opacity="0.4" />
<circle cx="100" cy="100" r="80" stroke="#0033CC" strokeWidth="0.5" strokeDasharray="3 5" opacity="0.3" />
</svg>
</div>
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
{/* Red/Blue gradient top strip */}
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
<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>
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
{isNumeric ? display : value}
</p>
{description && (
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
)}
{trend && (
<span className={cn(
"inline-flex items-center gap-0.5 text-xs font-semibold",
trend.up ? "text-emerald-500" : "text-rose-500"
)}>
{trend.up ? "↑" : "↓"} {trend.pct}%
</span>
)}
</div>
<div className={`flex h-12 w-12 items-center justify-center rounded-xl shrink-0 ${color.bg}`}>
<Icon className={`h-6 w-6 ${color.text}`} />
</div>
</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" && 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-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
</div>
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
</div>
)}
</CardContent>
<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>
)
}

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