Compare commits

...

60 Commits

Author SHA1 Message Date
JCBSComputer 85d6cdfb01 Setup.md Page fixed
Build & Auto-Repair / build (push) Has been cancelled
2026-07-06 09:18:29 +02:00
JCBSComputer 4035f5fad4 Fixed Resources, Added Error Logging/Messages, Fixed Pumkin Spice Background
Build & Auto-Repair / build (push) Has been cancelled
2026-07-06 09:13:45 +02:00
JCBSComputer dbec6c0851 Target Job AI Fixed. Now works and find leads
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 16:09:50 +02:00
JCBSComputer cfc69d47d3 Fixed AI. Optimized. Colour/Theme fixed. Added Forgot Password. Cuba theme — Added to client portal settings. Topbar search. Search, loading states
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 11:46:37 +02:00
JCBSComputer 51ec96ce7b Merge remote-tracking branch 'caitlin/main' into main
Build & Auto-Repair / build (push) Has been cancelled
- Adopt color theme management in website-theme-provider
- FOUC-prevention inline script in layout.tsx
- Facebook scraper search in ai-assistant page
- addAssistantMessage + timestamp cleanup in ai-chat
- New scripts (run-migrations.mjs), browser-use-service, job-selector
- Keep deleted-by-theirs files: campaigns, projects, proposals, client portal
2026-07-03 11:07:42 +02:00
JCBSComputer 7820896a35 Luminous added to Client Portal
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 11:01:25 +02:00
JCBSComputer a446a19760 Fixed Themes colors
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 10:39:04 +02:00
JCBSComputer dd56014318 Optimization FIxed/ Fixed Olama to run on startup
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 10:12:09 +02:00
JCBSComputer 9df7475f35 Fixed Black & White Theme. Text is more readable
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 17:15:25 +02:00
Rene 12115644e9 Fix Cuba theme: use body::before background with Malecón photo + transparency overrides
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 17:14:57 +02:00
Rene d1c62d940f Merge remote + add Cuba theme with classic car at golden hour
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 17:01:12 +02:00
Rene a7f3058668 Add Cuba theme: classic car on playa at golden hour
- New cuba-theme.css with warm sunset HSL palette (oranges, turquoise,
  golden hour tones) and classic Cuban car background image
- cuba-tokens.json with full color definitions and chart config
- Registered in themeClasses provider, layout.tsx import, and settings UI
2026-07-02 16:58:46 +02:00
JCBSComputer a1fef1d8bb Fixed Forest Theme
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 16:45:11 +02:00
Uwais 5d315bed22 feat: add forest minimal theme with light/dark botanical SVG backgrounds
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 16:34:47 +02:00
JCBSComputer abcef070c3 Pinned conversations no longer move on click or new messages
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 16:13:49 +02:00
JCBSComputer 211d026fb8 Add conversation pinning: pinned chats stay at top of sidebar
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 16:09:07 +02:00
caitlin dd6767980a search button theme fix in ai assistant 2026-07-02 16:04:53 +02:00
JCBSComputer 4c732c850f Fix: missing DB columns (ml_score, capacity_hours) + CSS escape fix + SQL error logging
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 15:58:40 +02:00
Yukon-Boop aabdbf9b91 Luminous theme (PATCH)
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 14:56:12 +02:00
Yukon-Boop fc099c6d00 New Theme (Luminous)
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 14:27:27 +02:00
Ace c1c4afadbb Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-07-02 13:26:58 +02:00
Ace 37679a7a60 Added Ai load fix 2026-07-02 13:26:56 +02:00
user 0a4988156f B/W theme: distinguishable chart greys, hover glow, avatar PFP desaturation
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 10:12:18 +02:00
Hannah_Bagga c0cb715ced ai assistant page fixed 2026-07-01 21:07:42 +02:00
JCBSComputer 53ae0944be Added Delete Files for Proposals & Quotes
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 16:54:39 +02:00
Jaundre 956e2697c9 Update README.md
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 16:38:43 +02:00
JCBSComputer 4a33ca0368 Fixed AI Auto Launch, Fixed Client Login not working
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 16:36:48 +02:00
Rene 7606af04ab Fix splash page /status endpoint for removed scraper service
Build & Auto-Repair / build (push) Has been cancelled
- Remove scraper health check (port 3008) since dev:browser-use
  was removed from dev:start; always report scraper as ready
- Add checkPort helper with localhost fallback for frontend
  check to handle Windows IPv6/IPv4 resolution issues
2026-07-01 16:10:38 +02:00
Rene 26319281d4 Fix splash page loop when scraper service is unavailable
Build & Auto-Repair / build (push) Has been cancelled
Skip browser/Facebook detection in /setup/status when port 3008
is not reachable, so first_run only depends on env + Ollama.
This prevents the setup wizard from showing endlessly when
browser-use service is not running.
2026-07-01 16:05:22 +02:00
Rene 183d6c629d Remove crash-prone dev:repair and dev:browser-use from dev:start
Build & Auto-Repair / build (push) Has been cancelled
These two processes exit with code 1 (Python not found / watch mode crash)
and are non-essential for the CRM. The core services needed are:
AI server (3001), Signaling (3007), and Next.js (3006).
2026-07-01 15:55:18 +02:00
JCBSComputer 6f622b6329 Add client portal themes (Cyberpunk etc.), settings page, semantic Tailwind classes
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 15:47:44 +02:00
Uwais ef2e12ec5e fix: force high-contrast user name text in resource planning cards
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 15:45:29 +02:00
Rene a02f013f38 Replace native selects with shadcn Select across all pages
Build & Auto-Repair / build (push) Has been cancelled
- lead-scoring-settings.tsx: Feature type dropdown
- bug-report-modal.tsx: Severity dropdown
- client-portal/support/page.tsx: Severity dropdown
- time-tracking/page.tsx: Timer project + manual entry project dropdowns
- proposals/new/page.tsx: Lead + customer dropdowns
2026-07-01 15:39:00 +02:00
Rene 2979078edd Fix customer/owner dropdown readability in New Project dialog
Build & Auto-Repair / build (push) Has been cancelled
Replace native <select> elements with shadcn Select component
so dropdown options use the theme's styled popover colors
2026-07-01 15:32:31 +02:00
Rene 3fe32d923e Add descriptive error messages across all API routes and toast notifications on client pages
Build & Auto-Repair / build (push) Has been cancelled
- 86 catch blocks in 49 API route files now return the actual error message via error.message
- 14 campaign/lead/config catch blocks that lacked console.error now log errors
- 17 client pages/components now show toast.error notifications on API failures
- Silent .catch(() => {}) and finally-only try blocks now surface errors to users
2026-07-01 15:20:30 +02:00
Hannah_Bagga 1269f6cce8 Revert "Removed quick tips, recent prompts Messages today, Tokens used"
This reverts commit 370f935cbb.
2026-07-01 14:36:47 +02:00
Uwais 727dcddf37 Update cyberpunk theme: light mode overrides synced to dark mode colors
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 14:34:59 +02:00
Hannah_Bagga 370f935cbb Removed quick tips, recent prompts Messages today, Tokens used 2026-07-01 14:24:32 +02:00
JCBSComputer 9bfd4e47ee Added Client Portal(Working)
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:34:58 +02:00
Rene 00bda54695 Add Lead Scoring ML + Email Sequences/Drip Campaigns
Build & Auto-Repair / build (push) Has been cancelled
Lead Scoring:
- lead_scoring_config table with configurable feature weights
- Scoring engine: boolean, keyword, and range-based feature scoring
- Settings tab: configure scoring features, run scoring on all leads
- ml_score + ml_score_details columns on leads table
- API: /api/leads/score, /api/leads/score/config
- mlScore returned in lead detail and list APIs

Email Campaigns:
- email_campaigns, email_campaign_steps, subscribers, logs tables
- Campaign list page with status badges and subscriber counts
- Campaign detail with step management, subscriber search/add, send
- Drip logic: steps with configurable delay days, template variables
- Variable replacement ({{name}}, {{email}}) in email bodies
- Sidebar: Campaigns nav item
2026-07-01 13:33:24 +02:00
Rene 963ea1d1b8 Revert "Add CI/CD pipeline (Gitea Actions)"
Build & Auto-Repair / build (push) Has been cancelled
This reverts commit eef290a20a.
2026-07-01 13:20:06 +02:00
Rene eef290a20a Add CI/CD pipeline (Gitea Actions)
Build & Auto-Repair / build (push) Has been cancelled
CI / build (push) Has been cancelled
Runs on push/PR to main: npm ci -> tsc --noEmit -> next build
Also fixes eslint flat config imports and adds typecheck script
2026-07-01 13:17:33 +02:00
Ace faf9dd551a Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 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 2026-07-01 13:14:35 +02:00
Hannah_Bagga bc422edcf7 colour scheme problem fixed 2026-07-01 13:13:31 +02:00
Rene ef75d16bf4 Add Resource Planning: team capacity, utilisation, workload overview
Build & Auto-Repair / build (push) Has been cancelled
- Migration 024: adds capacity_hours column to users (default 40h/week)
- /api/resource-planning: aggregates hours from time_entries, open tasks,
  active projects per user with utilisation calculations
- /resource-planning page: overview stats cards, per-member capacity bars
  with colour-coded status (green=available, yellow=busy, orange=near
  capacity, red=overloaded)
- Sidebar: new Resources nav item (BarChart3 icon)
2026-07-01 13:12:48 +02:00
TroodonEnjoyer da5f8360bc Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 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
Rene 9f4dd4fc6b Revert "Add CI/CD pipeline (Gitea Actions) and fix eslint flat config"
Build & Auto-Repair / build (push) Has been cancelled
This reverts commit bcd2d73e70.
2026-07-01 13:06:02 +02:00
Rene bcd2d73e70 Add CI/CD pipeline (Gitea Actions) and fix eslint flat config
Build & Auto-Repair / build (push) Has been cancelled
CI / build (push) Has been cancelled
- .gitea/workflows/ci.yml: lint, typecheck, build on push/PR
- package.json: add typecheck script (tsc --noEmit)
- eslint.config.mjs: add .js extensions for flat config module resolution
2026-07-01 13:04:55 +02:00
caitlin 84632e043f Merge origin/main into main 2026-07-01 12:40:53 +02:00
Ace b2bb6d94f5 added the search button for the AI 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 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
JCBSComputer 72b839f5d4 Fix Projects/Time pages: guard array state + merge conflict resolution
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:10:33 +02:00
JCBSComputer cb1944a9ea Fixed Projects/Proposals/Time 2026-07-01 12:08:22 +02:00
caitlin e8d80c3a16 login logo fix that i broke 2026-07-01 12:04:05 +02:00
caitlin 77cccba8a1 Fixed Dark Mode 2026-07-01 11:43:21 +02:00
167 changed files with 8395 additions and 1049 deletions
+7
View File
@@ -13,6 +13,13 @@ pnpm dev
# or # or
bun dev bun dev
``` ```
```bash
npm install build
# and
npm run build
# and
npm run start
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+3
View File
@@ -12,6 +12,8 @@ cd frontend
# Install dependencies # Install dependencies
npm install npm install
npm install devenv
npm install build
# Start dev server # Start dev server
npm run dev npm run dev
@@ -24,6 +26,7 @@ The app runs at **http://localhost:3000**
| Route | Description | | Route | Description |
|-------|-------------| |-------|-------------|
| `/login` | Login page | | `/login` | Login page |
| `client-portal/login` | Client Login page |
| `/` | Dashboard | | `/` | Dashboard |
| `/leads` | Lead management table | | `/leads` | Lead management table |
| `/leads/:id` | Lead detail view | | `/leads/:id` | Lead detail view |
+491 -45
View File
@@ -1,63 +1,64 @@
.dark.theme-bw { .dark.theme-bw {
--background: 0 0% 4%; --background: 0 0% 5%;
--foreground: 0 0% 95%; --foreground: 0 0% 97%;
--card: 0 0% 8%; --card: 0 0% 10%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 8%; --card-foreground: 0 0% 97%;
--popover-foreground: 0 0% 95%; --popover: 0 0% 10%;
--primary: 0 0% 95%; --popover-foreground: 0 0% 97%;
--primary: 0 0% 97%;
--primary-foreground: 0 0% 0%; --primary-foreground: 0 0% 0%;
--secondary: 0 0% 50%; --secondary: 0 0% 68%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 0%;
--muted: 0 0% 12%; --muted: 0 0% 15%;
--muted-foreground: 0 0% 55%; --muted-foreground: 0 0% 72%;
--accent: 0 0% 95%; --accent: 0 0% 25%;
--accent-foreground: 0 0% 0%; --accent-foreground: 0 0% 97%;
--destructive: 0 70% 50%; --destructive: 0 0% 40%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 97%;
--border: 0 0% 20%; --border: 0 0% 25%;
--input: 0 0% 20%; --input: 0 0% 25%;
--ring: 0 0% 70%; --ring: 0 0% 70%;
--radius: 0.5rem; --radius: 0.5rem;
--sidebar: 0 0% 4%; --sidebar: 0 0% 4%;
--sidebar-foreground: 0 0% 95%; --sidebar-foreground: 0 0% 97%;
--sidebar-primary: 0 0% 95%; --sidebar-primary: 0 0% 97%;
--sidebar-primary-foreground: 0 0% 0%; --sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 0 0% 95%; --sidebar-accent: 0 0% 15%;
--sidebar-accent-foreground: 0 0% 0%; --sidebar-accent-foreground: 0 0% 97%;
--sidebar-border: 0 0% 15%; --sidebar-border: 0 0% 18%;
--sidebar-ring: 0 0% 70%; --sidebar-ring: 0 0% 70%;
} }
.light.theme-bw { .light.theme-bw {
--background: 0 0% 97%; --background: 0 0% 97%;
--foreground: 0 0% 10%; --foreground: 0 0% 8%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 0 0% 10%; --card-foreground: 0 0% 8%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 0 0% 10%; --popover-foreground: 0 0% 8%;
--primary: 0 0% 10%; --primary: 0 0% 8%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 0 0% 40%; --secondary: 0 0% 55%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 100%;
--muted: 0 0% 90%; --muted: 0 0% 93%;
--muted-foreground: 0 0% 45%; --muted-foreground: 0 0% 38%;
--accent: 0 0% 10%; --accent: 0 0% 20%;
--accent-foreground: 0 0% 100%; --accent-foreground: 0 0% 100%;
--destructive: 0 80% 50%; --destructive: 0 0% 60%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 10%;
--border: 0 0% 82%; --border: 0 0% 78%;
--input: 0 0% 82%; --input: 0 0% 78%;
--ring: 0 0% 50%; --ring: 0 0% 45%;
--radius: 0.5rem; --radius: 0.5rem;
--sidebar: 0 0% 100%; --sidebar: 0 0% 100%;
--sidebar-foreground: 0 0% 10%; --sidebar-foreground: 0 0% 8%;
--sidebar-primary: 0 0% 10%; --sidebar-primary: 0 0% 8%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 0 0% 10%; --sidebar-accent: 0 0% 90%;
--sidebar-accent-foreground: 0 0% 100%; --sidebar-accent-foreground: 0 0% 8%;
--sidebar-border: 0 0% 82%; --sidebar-border: 0 0% 80%;
--sidebar-ring: 0 0% 50%; --sidebar-ring: 0 0% 45%;
} }
.dark.theme-bw body { .dark.theme-bw body {
@@ -312,9 +313,454 @@
background: linear-gradient(to right, hsl(0, 0%, 50%), hsl(0, 0%, 65%), hsl(0, 0%, 50%)) !important; background: linear-gradient(to right, hsl(0, 0%, 50%), hsl(0, 0%, 65%), hsl(0, 0%, 50%)) !important;
} }
.dark.theme-bw .growth-word, .dark.theme-bw .growth-word {
.light.theme-bw .growth-word { -webkit-text-fill-color: hsl(0, 0%, 70%) !important;
-webkit-text-fill-color: #1BB0CE !important; color: hsl(0, 0%, 70%) !important;
color: #1BB0CE !important;
background: none !important; background: none !important;
} }
.light.theme-bw .growth-word {
-webkit-text-fill-color: hsl(0, 0%, 40%) !important;
color: hsl(0, 0%, 40%) !important;
background: none !important;
}
/* ============================================================================
Client Portal Overrides — Background for portal pages
============================================================================ */
.dark.theme-bw .min-h-screen.bg-background {
background: transparent !important;
}
.dark.theme-bw aside.bg-card.border-r.border-border {
background: hsl(0, 0%, 6%) !important;
border-right-color: hsl(0, 0%, 18%) !important;
}
.dark.theme-bw aside .text-muted-foreground {
color: hsl(0, 0%, 50%) !important;
}
.dark.theme-bw aside button:hover {
background: hsla(0, 0%, 100%, 0.08) !important;
color: hsl(0, 0%, 90%) !important;
}
.dark.theme-bw aside .bg-primary\/10.text-primary {
background: hsl(0, 0%, 50%) !important;
color: hsl(0, 0%, 100%) !important;
box-shadow: 0 0 10px hsla(0, 0%, 50%, 0.3), 0 0 20px hsla(0, 0%, 50%, 0.1);
border-color: hsl(0, 0%, 50%) !important;
}
.dark.theme-bw aside .bg-primary\/10.text-primary svg {
color: hsl(0, 0%, 100%) !important;
}
.dark.theme-bw header.bg-card\/80 {
background: hsl(0, 0%, 6%) !important;
border-bottom-color: hsl(0, 0%, 18%) !important;
}
.dark.theme-bw .bg-card {
background: hsla(0, 0%, 7%, 0.65) !important;
backdrop-filter: blur(10px) !important;
border-color: hsla(0, 0%, 20%, 0.35) !important;
}
.dark.theme-bw .border-border {
border-color: hsl(0, 0%, 15%) !important;
}
.dark.theme-bw .bg-muted {
background: hsl(0, 0%, 9%) !important;
}
.dark.theme-bw .bg-primary {
background: hsl(0, 0%, 50%) !important;
box-shadow: 0 0 10px hsla(0, 0%, 50%, 0.2);
}
.dark.theme-bw .bg-primary:hover {
background: hsl(0, 0%, 60%) !important;
box-shadow: 0 0 15px hsla(0, 0%, 60%, 0.4);
}
/* ============================================================================
Dashboard Chart Overrides — donut chart & leads-per-month bar chart
Overrides SVG presentation attributes (fill, stop-color, flood-color)
Distinguishable greys per segment + glow on hover
============================================================================ */
/* ---- Donut chart: individual segment fills with varied greys ---- */
.dark.theme-bw [fill="url(#chart-grad-0)"] { fill: hsl(0,0%,55%) !important; }
.dark.theme-bw [fill="url(#chart-grad-1)"] { fill: hsl(0,0%,35%) !important; }
.dark.theme-bw [fill="url(#chart-grad-2)"] { fill: hsl(0,0%,65%) !important; }
.dark.theme-bw [fill="url(#chart-grad-3)"] { fill: hsl(0,0%,30%) !important; }
.dark.theme-bw [fill="url(#chart-grad-4)"] { fill: hsl(0,0%,70%) !important; }
.dark.theme-bw [fill="url(#chart-grad-5)"] { fill: hsl(0,0%,40%) !important; }
.light.theme-bw [fill="url(#chart-grad-0)"] { fill: hsl(0,0%,40%) !important; }
.light.theme-bw [fill="url(#chart-grad-1)"] { fill: hsl(0,0%,55%) !important; }
.light.theme-bw [fill="url(#chart-grad-2)"] { fill: hsl(0,0%,30%) !important; }
.light.theme-bw [fill="url(#chart-grad-3)"] { fill: hsl(0,0%,60%) !important; }
.light.theme-bw [fill="url(#chart-grad-4)"] { fill: hsl(0,0%,45%) !important; }
.light.theme-bw [fill="url(#chart-grad-5)"] { fill: hsl(0,0%,50%) !important; }
/* ---- Donut chart: gradient defs stops (match the fills above) ---- */
.dark.theme-bw #chart-grad-0 stop { stop-color: hsl(0,0%,55%) !important; }
.dark.theme-bw #chart-grad-1 stop { stop-color: hsl(0,0%,35%) !important; }
.dark.theme-bw #chart-grad-2 stop { stop-color: hsl(0,0%,65%) !important; }
.dark.theme-bw #chart-grad-3 stop { stop-color: hsl(0,0%,30%) !important; }
.dark.theme-bw #chart-grad-4 stop { stop-color: hsl(0,0%,70%) !important; }
.dark.theme-bw #chart-grad-5 stop { stop-color: hsl(0,0%,40%) !important; }
.light.theme-bw #chart-grad-0 stop { stop-color: hsl(0,0%,40%) !important; }
.light.theme-bw #chart-grad-1 stop { stop-color: hsl(0,0%,55%) !important; }
.light.theme-bw #chart-grad-2 stop { stop-color: hsl(0,0%,30%) !important; }
.light.theme-bw #chart-grad-3 stop { stop-color: hsl(0,0%,60%) !important; }
.light.theme-bw #chart-grad-4 stop { stop-color: hsl(0,0%,45%) !important; }
.light.theme-bw #chart-grad-5 stop { stop-color: hsl(0,0%,50%) !important; }
/* ---- Bar chart: distinguishable fills for two series ---- */
.dark.theme-bw [fill="url(#newLeadsGrad)"] { fill: hsl(0,0%,55%) !important; }
.dark.theme-bw [fill="url(#closedGrad)"] { fill: hsl(0,0%,30%) !important; }
.light.theme-bw [fill="url(#newLeadsGrad)"] { fill: hsl(0,0%,40%) !important; }
.light.theme-bw [fill="url(#closedGrad)"] { fill: hsl(0,0%,55%) !important; }
/* Bar chart: gradient defs stops */
.dark.theme-bw #newLeadsGrad stop { stop-color: hsl(0,0%,55%) !important; }
.dark.theme-bw #closedGrad stop { stop-color: hsl(0,0%,30%) !important; }
.light.theme-bw #newLeadsGrad stop { stop-color: hsl(0,0%,40%) !important; }
.light.theme-bw #closedGrad stop { stop-color: hsl(0,0%,55%) !important; }
/* ---- feDropShadow flood-color override ---- */
.dark.theme-bw feDropShadow { flood-color: hsl(0,0%,50%) !important; flood-opacity: 0.25 !important; }
.light.theme-bw feDropShadow { flood-color: hsl(0,0%,45%) !important; flood-opacity: 0.15 !important; }
/* ---- Hover glow: donut segments + bar rects ---- */
.dark.theme-bw [fill*="chart-grad"]:hover,
.dark.theme-bw [fill="url(#newLeadsGrad)"]:hover,
.dark.theme-bw [fill="url(#closedGrad)"]:hover {
filter: drop-shadow(0 0 14px hsla(0,0%,80%,0.45)) brightness(1.25) !important;
}
.light.theme-bw [fill*="chart-grad"]:hover,
.light.theme-bw [fill="url(#newLeadsGrad)"]:hover,
.light.theme-bw [fill="url(#closedGrad)"]:hover {
filter: drop-shadow(0 0 12px hsla(0,0%,10%,0.25)) brightness(1.15) !important;
}
/* ============================================================================
Avatar PFP Overrides — target SVG data URLs with hardcoded blue (#1d4ed8)
Desaturate + brighten the blue fill to light grey
============================================================================ */
.dark.theme-bw img[src*="1d4ed8"],
.dark.theme-bw img[src*="fill=%231d4ed8"] {
filter: saturate(0%) brightness(1.7) !important;
}
.light.theme-bw img[src*="1d4ed8"],
.light.theme-bw img[src*="fill=%231d4ed8"] {
filter: saturate(0%) brightness(0.6) !important;
}
/* ============================================================================
BW THEME: Force all colored Tailwind utilities to greyscale
Targets both regular classes AND dark: variant classes
Only applies when .theme-bw is active — no source files touched
============================================================================ */
/* ----- Dark mode: bg-{color}-{shade} ----- */
.dark.theme-bw .bg-blue-500, .dark.theme-bw .dark\:bg-blue-500,
.dark.theme-bw .bg-amber-500, .dark.theme-bw .dark\:bg-amber-500,
.dark.theme-bw .bg-purple-500, .dark.theme-bw .dark\:bg-purple-500,
.dark.theme-bw .bg-emerald-500, .dark.theme-bw .dark\:bg-emerald-500,
.dark.theme-bw .bg-red-500, .dark.theme-bw .dark\:bg-red-500,
.dark.theme-bw .bg-orange-500, .dark.theme-bw .dark\:bg-orange-500,
.dark.theme-bw .bg-yellow-500, .dark.theme-bw .dark\:bg-yellow-500,
.dark.theme-bw .bg-violet-500, .dark.theme-bw .dark\:bg-violet-500,
.dark.theme-bw .bg-green-500, .dark.theme-bw .dark\:bg-green-500,
.dark.theme-bw .bg-sky-500, .dark.theme-bw .dark\:bg-sky-500,
.dark.theme-bw .bg-teal-500, .dark.theme-bw .dark\:bg-teal-500,
.dark.theme-bw .bg-indigo-500, .dark.theme-bw .dark\:bg-indigo-500,
.dark.theme-bw .bg-pink-500, .dark.theme-bw .dark\:bg-pink-500 { background-color: hsl(0,0%,50%) !important; }
.dark.theme-bw .bg-blue-600, .dark.theme-bw .dark\:bg-blue-600,
.dark.theme-bw .bg-amber-600, .dark.theme-bw .dark\:bg-amber-600,
.dark.theme-bw .bg-emerald-600, .dark.theme-bw .dark\:bg-emerald-600,
.dark.theme-bw .bg-red-600, .dark.theme-bw .dark\:bg-red-600,
.dark.theme-bw .bg-green-600, .dark.theme-bw .dark\:bg-green-600,
.dark.theme-bw .bg-orange-600, .dark.theme-bw .dark\:bg-orange-600,
.dark.theme-bw .bg-yellow-600, .dark.theme-bw .dark\:bg-yellow-600,
.dark.theme-bw .bg-violet-600, .dark.theme-bw .dark\:bg-violet-600 { background-color: hsl(0,0%,35%) !important; }
.dark.theme-bw .bg-blue-400, .dark.theme-bw .dark\:bg-blue-400,
.dark.theme-bw .bg-red-400, .dark.theme-bw .dark\:bg-red-400,
.dark.theme-bw .bg-emerald-400, .dark.theme-bw .dark\:bg-emerald-400,
.dark.theme-bw .bg-amber-400, .dark.theme-bw .dark\:bg-amber-400,
.dark.theme-bw .bg-sky-400, .dark.theme-bw .dark\:bg-sky-400,
.dark.theme-bw .bg-green-400, .dark.theme-bw .dark\:bg-green-400,
.dark.theme-bw .bg-purple-400, .dark.theme-bw .dark\:bg-purple-400,
.dark.theme-bw .bg-orange-400, .dark.theme-bw .dark\:bg-orange-400 { background-color: hsl(0,0%,60%) !important; }
.dark.theme-bw .bg-red-50, .dark.theme-bw .dark\:bg-red-50,
.dark.theme-bw .bg-orange-50, .dark.theme-bw .dark\:bg-orange-50,
.dark.theme-bw .bg-yellow-50, .dark.theme-bw .dark\:bg-yellow-50,
.dark.theme-bw .bg-emerald-50, .dark.theme-bw .dark\:bg-emerald-50,
.dark.theme-bw .bg-blue-50, .dark.theme-bw .dark\:bg-blue-50,
.dark.theme-bw .bg-amber-50, .dark.theme-bw .dark\:bg-amber-50,
.dark.theme-bw .bg-violet-50, .dark.theme-bw .dark\:bg-violet-50,
.dark.theme-bw .bg-green-50, .dark.theme-bw .dark\:bg-green-50 { background-color: hsl(0,0%,95%) !important; }
.dark.theme-bw .bg-red-100, .dark.theme-bw .dark\:bg-red-100,
.dark.theme-bw .bg-orange-100, .dark.theme-bw .dark\:bg-orange-100,
.dark.theme-bw .bg-yellow-100, .dark.theme-bw .dark\:bg-yellow-100,
.dark.theme-bw .bg-emerald-100, .dark.theme-bw .dark\:bg-emerald-100,
.dark.theme-bw .bg-blue-100, .dark.theme-bw .dark\:bg-blue-100,
.dark.theme-bw .bg-amber-100, .dark.theme-bw .dark\:bg-amber-100,
.dark.theme-bw .bg-violet-100, .dark.theme-bw .dark\:bg-violet-100,
.dark.theme-bw .bg-green-100, .dark.theme-bw .dark\:bg-green-100 { background-color: hsl(0,0%,88%) !important; }
.dark.theme-bw .bg-blue-500\/10, .dark.theme-bw .dark\:bg-blue-500\/10,
.dark.theme-bw .bg-amber-500\/10, .dark.theme-bw .dark\:bg-amber-500\/10,
.dark.theme-bw .bg-purple-500\/10, .dark.theme-bw .dark\:bg-purple-500\/10,
.dark.theme-bw .bg-emerald-500\/10, .dark.theme-bw .dark\:bg-emerald-500\/10,
.dark.theme-bw .bg-red-500\/10, .dark.theme-bw .dark\:bg-red-500\/10,
.dark.theme-bw .bg-orange-500\/10, .dark.theme-bw .dark\:bg-orange-500\/10,
.dark.theme-bw .bg-yellow-500\/10, .dark.theme-bw .dark\:bg-yellow-500\/10,
.dark.theme-bw .bg-violet-500\/10, .dark.theme-bw .dark\:bg-violet-500\/10,
.dark.theme-bw .bg-green-500\/10, .dark.theme-bw .dark\:bg-green-500\/10,
.dark.theme-bw .bg-sky-500\/10, .dark.theme-bw .dark\:bg-sky-500\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
.dark.theme-bw .bg-blue-500\/20, .dark.theme-bw .dark\:bg-blue-500\/20,
.dark.theme-bw .bg-amber-500\/20, .dark.theme-bw .dark\:bg-amber-500\/20,
.dark.theme-bw .bg-purple-500\/20, .dark.theme-bw .dark\:bg-purple-500\/20,
.dark.theme-bw .bg-emerald-500\/20, .dark.theme-bw .dark\:bg-emerald-500\/20,
.dark.theme-bw .bg-red-500\/20, .dark.theme-bw .dark\:bg-red-500\/20,
.dark.theme-bw .bg-violet-500\/20, .dark.theme-bw .dark\:bg-violet-500\/20 { background-color: hsla(0,0%,50%,0.2) !important; }
.dark.theme-bw .bg-blue-500\/5, .dark.theme-bw .dark\:bg-blue-500\/5,
.dark.theme-bw .bg-amber-500\/5, .dark.theme-bw .dark\:bg-amber-500\/5,
.dark.theme-bw .bg-purple-500\/5, .dark.theme-bw .dark\:bg-purple-500\/5,
.dark.theme-bw .bg-emerald-500\/5, .dark.theme-bw .dark\:bg-emerald-500\/5,
.dark.theme-bw .bg-red-500\/5, .dark.theme-bw .dark\:bg-red-500\/5,
.dark.theme-bw .bg-violet-500\/5, .dark.theme-bw .dark\:bg-violet-500\/5 { background-color: hsla(0,0%,50%,0.05) !important; }
/* ----- Dark mode: text-{color}-{shade} (regular AND dark: variant) ----- */
.dark.theme-bw .text-blue-500, .dark.theme-bw .dark\:text-blue-500,
.dark.theme-bw .text-amber-500, .dark.theme-bw .dark\:text-amber-500,
.dark.theme-bw .text-purple-500, .dark.theme-bw .dark\:text-purple-500,
.dark.theme-bw .text-emerald-500, .dark.theme-bw .dark\:text-emerald-500,
.dark.theme-bw .text-red-500, .dark.theme-bw .dark\:text-red-500,
.dark.theme-bw .text-orange-500, .dark.theme-bw .dark\:text-orange-500,
.dark.theme-bw .text-yellow-500, .dark.theme-bw .dark\:text-yellow-500,
.dark.theme-bw .text-violet-500, .dark.theme-bw .dark\:text-violet-500,
.dark.theme-bw .text-green-500, .dark.theme-bw .dark\:text-green-500,
.dark.theme-bw .text-sky-500, .dark.theme-bw .dark\:text-sky-500,
.dark.theme-bw .text-teal-500, .dark.theme-bw .dark\:text-teal-500,
.dark.theme-bw .text-indigo-500, .dark.theme-bw .dark\:text-indigo-500,
.dark.theme-bw .text-pink-500, .dark.theme-bw .dark\:text-pink-500,
.dark.theme-bw .text-rose-500, .dark.theme-bw .dark\:text-rose-500 { color: hsl(0,0%,65%) !important; }
.dark.theme-bw .text-blue-600, .dark.theme-bw .dark\:text-blue-600,
.dark.theme-bw .text-amber-600, .dark.theme-bw .dark\:text-amber-600,
.dark.theme-bw .text-emerald-600, .dark.theme-bw .dark\:text-emerald-600,
.dark.theme-bw .text-red-600, .dark.theme-bw .dark\:text-red-600,
.dark.theme-bw .text-green-600, .dark.theme-bw .dark\:text-green-600,
.dark.theme-bw .text-orange-600, .dark.theme-bw .dark\:text-orange-600,
.dark.theme-bw .text-yellow-600, .dark.theme-bw .dark\:text-yellow-600,
.dark.theme-bw .text-violet-600, .dark.theme-bw .dark\:text-violet-600 { color: hsl(0,0%,50%) !important; }
.dark.theme-bw .text-blue-400, .dark.theme-bw .dark\:text-blue-400,
.dark.theme-bw .text-red-400, .dark.theme-bw .dark\:text-red-400,
.dark.theme-bw .text-emerald-400, .dark.theme-bw .dark\:text-emerald-400,
.dark.theme-bw .text-amber-400, .dark.theme-bw .dark\:text-amber-400,
.dark.theme-bw .text-sky-400, .dark.theme-bw .dark\:text-sky-400,
.dark.theme-bw .text-green-400, .dark.theme-bw .dark\:text-green-400,
.dark.theme-bw .text-rose-400, .dark.theme-bw .dark\:text-rose-400,
.dark.theme-bw .text-orange-400, .dark.theme-bw .dark\:text-orange-400,
.dark.theme-bw .text-purple-400, .dark.theme-bw .dark\:text-purple-400 { color: hsl(0,0%,75%) !important; }
.dark.theme-bw .text-blue-700, .dark.theme-bw .dark\:text-blue-700,
.dark.theme-bw .text-amber-700, .dark.theme-bw .dark\:text-amber-700,
.dark.theme-bw .text-emerald-700, .dark.theme-bw .dark\:text-emerald-700,
.dark.theme-bw .text-red-700, .dark.theme-bw .dark\:text-red-700,
.dark.theme-bw .text-orange-700, .dark.theme-bw .dark\:text-orange-700,
.dark.theme-bw .text-yellow-700, .dark.theme-bw .dark\:text-yellow-700,
.dark.theme-bw .text-violet-700, .dark.theme-bw .dark\:text-violet-700 { color: hsl(0,0%,35%) !important; }
.dark.theme-bw .text-emerald-600\/60, .dark.theme-bw .dark\:text-emerald-600\/60 { color: hsla(0,0%,50%,0.7) !important; }
.dark.theme-bw .text-red-500\/40, .dark.theme-bw .dark\:text-red-500\/40 { color: hsla(0,0%,65%,0.5) !important; }
/* ----- Dark mode: border-{color}-{shade} ----- */
.dark.theme-bw .border-blue-500, .dark.theme-bw .dark\:border-blue-500,
.dark.theme-bw .border-amber-500, .dark.theme-bw .dark\:border-amber-500,
.dark.theme-bw .border-purple-500, .dark.theme-bw .dark\:border-purple-500,
.dark.theme-bw .border-emerald-500, .dark.theme-bw .dark\:border-emerald-500,
.dark.theme-bw .border-red-500, .dark.theme-bw .dark\:border-red-500,
.dark.theme-bw .border-orange-500, .dark.theme-bw .dark\:border-orange-500,
.dark.theme-bw .border-yellow-500, .dark.theme-bw .dark\:border-yellow-500,
.dark.theme-bw .border-violet-500, .dark.theme-bw .dark\:border-violet-500 { border-color: hsl(0,0%,50%) !important; }
.dark.theme-bw .border-blue-500\/20, .dark.theme-bw .dark\:border-blue-500\/20,
.dark.theme-bw .border-amber-500\/20, .dark.theme-bw .dark\:border-amber-500\/20,
.dark.theme-bw .border-purple-500\/20, .dark.theme-bw .dark\:border-purple-500\/20,
.dark.theme-bw .border-emerald-500\/20, .dark.theme-bw .dark\:border-emerald-500\/20,
.dark.theme-bw .border-red-500\/20, .dark.theme-bw .dark\:border-red-500\/20 { border-color: hsla(0,0%,50%,0.2) !important; }
.dark.theme-bw .border-red-200, .dark.theme-bw .dark\:border-red-200,
.dark.theme-bw .border-orange-200, .dark.theme-bw .dark\:border-orange-200,
.dark.theme-bw .border-yellow-200, .dark.theme-bw .dark\:border-yellow-200,
.dark.theme-bw .border-emerald-200, .dark.theme-bw .dark\:border-emerald-200,
.dark.theme-bw .border-blue-200, .dark.theme-bw .dark\:border-blue-200,
.dark.theme-bw .border-violet-200, .dark.theme-bw .dark\:border-violet-200 { border-color: hsl(0,0%,80%) !important; }
.dark.theme-bw .border-red-300, .dark.theme-bw .dark\:border-red-300,
.dark.theme-bw .border-orange-300, .dark.theme-bw .dark\:border-orange-300,
.dark.theme-bw .border-yellow-300, .dark.theme-bw .dark\:border-yellow-300,
.dark.theme-bw .border-emerald-300, .dark.theme-bw .dark\:border-emerald-300,
.dark.theme-bw .border-blue-300, .dark.theme-bw .dark\:border-blue-300,
.dark.theme-bw .border-violet-300, .dark.theme-bw .dark\:border-violet-300 { border-color: hsl(0,0%,70%) !important; }
.dark.theme-bw .ring-blue-500\/20, .dark.theme-bw .dark\:ring-blue-500\/20,
.dark.theme-bw .ring-amber-500\/20, .dark.theme-bw .dark\:ring-amber-500\/20,
.dark.theme-bw .ring-emerald-500\/20, .dark.theme-bw .dark\:ring-emerald-500\/20,
.dark.theme-bw .ring-red-500\/20, .dark.theme-bw .dark\:ring-red-500\/20,
.dark.theme-bw .ring-purple-500\/20, .dark.theme-bw .dark\:ring-purple-500\/20 { --tw-ring-color: hsla(0,0%,50%,0.2) !important; }
/* ----- Dark mode: Gradient direction classes ----- */
.dark.theme-bw .from-blue-500, .dark.theme-bw .dark\:from-blue-500,
.dark.theme-bw .from-amber-500, .dark.theme-bw .dark\:from-amber-500,
.dark.theme-bw .from-purple-500, .dark.theme-bw .dark\:from-purple-500,
.dark.theme-bw .from-emerald-500, .dark.theme-bw .dark\:from-emerald-500,
.dark.theme-bw .from-red-500, .dark.theme-bw .dark\:from-red-500,
.dark.theme-bw .from-orange-500, .dark.theme-bw .dark\:from-orange-500,
.dark.theme-bw .from-violet-500, .dark.theme-bw .dark\:from-violet-500,
.dark.theme-bw .from-green-500, .dark.theme-bw .dark\:from-green-500,
.dark.theme-bw .from-pink-500, .dark.theme-bw .dark\:from-pink-500,
.dark.theme-bw .from-cyan-400, .dark.theme-bw .dark\:from-cyan-400,
.dark.theme-bw .from-fuchsia-500, .dark.theme-bw .dark\:from-fuchsia-500 { --tw-gradient-from: hsl(0,0%,50%) !important; }
.dark.theme-bw .to-blue-500, .dark.theme-bw .dark\:to-blue-500,
.dark.theme-bw .to-amber-500, .dark.theme-bw .dark\:to-amber-500,
.dark.theme-bw .to-purple-500, .dark.theme-bw .dark\:to-purple-500,
.dark.theme-bw .to-emerald-500, .dark.theme-bw .dark\:to-emerald-500,
.dark.theme-bw .to-red-500, .dark.theme-bw .dark\:to-red-500,
.dark.theme-bw .to-orange-500, .dark.theme-bw .dark\:to-orange-500,
.dark.theme-bw .to-green-500, .dark.theme-bw .dark\:to-green-500,
.dark.theme-bw .to-cyan-400, .dark.theme-bw .dark\:to-cyan-400 { --tw-gradient-to: hsl(0,0%,50%) !important; }
.dark.theme-bw .from-blue-500\/5, .dark.theme-bw .dark\:from-blue-500\/5,
.dark.theme-bw .from-amber-500\/5, .dark.theme-bw .dark\:from-amber-500\/5,
.dark.theme-bw .from-purple-500\/5, .dark.theme-bw .dark\:from-purple-500\/5,
.dark.theme-bw .from-emerald-500\/5, .dark.theme-bw .dark\:from-emerald-500\/5,
.dark.theme-bw .from-red-500\/5, .dark.theme-bw .dark\:from-red-500\/5 { --tw-gradient-from: hsla(0,0%,50%,0.05) !important; }
.dark.theme-bw .to-violet-500\/\[0\.02\], .dark.theme-bw .dark\:to-violet-500\/\[0\.02\],
.dark.theme-bw .to-amber-500\/\[0\.02\], .dark.theme-bw .dark\:to-amber-500\/\[0\.02\] { --tw-gradient-to: hsla(0,0%,50%,0.02) !important; }
.dark.theme-bw .from-violet-500\/5, .dark.theme-bw .dark\:from-violet-500\/5,
.dark.theme-bw .from-amber-500\/5, .dark.theme-bw .dark\:from-amber-500\/5 { --tw-gradient-from: hsla(0,0%,50%,0.05) !important; }
/* ----- Dark mode: Inline arbitrary hex colors (stat-card, voice-call, etc) ----- */
.dark.theme-bw .text-\[#C84B4B\], .dark.theme-bw .dark\:text-\[\#C84B4B\],
.dark.theme-bw .text-\[\#FF1111\], .dark.theme-bw .dark\:text-\[\#FF1111\] { color: hsl(0,0%,60%) !important; }
.dark.theme-bw .text-\[#5A8FC4\], .dark.theme-bw .dark\:text-\[\#5A8FC4\],
.dark.theme-bw .text-\[\#1144FF\], .dark.theme-bw .dark\:text-\[\#1144FF\] { color: hsl(0,0%,50%) !important; }
.dark.theme-bw .text-\[#2D3020\], .dark.theme-bw .dark\:text-\[\#2D3020\] { color: hsl(0,0%,20%) !important; }
.dark.theme-bw .text-\[#8A9078\], .dark.theme-bw .dark\:text-\[\#8A9078\] { color: hsl(0,0%,55%) !important; }
.dark.theme-bw .bg-\[#C84B4B\]\/10, .dark.theme-bw .dark\:bg-\[\#C84B4B\]\/10,
.dark.theme-bw .bg-\[#5A8FC4\]\/10, .dark.theme-bw .dark\:bg-\[\#5A8FC4\]\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
.dark.theme-bw .from-\[#C84B4B\], .dark.theme-bw .dark\:from-\[\#C84B4B\],
.dark.theme-bw .via-\[#C84B4B\], .dark.theme-bw .dark\:via-\[\#C84B4B\],
.dark.theme-bw .to-\[#C84B4B\], .dark.theme-bw .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(0,0%,50%) !important; }
.dark.theme-bw .from-\[#5A8FC4\], .dark.theme-bw .dark\:from-\[\#5A8FC4\],
.dark.theme-bw .via-\[#5A8FC4\], .dark.theme-bw .dark\:via-\[\#5A8FC4\],
.dark.theme-bw .to-\[#5A8FC4\], .dark.theme-bw .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(0,0%,50%) !important; }
/* ----- Dark mode: SVG stroke/fill (app-shell, etc) ----- */
.dark.theme-bw [stroke="#CC0000"] { stroke: hsl(0,0%,50%) !important; }
.dark.theme-bw [stroke="#0033CC"] { stroke: hsl(0,0%,50%) !important; }
.dark.theme-bw [fill="#2D3020"] { fill: hsl(0,0%,20%) !important; }
.dark.theme-bw [fill="#8A9078"] { fill: hsl(0,0%,55%) !important; }
/* ----- Dark mode: Hover states ----- */
.dark.theme-bw .hover\:bg-sky-400\/20:hover { background-color: hsla(0,0%,50%,0.2) !important; }
.dark.theme-bw .hover\:bg-emerald-700:hover { background-color: hsl(0,0%,30%) !important; }
/* ----- Light mode overrides ----- */
.light.theme-bw .bg-blue-500, .light.theme-bw .dark\:bg-blue-500,
.light.theme-bw .bg-amber-500, .light.theme-bw .dark\:bg-amber-500,
.light.theme-bw .bg-purple-500, .light.theme-bw .dark\:bg-purple-500,
.light.theme-bw .bg-emerald-500, .light.theme-bw .dark\:bg-emerald-500,
.light.theme-bw .bg-red-500, .light.theme-bw .dark\:bg-red-500,
.light.theme-bw .bg-orange-500, .light.theme-bw .dark\:bg-orange-500,
.light.theme-bw .bg-yellow-500, .light.theme-bw .dark\:bg-yellow-500,
.light.theme-bw .bg-violet-500, .light.theme-bw .dark\:bg-violet-500,
.light.theme-bw .bg-green-500, .light.theme-bw .dark\:bg-green-500,
.light.theme-bw .bg-sky-500, .light.theme-bw .dark\:bg-sky-500 { background-color: hsl(0,0%,50%) !important; }
.light.theme-bw .text-blue-500, .light.theme-bw .dark\:text-blue-500,
.light.theme-bw .text-amber-500, .light.theme-bw .dark\:text-amber-500,
.light.theme-bw .text-purple-500, .light.theme-bw .dark\:text-purple-500,
.light.theme-bw .text-emerald-500, .light.theme-bw .dark\:text-emerald-500,
.light.theme-bw .text-red-500, .light.theme-bw .dark\:text-red-500,
.light.theme-bw .text-orange-500, .light.theme-bw .dark\:text-orange-500,
.light.theme-bw .text-yellow-500, .light.theme-bw .dark\:text-yellow-500,
.light.theme-bw .text-violet-500, .light.theme-bw .dark\:text-violet-500,
.light.theme-bw .text-sky-400, .light.theme-bw .dark\:text-sky-400,
.light.theme-bw .text-rose-500, .light.theme-bw .dark\:text-rose-500,
.light.theme-bw .text-green-500, .light.theme-bw .dark\:text-green-500 { color: hsl(0,0%,40%) !important; }
.light.theme-bw .bg-blue-500\/10, .light.theme-bw .dark\:bg-blue-500\/10,
.light.theme-bw .bg-amber-500\/10, .light.theme-bw .dark\:bg-amber-500\/10,
.light.theme-bw .bg-purple-500\/10, .light.theme-bw .dark\:bg-purple-500\/10,
.light.theme-bw .bg-emerald-500\/10, .light.theme-bw .dark\:bg-emerald-500\/10,
.light.theme-bw .bg-red-500\/10, .light.theme-bw .dark\:bg-red-500\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
/* ============================================================================
Pipeline (Kanban Board + Stat Cards) — white glow instead of stage colours
============================================================================ */
/* ---- Kanban columns ---- */
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border {
box-shadow: 0 0 20px hsla(0,0%,100%,0.06), 0 0 50px hsla(0,0%,100%,0.02) !important;
}
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border .rounded-t-lg {
background: transparent !important;
border-bottom-color: hsla(0,0%,100%,0.1) !important;
}
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border .h-2.w-2.rounded-full {
background: hsl(0,0%,65%) !important;
box-shadow: 0 0 6px hsla(0,0%,100%,0.4) !important;
}
.dark.theme-bw .overflow-x-auto .cursor-grab .rounded-full {
background: hsl(0,0%,50%) !important;
box-shadow: 0 0 6px hsla(0,0%,100%,0.2) !important;
}
/* ---- Dashboard cards (Pipeline Overview stat cards, Analytics charts, Recent Leads table) ---- */
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card {
box-shadow: 0 0 16px hsla(0,0%,100%,0.05), 0 0 40px hsla(0,0%,100%,0.015) !important;
}
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .rounded-xl {
background: transparent !important;
box-shadow: 0 0 12px hsla(0,0%,100%,0.15), inset 0 0 8px hsla(0,0%,100%,0.05) !important;
}
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .rounded-xl svg {
color: hsl(0,0%,70%) !important;
}
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
background: hsl(0,0%,50%) !important;
box-shadow: 0 0 8px hsla(0,0%,100%,0.3) !important;
}
+229
View File
@@ -287,3 +287,232 @@
color: #1BB0CE !important; color: #1BB0CE !important;
background: none !important; background: none !important;
} }
/* ============================================================================
Charts — Donut (LeadStatus) & Bar (LeadsPerMonth) — Cosmic colors
============================================================================ */
/* ---- SVG gradient overrides (donut chart segments) ---- */
.dark.theme-cosmic #chart-grad-0 stop { stop-color: hsl(280, 60%, 55%) !important; }
.dark.theme-cosmic #chart-grad-1 stop { stop-color: hsl(120, 100%, 45%) !important; }
.dark.theme-cosmic #chart-grad-2 stop { stop-color: hsl(320, 80%, 60%) !important; }
.dark.theme-cosmic #chart-grad-3 stop { stop-color: hsl(170, 80%, 50%) !important; }
.dark.theme-cosmic #chart-grad-4 stop { stop-color: hsl(260, 15%, 40%) !important; }
.light.theme-cosmic #chart-grad-0 stop { stop-color: hsl(280, 50%, 50%) !important; }
.light.theme-cosmic #chart-grad-1 stop { stop-color: hsl(120, 80%, 40%) !important; }
.light.theme-cosmic #chart-grad-2 stop { stop-color: hsl(320, 70%, 55%) !important; }
.light.theme-cosmic #chart-grad-3 stop { stop-color: hsl(170, 70%, 40%) !important; }
.light.theme-cosmic #chart-grad-4 stop { stop-color: hsl(260, 10%, 55%) !important; }
/* ---- SVG gradient overrides (bar chart) ---- */
.dark.theme-cosmic #newLeadsGrad stop { stop-color: hsl(280, 60%, 55%) !important; }
.dark.theme-cosmic #closedGrad stop { stop-color: hsl(120, 100%, 45%) !important; }
.light.theme-cosmic #newLeadsGrad stop { stop-color: hsl(280, 50%, 50%) !important; }
.light.theme-cosmic #closedGrad stop { stop-color: hsl(120, 80%, 40%) !important; }
/* ---- Inline chart colors (legend dots, hover text) ---- */
.dark.theme-cosmic [style*="background: #3b82f6"],
.dark.theme-cosmic [style*="background:#3b82f6"] { background: hsl(280, 60%, 55%) !important; }
.dark.theme-cosmic [style*="background: #f59e0b"],
.dark.theme-cosmic [style*="background:#f59e0b"] { background: hsl(120, 100%, 45%) !important; }
.dark.theme-cosmic [style*="background: #8b5cf6"],
.dark.theme-cosmic [style*="background:#8b5cf6"] { background: hsl(320, 80%, 60%) !important; }
.dark.theme-cosmic [style*="background: #10b981"],
.dark.theme-cosmic [style*="background:#10b981"] { background: hsl(170, 80%, 50%) !important; }
.dark.theme-cosmic [style*="background: #6B7280"],
.dark.theme-cosmic [style*="background:#6B7280"] { background: hsl(260, 15%, 40%) !important; }
.light.theme-cosmic [style*="background: #3b82f6"],
.light.theme-cosmic [style*="background:#3b82f6"] { background: hsl(280, 50%, 50%) !important; }
.light.theme-cosmic [style*="background: #f59e0b"],
.light.theme-cosmic [style*="background:#f59e0b"] { background: hsl(120, 80%, 40%) !important; }
.light.theme-cosmic [style*="background: #8b5cf6"],
.light.theme-cosmic [style*="background:#8b5cf6"] { background: hsl(320, 70%, 55%) !important; }
.light.theme-cosmic [style*="background: #10b981"],
.light.theme-cosmic [style*="background:#10b981"] { background: hsl(170, 70%, 40%) !important; }
.light.theme-cosmic [style*="background: #6B7280"],
.light.theme-cosmic [style*="background:#6B7280"] { background: hsl(260, 10%, 55%) !important; }
/* ---- Chart legend hover borders/box-shadows ---- */
.dark.theme-cosmic [style*="border: 1px solid #3b82f6"],
.dark.theme-cosmic [style*="border:1px solid #3b82f6"] { border-color: hsl(280, 60%, 55%) !important; }
.dark.theme-cosmic [style*="border: 1px solid #f59e0b"],
.dark.theme-cosmic [style*="border:1px solid #f59e0b"] { border-color: hsl(120, 100%, 45%) !important; }
.dark.theme-cosmic [style*="border: 1px solid #8b5cf6"],
.dark.theme-cosmic [style*="border:1px solid #8b5cf6"] { border-color: hsl(320, 80%, 60%) !important; }
.dark.theme-cosmic [style*="border: 1px solid #10b981"],
.dark.theme-cosmic [style*="border:1px solid #10b981"] { border-color: hsl(170, 80%, 50%) !important; }
.dark.theme-cosmic [style*="border: 1px solid #6B7280"],
.dark.theme-cosmic [style*="border:1px solid #6B7280"] { border-color: hsl(260, 15%, 40%) !important; }
/* ---- Donut chart segment text color on hover ---- */
.dark.theme-cosmic [style*="color: #3b82f6"] { color: hsl(280, 60%, 55%) !important; }
.dark.theme-cosmic [style*="color: #f59e0b"] { color: hsl(120, 100%, 45%) !important; }
.dark.theme-cosmic [style*="color: #8b5cf6"] { color: hsl(320, 80%, 60%) !important; }
.dark.theme-cosmic [style*="color: #10b981"] { color: hsl(170, 80%, 50%) !important; }
.dark.theme-cosmic [style*="color: #6B7280"] { color: hsl(260, 15%, 40%) !important; }
.light.theme-cosmic [style*="color: #3b82f6"] { color: hsl(280, 50%, 50%) !important; }
.light.theme-cosmic [style*="color: #f59e0b"] { color: hsl(120, 80%, 40%) !important; }
.light.theme-cosmic [style*="color: #8b5cf6"] { color: hsl(320, 70%, 55%) !important; }
.light.theme-cosmic [style*="color: #10b981"] { color: hsl(170, 70%, 40%) !important; }
.light.theme-cosmic [style*="color: #6B7280"] { color: hsl(260, 10%, 55%) !important; }
/* ============================================================================
Stat Cards — Icon backgrounds, text colors, progress bars
============================================================================ */
.dark.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
background: hsla(280, 60%, 55%, 0.12) !important;
}
.dark.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
color: hsl(280, 60%, 55%) !important;
}
.dark.theme-cosmic .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
background: linear-gradient(to right, hsl(280, 60%, 55%), hsl(120, 100%, 45%)) !important;
}
.dark.theme-cosmic .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
background: linear-gradient(to right, transparent, hsla(280, 60%, 55%, 0.2), transparent) !important;
}
.light.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
background: hsla(280, 50%, 50%, 0.1) !important;
}
.light.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
color: hsl(280, 50%, 50%) !important;
}
.light.theme-cosmic .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
background: linear-gradient(to right, hsl(280, 50%, 50%), hsl(120, 80%, 40%)) !important;
}
.light.theme-cosmic .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
background: linear-gradient(to right, transparent, hsla(280, 50%, 50%, 0.15), transparent) !important;
}
/* ============================================================================
Inline hex color overrides — stat card values, page icons, etc.
============================================================================ */
.dark.theme-cosmic .text-\[#C84B4B\], .dark.theme-cosmic .dark\:text-\[\#C84B4B\] { color: hsl(280, 60%, 55%) !important; }
.dark.theme-cosmic .text-\[#5A8FC4\], .dark.theme-cosmic .dark\:text-\[\#5A8FC4\] { color: hsl(120, 100%, 45%) !important; }
.dark.theme-cosmic .text-\[#FF1111\], .dark.theme-cosmic .dark\:text-\[\#FF1111\] { color: hsl(280, 80%, 60%) !important; }
.dark.theme-cosmic .text-\[#1144FF\], .dark.theme-cosmic .dark\:text-\[\#1144FF\] { color: hsl(120, 100%, 55%) !important; }
.light.theme-cosmic .text-\[#C84B4B\], .light.theme-cosmic .dark\:text-\[\#C84B4B\] { color: hsl(280, 50%, 50%) !important; }
.light.theme-cosmic .text-\[#5A8FC4\], .light.theme-cosmic .dark\:text-\[\#5A8FC4\] { color: hsl(120, 80%, 40%) !important; }
.dark.theme-cosmic .bg-\[#C84B4B\]\/10, .dark.theme-cosmic .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(280, 60%, 55%, 0.1) !important; }
.dark.theme-cosmic .bg-\[#5A8FC4\]\/10, .dark.theme-cosmic .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(120, 100%, 45%, 0.1) !important; }
.light.theme-cosmic .bg-\[#C84B4B\]\/10, .light.theme-cosmic .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(280, 50%, 50%, 0.08) !important; }
.light.theme-cosmic .bg-\[#5A8FC4\]\/10, .light.theme-cosmic .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(120, 80%, 40%, 0.08) !important; }
.dark.theme-cosmic .from-\[#C84B4B\], .dark.theme-cosmic .dark\:from-\[\#C84B4B\],
.dark.theme-cosmic .via-\[#C84B4B\], .dark.theme-cosmic .dark\:via-\[\#C84B4B\],
.dark.theme-cosmic .to-\[#C84B4B\], .dark.theme-cosmic .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(280, 60%, 55%) !important; }
.dark.theme-cosmic .from-\[#5A8FC4\], .dark.theme-cosmic .dark\:from-\[\#5A8FC4\],
.dark.theme-cosmic .via-\[#5A8FC4\], .dark.theme-cosmic .dark\:via-\[\#5A8FC4\],
.dark.theme-cosmic .to-\[#5A8FC4\], .dark.theme-cosmic .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(120, 100%, 45%) !important; }
.light.theme-cosmic .from-\[#C84B4B\], .light.theme-cosmic .dark\:from-\[\#C84B4B\],
.light.theme-cosmic .via-\[#C84B4B\], .light.theme-cosmic .dark\:via-\[\#C84B4B\],
.light.theme-cosmic .to-\[#C84B4B\], .light.theme-cosmic .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(280, 50%, 50%) !important; }
.light.theme-cosmic .from-\[#5A8FC4\], .light.theme-cosmic .dark\:from-\[\#5A8FC4\],
.light.theme-cosmic .via-\[#5A8FC4\], .light.theme-cosmic .dark\:via-\[\#5A8FC4\],
.light.theme-cosmic .to-\[#5A8FC4\], .light.theme-cosmic .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(120, 80%, 40%) !important; }
/* ---- SVG stroke/fill overrides ---- */
.dark.theme-cosmic [stroke="#CC0000"] { stroke: hsl(280, 60%, 55%) !important; }
.dark.theme-cosmic [stroke="#0033CC"] { stroke: hsl(120, 100%, 45%) !important; }
.light.theme-cosmic [stroke="#CC0000"] { stroke: hsl(280, 50%, 50%) !important; }
.light.theme-cosmic [stroke="#0033CC"] { stroke: hsl(120, 80%, 40%) !important; }
/* ============================================================================
Light mode — deeper visual polish
============================================================================ */
.light.theme-cosmic .rounded-xl.border.bg-card {
box-shadow: 0 1px 3px hsla(280, 50%, 30%, 0.08), 0 4px 12px hsla(280, 50%, 30%, 0.04) !important;
}
.light.theme-cosmic aside .group:hover {
background: hsla(280, 50%, 50%, 0.04) !important;
}
.light.theme-cosmic .hover\:bg-accent:hover {
background: hsla(280, 50%, 50%, 0.06) !important;
}
/* ============================================================================
Client Portal Overrides — Background for portal pages
============================================================================ */
/* Make the outer portal container transparent so body::before background shows */
.dark.theme-cosmic .min-h-screen.bg-background {
background: transparent !important;
}
/* Portal sidebar — dark glass */
.dark.theme-cosmic aside.bg-card.border-r.border-border {
background: hsl(240, 15%, 3%) !important;
border-right-color: hsl(270, 25%, 10%) !important;
}
/* Portal sidebar nav items — muted text */
.dark.theme-cosmic aside .text-muted-foreground {
color: hsl(240, 8%, 50%) !important;
}
.dark.theme-cosmic aside button:hover {
background: hsla(330, 100%, 50%, 0.08) !important;
color: hsl(0, 0%, 90%) !important;
}
/* Portal sidebar active nav item */
.dark.theme-cosmic aside .bg-primary\/10.text-primary {
background: hsl(330, 100%, 50%) !important;
color: hsl(0, 0%, 100%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.1);
border-color: hsl(330, 100%, 50%) !important;
}
.dark.theme-cosmic aside .bg-primary\/10.text-primary svg {
color: hsl(0, 0%, 100%) !important;
}
/* Portal header — dark glass */
.dark.theme-cosmic header.bg-card\/80 {
background: hsl(240, 15%, 3%) !important;
border-bottom-color: hsl(270, 25%, 10%) !important;
}
/* Portal card backgrounds — semi-transparent glass */
.dark.theme-cosmic .bg-card {
background: hsla(240, 15%, 7%, 0.65) !important;
backdrop-filter: blur(10px) !important;
border-color: hsla(270, 30%, 20%, 0.35) !important;
}
/* Portal borders */
.dark.theme-cosmic .border-border {
border-color: hsl(270, 25%, 12%) !important;
}
/* Portal input fields */
.dark.theme-cosmic .bg-muted {
background: hsl(240, 12%, 9%) !important;
}
/* Portal primary buttons */
.dark.theme-cosmic .bg-primary {
background: hsl(330, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
}
.dark.theme-cosmic .bg-primary:hover {
background: hsl(330, 100%, 55%) !important;
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
}
+154
View File
@@ -0,0 +1,154 @@
/* ============================================================================
Cuba Theme — Classic Cuban car on a playa at golden hour (6pm sunset)
Malecón, Havana — warm sunset tones, turquoise Caribbean accents
============================================================================ */
.dark.theme-cuba {
--background: 25 40% 6%;
--foreground: 30 20% 90%;
--card: 25 35% 10%;
--card-foreground: 30 20% 90%;
--popover: 25 35% 10%;
--popover-foreground: 30 20% 90%;
--primary: 15 85% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 185 60% 50%;
--secondary-foreground: 0 0% 100%;
--muted: 25 20% 15%;
--muted-foreground: 25 10% 55%;
--accent: 195 65% 55%;
--accent-foreground: 0 0% 0%;
--destructive: 0 75% 50%;
--destructive-foreground: 0 0% 100%;
--success: 145 55% 45%;
--success-foreground: 0 0% 100%;
--warning: 35 90% 55%;
--warning-foreground: 0 0% 0%;
--border: 25 25% 18%;
--input: 25 20% 15%;
--ring: 15 85% 55%;
--radius: 0.75rem;
--sidebar: 25 40% 6%;
--sidebar-foreground: 30 20% 90%;
--sidebar-primary: 15 85% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 25 30% 12%;
--sidebar-accent-foreground: 30 20% 90%;
--sidebar-border: 25 25% 15%;
--sidebar-ring: 15 85% 55%;
--chart-1: 15 85% 55%;
--chart-2: 185 60% 50%;
--chart-3: 45 85% 55%;
--chart-4: 195 65% 55%;
--chart-5: 335 60% 55%;
}
.light.theme-cuba {
--background: 30 30% 96%;
--foreground: 25 40% 15%;
--card: 0 0% 100%;
--card-foreground: 25 40% 15%;
--popover: 0 0% 100%;
--popover-foreground: 25 40% 15%;
--primary: 15 80% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 185 55% 45%;
--secondary-foreground: 0 0% 100%;
--muted: 30 15% 90%;
--muted-foreground: 25 10% 45%;
--accent: 195 60% 50%;
--accent-foreground: 0 0% 0%;
--destructive: 0 72% 50%;
--destructive-foreground: 0 0% 100%;
--success: 145 50% 40%;
--success-foreground: 0 0% 100%;
--warning: 35 85% 50%;
--warning-foreground: 0 0% 0%;
--border: 30 15% 85%;
--input: 30 10% 88%;
--ring: 15 80% 50%;
--radius: 0.75rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 25 40% 15%;
--sidebar-primary: 15 80% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 30 15% 92%;
--sidebar-accent-foreground: 25 40% 15%;
--sidebar-border: 30 15% 85%;
--sidebar-ring: 15 80% 50%;
--chart-1: 15 80% 50%;
--chart-2: 185 55% 45%;
--chart-3: 45 80% 50%;
--chart-4: 195 60% 50%;
--chart-5: 335 55% 50%;
}
/* ── Malecón, Havana at golden hour background ──────────────────── */
.dark.theme-cuba body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: url("https://images.unsplash.com/photo-1584098179114-7962ad5d6653?w=1920&q=80") no-repeat center center fixed;
background-size: cover;
background-attachment: fixed;
background-position: center;
}
.light.theme-cuba body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: hsl(30, 30%, 96%);
}
/* ── Make main containers transparent so body::before shows ──────── */
.dark.theme-cuba .min-h-screen.bg-background {
background: transparent !important;
}
.dark.theme-cuba .min-h-screen.bg-\[\#F8F8F8\],
.dark.theme-cuba .dark\:bg-\[\#0A0A0A\] {
background: transparent !important;
}
.light.theme-cuba .min-h-screen {
background: hsl(30, 30%, 96%) !important;
}
/* ── Frosted glass cards ────────────────────────────────────────── */
.dark.theme-cuba .bg-card,
.dark.theme-cuba .bg-popover,
.dark.theme-cuba .bg-muted {
background-color: hsl(var(--card) / 0.85);
backdrop-filter: blur(12px);
}
.dark.theme-cuba .bg-sidebar {
background-color: hsl(var(--sidebar) / 0.9);
backdrop-filter: blur(12px);
}
.light.theme-cuba .bg-card,
.light.theme-cuba .bg-popover,
.light.theme-cuba .bg-muted {
background-color: hsl(var(--card) / 0.92);
backdrop-filter: blur(8px);
}
.light.theme-cuba .bg-sidebar {
background-color: hsl(var(--sidebar) / 0.95);
backdrop-filter: blur(8px);
}
@media (prefers-reduced-transparency: reduce) {
.theme-cuba .bg-card,
.theme-cuba .bg-popover,
.theme-cuba .bg-muted,
.theme-cuba .bg-sidebar {
backdrop-filter: none;
}
}
+109
View File
@@ -0,0 +1,109 @@
{
"id": "cuba",
"name": "Cuba Theme",
"description": "Classic Cuban car on a playa at golden hour — warm sunset tones with turquoise Caribbean accents.",
"type": "dual",
"default": false,
"dark": {
"colors": {
"background": "hsl(25, 40%, 6%)",
"foreground": "hsl(30, 20%, 90%)",
"card": "hsl(25, 35%, 10%)",
"cardForeground": "hsl(30, 20%, 90%)",
"popover": "hsl(25, 35%, 10%)",
"popoverForeground": "hsl(30, 20%, 90%)",
"primary": "hsl(15, 85%, 55%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(185, 60%, 50%)",
"secondaryForeground": "hsl(0, 0%, 100%)",
"muted": "hsl(25, 20%, 15%)",
"mutedForeground": "hsl(25, 10%, 55%)",
"accent": "hsl(195, 65%, 55%)",
"accentForeground": "hsl(0, 0%, 0%)",
"destructive": "hsl(0, 75%, 50%)",
"destructiveForeground": "hsl(0, 0%, 100%)",
"border": "hsl(25, 25%, 18%)",
"input": "hsl(25, 20%, 15%)",
"ring": "hsl(15, 85%, 55%)",
"sidebar": "hsl(25, 40%, 6%)",
"sidebarForeground": "hsl(30, 20%, 90%)",
"sidebarPrimary": "hsl(15, 85%, 55%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(25, 30%, 12%)",
"sidebarAccentForeground": "hsl(30, 20%, 90%)",
"sidebarBorder": "hsl(25, 25%, 15%)",
"sidebarRing": "hsl(15, 85%, 55%)"
}
},
"light": {
"colors": {
"background": "hsl(30, 30%, 96%)",
"foreground": "hsl(25, 40%, 15%)",
"card": "hsl(0, 0%, 100%)",
"cardForeground": "hsl(25, 40%, 15%)",
"popover": "hsl(0, 0%, 100%)",
"popoverForeground": "hsl(25, 40%, 15%)",
"primary": "hsl(15, 80%, 50%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(185, 55%, 45%)",
"secondaryForeground": "hsl(0, 0%, 100%)",
"muted": "hsl(30, 15%, 90%)",
"mutedForeground": "hsl(25, 10%, 45%)",
"accent": "hsl(195, 60%, 50%)",
"accentForeground": "hsl(0, 0%, 0%)",
"destructive": "hsl(0, 72%, 50%)",
"destructiveForeground": "hsl(0, 0%, 100%)",
"border": "hsl(30, 15%, 85%)",
"input": "hsl(30, 10%, 88%)",
"ring": "hsl(15, 80%, 50%)",
"sidebar": "hsl(0, 0%, 100%)",
"sidebarForeground": "hsl(25, 40%, 15%)",
"sidebarPrimary": "hsl(15, 80%, 50%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(30, 15%, 92%)",
"sidebarAccentForeground": "hsl(25, 40%, 15%)",
"sidebarBorder": "hsl(30, 15%, 85%)",
"sidebarRing": "hsl(15, 80%, 50%)"
}
},
"borderRadius": "0.75rem",
"typography": {
"fontFamily": "Inter, sans-serif",
"headingFont": "Inter, sans-serif"
},
"keyColors": {
"primary": "#e85d28",
"background": "#0f0a06 (dark) / #f5f0eb (light)",
"card": "#1a120c (dark) / #ffffff (light)",
"sidebar": "#0f0a06 (dark) / #ffffff (light)",
"border": "#2a1f15 (dark) / #d9cfc5 (light)",
"text": "#e6ddd4 (dark) / #1a110a (light)",
"mutedText": "#8a7a6a (dark) / #6b5d50 (light)"
},
"sunset": {
"orange": "#e85d28",
"golden": "#f4a340",
"turquoise": "#30c5d2",
"pink": "#e8677a",
"purple": "#8b5cf6",
"cream": "#f5e6d0"
},
"chartSegments": {
"open": { "label": "Open", "color": "#e85d28", "description": "Sunset Orange — 35%" },
"contacted": { "label": "Contacted", "color": "#30c5d2", "description": "Caribbean Turquoise — 25%" },
"pending": { "label": "Pending", "color": "#f4a340", "description": "Golden Hour — 17%" },
"closed": { "label": "Closed", "color": "#50b87a", "description": "Palm Green — 23%" },
"ignored": { "label": "Ignored", "color": "#8b7a6a", "description": "Warm Muted — 0%" }
},
"barChart": {
"newLeads": { "color": "#e85d28", "glow": "rgba(232, 93, 40, 0.5)" },
"closed": { "color": "#30c5d2", "glow": "rgba(48, 197, 210, 0.4)" }
},
"statusBadges": {
"open": { "color": "#30c5d2", "label": "Turquoise" },
"contacted": { "color": "#e8677a", "label": "Sunset Pink" },
"pending": { "color": "#f4a340", "label": "Golden" },
"closed": { "color": "#50b87a", "label": "Palm Green" },
"ignored": { "color": "#8b7a6a", "label": "Warm Muted" }
}
}
+229
View File
@@ -318,3 +318,232 @@
color: #1BB0CE !important; color: #1BB0CE !important;
background: none !important; background: none !important;
} }
/* ============================================================================
Charts — Donut (LeadStatus) & Bar (LeadsPerMonth) — Cyber2 colors
============================================================================ */
/* ---- SVG gradient overrides (donut chart segments) ---- */
.dark.theme-cyber2 #chart-grad-0 stop { stop-color: hsl(180, 100%, 50%) !important; }
.dark.theme-cyber2 #chart-grad-1 stop { stop-color: hsl(280, 80%, 55%) !important; }
.dark.theme-cyber2 #chart-grad-2 stop { stop-color: hsl(310, 90%, 60%) !important; }
.dark.theme-cyber2 #chart-grad-3 stop { stop-color: hsl(170, 100%, 45%) !important; }
.dark.theme-cyber2 #chart-grad-4 stop { stop-color: hsl(180, 20%, 40%) !important; }
.light.theme-cyber2 #chart-grad-0 stop { stop-color: hsl(180, 80%, 40%) !important; }
.light.theme-cyber2 #chart-grad-1 stop { stop-color: hsl(280, 60%, 50%) !important; }
.light.theme-cyber2 #chart-grad-2 stop { stop-color: hsl(310, 70%, 55%) !important; }
.light.theme-cyber2 #chart-grad-3 stop { stop-color: hsl(170, 80%, 38%) !important; }
.light.theme-cyber2 #chart-grad-4 stop { stop-color: hsl(180, 15%, 50%) !important; }
/* ---- SVG gradient overrides (bar chart) ---- */
.dark.theme-cyber2 #newLeadsGrad stop { stop-color: hsl(180, 100%, 50%) !important; }
.dark.theme-cyber2 #closedGrad stop { stop-color: hsl(280, 80%, 55%) !important; }
.light.theme-cyber2 #newLeadsGrad stop { stop-color: hsl(180, 80%, 40%) !important; }
.light.theme-cyber2 #closedGrad stop { stop-color: hsl(280, 60%, 50%) !important; }
/* ---- Inline chart colors (legend dots, hover text) ---- */
.dark.theme-cyber2 [style*="background: #3b82f6"],
.dark.theme-cyber2 [style*="background:#3b82f6"] { background: hsl(180, 100%, 50%) !important; }
.dark.theme-cyber2 [style*="background: #f59e0b"],
.dark.theme-cyber2 [style*="background:#f59e0b"] { background: hsl(280, 80%, 55%) !important; }
.dark.theme-cyber2 [style*="background: #8b5cf6"],
.dark.theme-cyber2 [style*="background:#8b5cf6"] { background: hsl(310, 90%, 60%) !important; }
.dark.theme-cyber2 [style*="background: #10b981"],
.dark.theme-cyber2 [style*="background:#10b981"] { background: hsl(170, 100%, 45%) !important; }
.dark.theme-cyber2 [style*="background: #6B7280"],
.dark.theme-cyber2 [style*="background:#6B7280"] { background: hsl(180, 20%, 40%) !important; }
.light.theme-cyber2 [style*="background: #3b82f6"],
.light.theme-cyber2 [style*="background:#3b82f6"] { background: hsl(180, 80%, 40%) !important; }
.light.theme-cyber2 [style*="background: #f59e0b"],
.light.theme-cyber2 [style*="background:#f59e0b"] { background: hsl(280, 60%, 50%) !important; }
.light.theme-cyber2 [style*="background: #8b5cf6"],
.light.theme-cyber2 [style*="background:#8b5cf6"] { background: hsl(310, 70%, 55%) !important; }
.light.theme-cyber2 [style*="background: #10b981"],
.light.theme-cyber2 [style*="background:#10b981"] { background: hsl(170, 80%, 38%) !important; }
.light.theme-cyber2 [style*="background: #6B7280"],
.light.theme-cyber2 [style*="background:#6B7280"] { background: hsl(180, 15%, 50%) !important; }
/* ---- Chart legend hover borders ---- */
.dark.theme-cyber2 [style*="border: 1px solid #3b82f6"],
.dark.theme-cyber2 [style*="border:1px solid #3b82f6"] { border-color: hsl(180, 100%, 50%) !important; }
.dark.theme-cyber2 [style*="border: 1px solid #f59e0b"],
.dark.theme-cyber2 [style*="border:1px solid #f59e0b"] { border-color: hsl(280, 80%, 55%) !important; }
.dark.theme-cyber2 [style*="border: 1px solid #8b5cf6"],
.dark.theme-cyber2 [style*="border:1px solid #8b5cf6"] { border-color: hsl(310, 90%, 60%) !important; }
.dark.theme-cyber2 [style*="border: 1px solid #10b981"],
.dark.theme-cyber2 [style*="border:1px solid #10b981"] { border-color: hsl(170, 100%, 45%) !important; }
.dark.theme-cyber2 [style*="border: 1px solid #6B7280"],
.dark.theme-cyber2 [style*="border:1px solid #6B7280"] { border-color: hsl(180, 20%, 40%) !important; }
/* ---- Donut chart segment text color on hover ---- */
.dark.theme-cyber2 [style*="color: #3b82f6"] { color: hsl(180, 100%, 50%) !important; }
.dark.theme-cyber2 [style*="color: #f59e0b"] { color: hsl(280, 80%, 55%) !important; }
.dark.theme-cyber2 [style*="color: #8b5cf6"] { color: hsl(310, 90%, 60%) !important; }
.dark.theme-cyber2 [style*="color: #10b981"] { color: hsl(170, 100%, 45%) !important; }
.dark.theme-cyber2 [style*="color: #6B7280"] { color: hsl(180, 20%, 40%) !important; }
.light.theme-cyber2 [style*="color: #3b82f6"] { color: hsl(180, 80%, 40%) !important; }
.light.theme-cyber2 [style*="color: #f59e0b"] { color: hsl(280, 60%, 50%) !important; }
.light.theme-cyber2 [style*="color: #8b5cf6"] { color: hsl(310, 70%, 55%) !important; }
.light.theme-cyber2 [style*="color: #10b981"] { color: hsl(170, 80%, 38%) !important; }
.light.theme-cyber2 [style*="color: #6B7280"] { color: hsl(180, 15%, 50%) !important; }
/* ============================================================================
Stat Cards — Icon backgrounds, text colors, progress bars
============================================================================ */
.dark.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
background: hsla(180, 100%, 50%, 0.12) !important;
}
.dark.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
color: hsl(180, 100%, 50%) !important;
}
.dark.theme-cyber2 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(280, 80%, 55%)) !important;
}
.dark.theme-cyber2 .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
background: linear-gradient(to right, transparent, hsla(180, 100%, 50%, 0.2), transparent) !important;
}
.light.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
background: hsla(180, 80%, 40%, 0.1) !important;
}
.light.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
color: hsl(180, 80%, 40%) !important;
}
.light.theme-cyber2 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
background: linear-gradient(to right, hsl(180, 80%, 40%), hsl(280, 60%, 50%)) !important;
}
.light.theme-cyber2 .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
background: linear-gradient(to right, transparent, hsla(180, 80%, 40%, 0.15), transparent) !important;
}
/* ============================================================================
Inline hex color overrides — stat card values, page icons, etc.
============================================================================ */
.dark.theme-cyber2 .text-\[#C84B4B\], .dark.theme-cyber2 .dark\:text-\[\#C84B4B\] { color: hsl(180, 100%, 50%) !important; }
.dark.theme-cyber2 .text-\[#5A8FC4\], .dark.theme-cyber2 .dark\:text-\[\#5A8FC4\] { color: hsl(280, 80%, 55%) !important; }
.dark.theme-cyber2 .text-\[#FF1111\], .dark.theme-cyber2 .dark\:text-\[\#FF1111\] { color: hsl(180, 100%, 55%) !important; }
.dark.theme-cyber2 .text-\[#1144FF\], .dark.theme-cyber2 .dark\:text-\[\#1144FF\] { color: hsl(280, 80%, 60%) !important; }
.light.theme-cyber2 .text-\[#C84B4B\], .light.theme-cyber2 .dark\:text-\[\#C84B4B\] { color: hsl(180, 80%, 40%) !important; }
.light.theme-cyber2 .text-\[#5A8FC4\], .light.theme-cyber2 .dark\:text-\[\#5A8FC4\] { color: hsl(280, 60%, 50%) !important; }
.dark.theme-cyber2 .bg-\[#C84B4B\]\/10, .dark.theme-cyber2 .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(180, 100%, 50%, 0.1) !important; }
.dark.theme-cyber2 .bg-\[#5A8FC4\]\/10, .dark.theme-cyber2 .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(280, 80%, 55%, 0.1) !important; }
.light.theme-cyber2 .bg-\[#C84B4B\]\/10, .light.theme-cyber2 .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(180, 80%, 40%, 0.08) !important; }
.light.theme-cyber2 .bg-\[#5A8FC4\]\/10, .light.theme-cyber2 .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(280, 60%, 50%, 0.08) !important; }
.dark.theme-cyber2 .from-\[#C84B4B\], .dark.theme-cyber2 .dark\:from-\[\#C84B4B\],
.dark.theme-cyber2 .via-\[#C84B4B\], .dark.theme-cyber2 .dark\:via-\[\#C84B4B\],
.dark.theme-cyber2 .to-\[#C84B4B\], .dark.theme-cyber2 .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(180, 100%, 50%) !important; }
.dark.theme-cyber2 .from-\[#5A8FC4\], .dark.theme-cyber2 .dark\:from-\[\#5A8FC4\],
.dark.theme-cyber2 .via-\[#5A8FC4\], .dark.theme-cyber2 .dark\:via-\[\#5A8FC4\],
.dark.theme-cyber2 .to-\[#5A8FC4\], .dark.theme-cyber2 .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(280, 80%, 55%) !important; }
.light.theme-cyber2 .from-\[#C84B4B\], .light.theme-cyber2 .dark\:from-\[\#C84B4B\],
.light.theme-cyber2 .via-\[#C84B4B\], .light.theme-cyber2 .dark\:via-\[\#C84B4B\],
.light.theme-cyber2 .to-\[#C84B4B\], .light.theme-cyber2 .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(180, 80%, 40%) !important; }
.light.theme-cyber2 .from-\[#5A8FC4\], .light.theme-cyber2 .dark\:from-\[\#5A8FC4\],
.light.theme-cyber2 .via-\[#5A8FC4\], .light.theme-cyber2 .dark\:via-\[\#5A8FC4\],
.light.theme-cyber2 .to-\[#5A8FC4\], .light.theme-cyber2 .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(280, 60%, 50%) !important; }
/* ---- SVG stroke/fill overrides ---- */
.dark.theme-cyber2 [stroke="#CC0000"] { stroke: hsl(180, 100%, 50%) !important; }
.dark.theme-cyber2 [stroke="#0033CC"] { stroke: hsl(280, 80%, 55%) !important; }
.light.theme-cyber2 [stroke="#CC0000"] { stroke: hsl(180, 80%, 40%) !important; }
.light.theme-cyber2 [stroke="#0033CC"] { stroke: hsl(280, 60%, 50%) !important; }
/* ============================================================================
Light mode — deeper visual polish
============================================================================ */
.light.theme-cyber2 .rounded-xl.border.bg-card {
box-shadow: 0 1px 3px hsla(180, 80%, 30%, 0.08), 0 4px 12px hsla(180, 80%, 30%, 0.04) !important;
}
.light.theme-cyber2 aside .group:hover {
background: hsla(180, 80%, 40%, 0.04) !important;
}
.light.theme-cyber2 .hover\:bg-accent:hover {
background: hsla(180, 80%, 40%, 0.06) !important;
}
/* ============================================================================
Client Portal Overrides — Background for portal pages
============================================================================ */
/* Make the outer portal container transparent so body::before background shows */
.dark.theme-cyber2 .min-h-screen.bg-background {
background: transparent !important;
}
/* Portal sidebar — dark glass */
.dark.theme-cyber2 aside.bg-card.border-r.border-border {
background: hsl(240, 15%, 3%) !important;
border-right-color: hsl(270, 25%, 10%) !important;
}
/* Portal sidebar nav items — muted text */
.dark.theme-cyber2 aside .text-muted-foreground {
color: hsl(240, 8%, 50%) !important;
}
.dark.theme-cyber2 aside button:hover {
background: hsla(330, 100%, 50%, 0.08) !important;
color: hsl(0, 0%, 90%) !important;
}
/* Portal sidebar active nav item */
.dark.theme-cyber2 aside .bg-primary\/10.text-primary {
background: hsl(330, 100%, 50%) !important;
color: hsl(0, 0%, 100%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.1);
border-color: hsl(330, 100%, 50%) !important;
}
.dark.theme-cyber2 aside .bg-primary\/10.text-primary svg {
color: hsl(0, 0%, 100%) !important;
}
/* Portal header — dark glass */
.dark.theme-cyber2 header.bg-card\/80 {
background: hsl(240, 15%, 3%) !important;
border-bottom-color: hsl(270, 25%, 10%) !important;
}
/* Portal card backgrounds — semi-transparent glass */
.dark.theme-cyber2 .bg-card {
background: hsla(240, 15%, 7%, 0.65) !important;
backdrop-filter: blur(10px) !important;
border-color: hsla(270, 30%, 20%, 0.35) !important;
}
/* Portal borders */
.dark.theme-cyber2 .border-border {
border-color: hsl(270, 25%, 12%) !important;
}
/* Portal input fields */
.dark.theme-cyber2 .bg-muted {
background: hsl(240, 12%, 9%) !important;
}
/* Portal primary buttons */
.dark.theme-cyber2 .bg-primary {
background: hsl(330, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
}
.dark.theme-cyber2 .bg-primary:hover {
background: hsl(330, 100%, 55%) !important;
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
}
+382 -37
View File
@@ -38,34 +38,38 @@
--sidebar-ring: 330 100% 50%; --sidebar-ring: 330 100% 50%;
} }
.light.theme-cyberpunk { .light.theme-cyberpunk {
--background: 220 15% 96%; --background: 0 0% 98%;
--foreground: 240 30% 10%; --foreground: 240 10% 15%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 240 30% 10%; --card-foreground: 240 10% 15%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 240 30% 10%; --popover-foreground: 240 10% 15%;
--primary: 330 80% 48%; --primary: 330 100% 50%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 180 80% 38%; --secondary: 180 100% 50%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 0%;
--muted: 240 8% 92%; --muted: 240 5% 94%;
--muted-foreground: 240 8% 48%; --muted-foreground: 240 4% 48%;
--accent: 260 65% 52%; --accent: 260 80% 60%;
--accent-foreground: 0 0% 100%; --accent-foreground: 0 0% 100%;
--destructive: 0 80% 55%; --destructive: 0 84% 55%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 270 15% 84%; --success: 110 100% 50%;
--input: 240 8% 92%; --success-foreground: 0 0% 0%;
--ring: 330 80% 48%; --warning: 30 100% 55%;
--warning-foreground: 0 0% 0%;
--border: 270 10% 88%;
--input: 240 5% 92%;
--ring: 330 100% 50%;
--radius: 0.5rem; --radius: 0.5rem;
--sidebar: 0 0% 100%; --sidebar: 0 0% 100%;
--sidebar-foreground: 240 30% 10%; --sidebar-foreground: 240 10% 15%;
--sidebar-primary: 330 80% 48%; --sidebar-primary: 330 100% 50%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 8% 92%; --sidebar-accent: 240 5% 94%;
--sidebar-accent-foreground: 240 30% 10%; --sidebar-accent-foreground: 240 10% 15%;
--sidebar-border: 270 15% 84%; --sidebar-border: 270 10% 88%;
--sidebar-ring: 330 80% 48%; --sidebar-ring: 330 100% 50%;
} }
/* ============================================================================ /* ============================================================================
@@ -89,11 +93,7 @@
inset: 0; inset: 0;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background: background: hsl(0, 0%, 98%);
repeating-linear-gradient(90deg, transparent 0px, transparent 59px, rgba(255, 0, 127, 0.03) 59px, transparent 61px),
repeating-linear-gradient(0deg, transparent 0px, transparent 39px, rgba(0, 200, 255, 0.02) 39px, transparent 41px);
background-size: 200% 200%;
animation: cyberpunk-bg 50s ease-in-out infinite;
} }
@keyframes cyberpunk-bg { @keyframes cyberpunk-bg {
@@ -106,12 +106,17 @@
/* ============================================================================ /* ============================================================================
App Shell Make the background transparent so body::before shows through App Shell Make the background transparent so body::before shows through
============================================================================ */ ============================================================================ */
.dark.theme-cyberpunk .min-h-screen.bg-\[\#F8F8F8\] { .dark.theme-cyberpunk .min-h-screen.bg-\[\#F8F8F8\],
.light.theme-cyberpunk .min-h-screen.bg-\[\#F8F8F8\] {
background: transparent !important; background: transparent !important;
} }
.dark.theme-cyberpunk .dark\:bg-\[\#0A0A0A\] { .dark.theme-cyberpunk .dark\:bg-\[\#0A0A0A\],
.light.theme-cyberpunk .dark\:bg-\[\#0A0A0A\] {
background: transparent !important; background: transparent !important;
} }
.light.theme-cyberpunk .min-h-screen {
background: hsl(0, 0%, 98%) !important;
}
/* ============================================================================ /* ============================================================================
Top Navigation & Header Bar Top Navigation & Header Bar
@@ -152,24 +157,32 @@
/* ============================================================================ /* ============================================================================
Page Header Dashboard title Page Header Dashboard title
============================================================================ */ ============================================================================ */
.dark.theme-cyberpunk .border-l-4.border-\[\#C84B4B\] { .dark.theme-cyberpunk .border-l-4.border-\[\#C84B4B\],
.light.theme-cyberpunk .border-l-4.border-\[\#C84B4B\] {
border-left-color: hsl(330, 100%, 50%) !important; border-left-color: hsl(330, 100%, 50%) !important;
} }
.dark.theme-cyberpunk .dark\:border-\[\#FF1111\] { .dark.theme-cyberpunk .dark\:border-\[\#FF1111\],
.light.theme-cyberpunk .dark\:border-\[\#FF1111\] {
border-left-color: hsl(330, 100%, 50%) !important; border-left-color: hsl(330, 100%, 50%) !important;
} }
.dark.theme-cyberpunk .dark\:text-white.text-2xl.font-bold { .dark.theme-cyberpunk .dark\:text-white.text-2xl.font-bold {
color: hsl(0, 0%, 97%) !important; color: hsl(0, 0%, 97%) !important;
text-shadow: 0 0 6px rgba(255, 0, 127, 0.15); text-shadow: 0 0 6px rgba(255, 0, 127, 0.15);
} }
.dark.theme-cyberpunk .dark\:text-\[\#AAAAAA\] { .light.theme-cyberpunk .text-2xl.font-bold {
color: hsl(220, 20%, 12%) !important;
font-weight: 800 !important;
}
.dark.theme-cyberpunk .dark\:text-\[\#AAAAAA\],
.light.theme-cyberpunk .text-\[\#AAAAAA\] {
color: hsl(240, 8%, 50%) !important; color: hsl(240, 8%, 50%) !important;
} }
/* ============================================================================ /* ============================================================================
Section Subtitles "Pipeline Overview" / "Analytics" Section Subtitles "Pipeline Overview" / "Analytics"
============================================================================ */ ============================================================================ */
.dark.theme-cyberpunk .dark\:text-\[\#666666\].text-xs.font-semibold.tracking-widest { .dark.theme-cyberpunk .dark\:text-\[\#666666\].text-xs.font-semibold.tracking-widest,
.light.theme-cyberpunk .text-xs.font-semibold.tracking-widest {
color: hsl(240, 8%, 38%) !important; color: hsl(240, 8%, 38%) !important;
letter-spacing: 0.18em; letter-spacing: 0.18em;
} }
@@ -257,7 +270,8 @@
============================================================================ */ ============================================================================ */
/* Pipeline Overview metric cards semi-transparent glass + neon-pink glow border */ /* Pipeline Overview metric cards semi-transparent glass + neon-pink glow border */
.dark.theme-cyberpunk .group.h-full > .rounded-xl.border { .dark.theme-cyberpunk .group.h-full > .rounded-xl.border,
.light.theme-cyberpunk .group.h-full > .rounded-xl.border {
background: rgba(20, 20, 25, 0.75) !important; background: rgba(20, 20, 25, 0.75) !important;
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
border: 2px solid hsl(330, 100%, 50%) !important; border: 2px solid hsl(330, 100%, 50%) !important;
@@ -267,7 +281,8 @@
0 -2px 8px rgba(255, 0, 127, 0.15), 0 -2px 8px rgba(255, 0, 127, 0.15),
0 4px 24px rgba(0, 0, 0, 0.5) !important; 0 4px 24px rgba(0, 0, 0, 0.5) !important;
} }
.dark.theme-cyberpunk .group.h-full:hover > .rounded-xl.border { .dark.theme-cyberpunk .group.h-full:hover > .rounded-xl.border,
.light.theme-cyberpunk .group.h-full:hover > .rounded-xl.border {
border-color: hsl(330, 100%, 55%) !important; border-color: hsl(330, 100%, 55%) !important;
box-shadow: 0 0 0 2px hsla(330, 100%, 50%, 0.5), box-shadow: 0 0 0 2px hsla(330, 100%, 50%, 0.5),
0 0 20px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.3),
@@ -277,13 +292,15 @@
} }
/* Neon-pink top border strip on every card */ /* Neon-pink top border strip on every card */
.dark.theme-cyberpunk .h-1.w-full.bg-gradient-to-r.shadow-sm { .dark.theme-cyberpunk .h-1.w-full.bg-gradient-to-r.shadow-sm,
.light.theme-cyberpunk .h-1.w-full.bg-gradient-to-r.shadow-sm {
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(330, 100%, 65%), hsl(330, 100%, 50%)) !important; background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(330, 100%, 65%), hsl(330, 100%, 50%)) !important;
box-shadow: 0 0 8px rgba(255, 0, 127, 0.3), 0 0 16px rgba(255, 0, 127, 0.1) !important; box-shadow: 0 0 8px rgba(255, 0, 127, 0.3), 0 0 16px rgba(255, 0, 127, 0.1) !important;
} }
/* Stat value bright white with subtle glow */ /* Stat value bright white with subtle glow */
.dark.theme-cyberpunk .text-3xl.font-bold.tracking-tight { .dark.theme-cyberpunk .text-3xl.font-bold.tracking-tight,
.light.theme-cyberpunk .text-3xl.font-bold.tracking-tight {
color: hsl(0, 0%, 97%) !important; color: hsl(0, 0%, 97%) !important;
text-shadow: 0 0 8px rgba(255, 0, 127, 0.1); text-shadow: 0 0 8px rgba(255, 0, 127, 0.1);
} }
@@ -336,7 +353,8 @@
} }
/* Bottom glow bar on cards */ /* Bottom glow bar on cards */
.dark.theme-cyberpunk .absolute.bottom-0.left-0.right-0.h-1 { .dark.theme-cyberpunk .absolute.bottom-0.left-0.right-0.h-1,
.light.theme-cyberpunk .absolute.bottom-0.left-0.right-0.h-1 {
background: linear-gradient(to right, transparent, hsla(330, 100%, 50%, 0.25), transparent) !important; background: linear-gradient(to right, transparent, hsla(330, 100%, 50%, 0.25), transparent) !important;
} }
@@ -829,7 +847,10 @@
============================================================================ */ ============================================================================ */
.dark.theme-cyberpunk .grid.grid-cols-1.md\:grid-cols-2.xl\:grid-cols-3.gap-4 > div, .dark.theme-cyberpunk .grid.grid-cols-1.md\:grid-cols-2.xl\:grid-cols-3.gap-4 > div,
.dark.theme-cyberpunk [class*="bg-card"], .dark.theme-cyberpunk [class*="bg-card"],
.dark.theme-cyberpunk .rounded-xl.border.bg-card { .dark.theme-cyberpunk .rounded-xl.border.bg-card,
.light.theme-cyberpunk .grid.grid-cols-1.md\:grid-cols-2.xl\:grid-cols-3.gap-4 > div,
.light.theme-cyberpunk [class*="bg-card"],
.light.theme-cyberpunk .rounded-xl.border.bg-card {
border-top: 2px solid hsl(330, 100%, 50%) !important; border-top: 2px solid hsl(330, 100%, 50%) !important;
border-bottom: 2px solid hsl(330, 100%, 50%) !important; border-bottom: 2px solid hsl(330, 100%, 50%) !important;
box-shadow: 0 4px 20px rgba(255, 0, 127, 0.15) !important; box-shadow: 0 4px 20px rgba(255, 0, 127, 0.15) !important;
@@ -929,6 +950,11 @@
.dark.theme-cyberpunk .dark\:text-\[\#666666\].text-xs.font-semibold.tracking-widest.uppercase { .dark.theme-cyberpunk .dark\:text-\[\#666666\].text-xs.font-semibold.tracking-widest.uppercase {
color: hsl(210, 20%, 88%) !important; color: hsl(210, 20%, 88%) !important;
} }
.light.theme-cyberpunk .text-xs.font-semibold.tracking-widest.uppercase {
color: hsl(220, 15%, 20%) !important;
font-weight: 700 !important;
letter-spacing: 0.18em;
}
/* ============================================================================ /* ============================================================================
Pipeline Color Bars — Cyberpunk neon pink/cyan Pipeline Color Bars — Cyberpunk neon pink/cyan
@@ -969,3 +995,322 @@
color: #1BB0CE !important; color: #1BB0CE !important;
background: none !important; background: none !important;
} }
/* Progress bar background track — light theme */
.light.theme-cyberpunk .w-full.h-1\.5.bg-muted.rounded-full {
background: hsl(240, 5%, 90%) !important;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08) !important;
}
/* Progress bar fill — red/faded-red → neon pink */
.light.theme-cyberpunk .h-full.rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(180, 100%, 50%)) !important;
box-shadow: 0 0 6px rgba(255, 0, 127, 0.3) !important;
}
/* Progress bar fill — blue → neon cyan */
.light.theme-cyberpunk .h-full.rounded-full {
background: hsl(180, 100%, 50%) !important;
}
/* Percentage text above progress bars — dark slate */
.light.theme-cyberpunk .text-sm.font-semibold {
color: hsl(220, 15%, 20%) !important;
}
/* ============================================================================
LIGHT THEME — Metric Card Icons & Indicator Lines Override
============================================================================ */
.light.theme-cyberpunk .grid.grid-cols-1.md\:grid-cols-2.xl\:grid-cols-3.gap-4 > div,
.light.theme-cyberpunk [class*="bg-card"],
.light.theme-cyberpunk .rounded-xl.border.bg-card {
border-top: 2px solid hsl(330, 100%, 50%) !important;
border-bottom: 2px solid hsl(330, 100%, 50%) !important;
box-shadow: 0 4px 20px rgba(255, 0, 127, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06) !important;
background: hsl(0, 0%, 100%) !important;
}
.light.theme-cyberpunk .group.h-full > .rounded-xl.border {
background: hsl(0, 0%, 100%) !important;
border: 2px solid hsl(330, 100%, 50%) !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 20px rgba(255, 0, 127, 0.1) !important;
}
.light.theme-cyberpunk .group.h-full:hover > .rounded-xl.border {
border-color: hsl(330, 100%, 55%) !important;
box-shadow: 0 4px 24px rgba(255, 0, 127, 0.15), 0 1px 3px rgba(0, 0, 0, 0.08) !important;
}
.light.theme-cyberpunk .h-1.w-full.bg-gradient-to-r.shadow-sm {
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(330, 100%, 65%), hsl(330, 100%, 50%)) !important;
box-shadow: 0 0 8px rgba(255, 0, 127, 0.2) !important;
}
.light.theme-cyberpunk .text-3xl.font-bold.tracking-tight {
color: hsl(240, 10%, 15%) !important;
}
.light.theme-cyberpunk .text-sm.font-medium.text-muted-foreground {
color: hsl(240, 4%, 48%) !important;
}
.light.theme-cyberpunk .flex.h-12.w-12.items-center.justify-center.rounded-xl {
background: hsla(330, 100%, 50%, 0.08) !important;
}
.light.theme-cyberpunk .flex.h-12.w-12.items-center.justify-center.rounded-xl svg.h-6 {
color: hsl(330, 100%, 50%) !important;
}
.light.theme-cyberpunk .absolute.bottom-0.left-0.right-0.h-1 {
background: linear-gradient(to right, transparent, hsla(330, 100%, 50%, 0.2), transparent) !important;
}
/* Pipeline accent lines — odd cards neon pink */
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(330, 100%, 65%), hsl(330, 100%, 50%)) !important;
box-shadow: 0 0 8px rgba(255, 0, 127, 0.3) !important;
}
/* Pipeline accent lines — even cards neon cyan */
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(180, 100%, 65%), hsl(180, 100%, 50%)) !important;
box-shadow: 0 0 8px rgba(0, 240, 255, 0.3) !important;
}
/* Icon containers — odd neon pink, even neon cyan */
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(330, 100%, 50%, 0.12) !important;
}
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(180, 100%, 50%, 0.1) !important;
}
/* Icon SVG colors — odd neon pink, even neon cyan */
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
color: hsl(330, 100%, 50%) !important;
}
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(180, 100%, 50%) !important;
}
/* Bottom glow bars — odd neon pink, even neon cyan */
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
background: linear-gradient(to right, transparent, hsla(330, 100%, 50%, 0.3), transparent) !important;
}
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
background: linear-gradient(to right, transparent, hsla(180, 100%, 50%, 0.25), transparent) !important;
}
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(180, 100%, 50%)) !important;
}
/* ============================================================================
LIGHT THEME — Donut Chart Segment Colors (mirrors dark mode)
============================================================================ */
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(1) {
fill: hsl(285, 100%, 50%) !important;
filter: drop-shadow(0 0 8px rgba(189, 0, 255, 0.4));
}
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(2) {
fill: hsl(186, 100%, 50%) !important;
filter: drop-shadow(0 0 8px rgba(0, 229, 255, 0.4));
}
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(3) {
fill: hsl(51, 100%, 50%) !important;
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.4));
}
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(4) {
fill: hsl(110, 100%, 50%) !important;
filter: drop-shadow(0 0 8px rgba(57, 255, 20, 0.4));
}
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(5) {
fill: hsl(340, 100%, 50%) !important;
filter: drop-shadow(0 0 6px rgba(255, 0, 85, 0.3));
}
/* Legend dots — match dark mode colors */
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(1) .rounded-full {
background: hsl(285, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(189, 0, 255, 0.5) !important;
}
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(2) .rounded-full {
background: hsl(186, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(0, 229, 255, 0.5) !important;
}
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(3) .rounded-full {
background: hsl(51, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.5) !important;
}
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(4) .rounded-full {
background: hsl(110, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(57, 255, 20, 0.5) !important;
}
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(5) .rounded-full {
background: hsl(340, 100%, 50%) !important;
box-shadow: 0 0 8px rgba(255, 0, 85, 0.4) !important;
}
/* ============================================================================
LIGHT THEME — Bar Chart (mirrors dark mode)
============================================================================ */
.light.theme-cyberpunk svg rect[fill*="newLeadsGrad"] {
fill: hsl(330, 100%, 50%) !important;
filter: drop-shadow(0 0 8px rgba(255, 0, 127, 0.5)) !important;
rx: 4;
}
.light.theme-cyberpunk svg rect[fill*="closedGrad"] {
fill: hsl(186, 100%, 50%) !important;
filter: drop-shadow(0 0 8px rgba(0, 229, 255, 0.4)) !important;
rx: 4;
}
.light.theme-cyberpunk .items-start .gap-4 > :first-child .rounded-sm {
background: hsl(330, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.7), 0 0 20px rgba(255, 0, 127, 0.25) !important;
border: 1px solid hsl(330, 100%, 60%) !important;
}
.light.theme-cyberpunk .items-start .gap-4 > :nth-child(2) .rounded-sm {
background: hsl(186, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(0, 229, 255, 0.6), 0 0 20px rgba(0, 229, 255, 0.2) !important;
border: 1px solid hsl(186, 100%, 55%) !important;
}
.light.theme-cyberpunk .items-start .gap-4 > :first-child .text-xs {
color: hsl(330, 100%, 65%) !important;
font-weight: 600;
}
.light.theme-cyberpunk .items-start .gap-4 > :nth-child(2) .text-xs {
color: hsl(186, 100%, 60%) !important;
font-weight: 600;
}
/* ============================================================================
LIGHT THEME — Status Badges (mirrors dark mode neon pills)
============================================================================ */
.light.theme-cyberpunk [class*="bg-blue-500/10"] {
background: hsla(186, 100%, 50%, 0.12) !important;
color: hsl(186, 100%, 40%) !important;
border-color: hsla(186, 100%, 50%, 0.25) !important;
border-radius: 9999px !important;
}
.light.theme-cyberpunk [class*="bg-amber-500/10"] {
background: hsla(285, 100%, 50%, 0.12) !important;
color: hsl(285, 100%, 45%) !important;
border-color: hsla(285, 100%, 50%, 0.25) !important;
border-radius: 9999px !important;
}
.light.theme-cyberpunk [class*="bg-purple-500/10"] {
background: hsla(40, 100%, 55%, 0.12) !important;
color: hsl(40, 90%, 40%) !important;
border-color: hsla(40, 100%, 55%, 0.25) !important;
border-radius: 9999px !important;
}
.light.theme-cyberpunk [class*="bg-emerald-500/10"] {
background: hsla(350, 75%, 45%, 0.12) !important;
color: hsl(350, 75%, 40%) !important;
border-color: hsla(350, 75%, 45%, 0.25) !important;
border-radius: 9999px !important;
}
.light.theme-cyberpunk [class*="bg-zinc-500/10"] {
background: hsla(0, 0%, 40%, 0.08) !important;
color: hsl(0, 0%, 40%) !important;
border-color: hsla(0, 0%, 40%, 0.15) !important;
border-radius: 9999px !important;
}
.light.theme-cyberpunk .text-blue-600.dark\:text-blue-400 {
color: hsl(186, 100%, 40%) !important;
}
.light.theme-cyberpunk .text-amber-600.dark\:text-amber-400 {
color: hsl(285, 100%, 45%) !important;
}
.light.theme-cyberpunk .text-purple-600.dark\:text-purple-400 {
color: hsl(40, 90%, 40%) !important;
}
.light.theme-cyberpunk .text-emerald-600.dark\:text-emerald-400 {
color: hsl(350, 75%, 40%) !important;
}
.light.theme-cyberpunk .text-zinc-600.dark\:text-zinc-400 {
color: hsl(0, 0%, 40%) !important;
}
/* Status dots — light theme */
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-blue-500 {
background: hsl(186, 100%, 50%) !important;
}
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-amber-500 {
background: hsl(285, 100%, 50%) !important;
}
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-purple-500 {
background: hsl(40, 100%, 55%) !important;
}
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-emerald-500 {
background: hsl(350, 75%, 45%) !important;
}
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-zinc-500 {
background: hsl(0, 0%, 40%) !important;
}
/* ============================================================================
Client Portal Overrides — Cyberpunk background for portal pages
============================================================================ */
/* Make the outer portal container transparent so body::before background shows */
.dark.theme-cyberpunk .min-h-screen.bg-background {
background: transparent !important;
}
/* Portal sidebar — dark glass */
.dark.theme-cyberpunk aside.bg-card.border-r.border-border {
background: hsl(240, 15%, 3%) !important;
border-right-color: hsl(270, 25%, 10%) !important;
}
/* Portal sidebar nav items — muted text */
.dark.theme-cyberpunk aside .text-muted-foreground {
color: hsl(240, 8%, 50%) !important;
}
.dark.theme-cyberpunk aside button:hover {
background: hsla(330, 100%, 50%, 0.08) !important;
color: hsl(0, 0%, 90%) !important;
}
/* Portal sidebar active nav item — neon pink pill */
.dark.theme-cyberpunk aside .bg-primary\/10.text-primary {
background: hsl(330, 100%, 50%) !important;
color: hsl(0, 0%, 100%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.1);
border-color: hsl(330, 100%, 50%) !important;
}
.dark.theme-cyberpunk aside .bg-primary\/10.text-primary svg {
color: hsl(0, 0%, 100%) !important;
}
/* Portal header — dark glass */
.dark.theme-cyberpunk header.bg-card\/80 {
background: hsl(240, 15%, 3%) !important;
border-bottom-color: hsl(270, 25%, 10%) !important;
}
/* Portal card backgrounds — semi-transparent glass */
.dark.theme-cyberpunk .bg-card {
background: hsla(240, 15%, 7%, 0.65) !important;
backdrop-filter: blur(10px) !important;
border-color: hsla(270, 30%, 20%, 0.35) !important;
}
/* Portal borders */
.dark.theme-cyberpunk .border-border {
border-color: hsl(270, 25%, 12%) !important;
}
/* Portal input fields */
.dark.theme-cyberpunk .bg-muted {
background: hsl(240, 12%, 9%) !important;
}
.dark.theme-cyberpunk .border-border:focus {
border-color: hsl(330, 100%, 50%) !important;
}
/* Portal primary buttons */
.dark.theme-cyberpunk .bg-primary {
background: hsl(330, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
}
.dark.theme-cyberpunk .bg-primary:hover {
background: hsl(330, 100%, 55%) !important;
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
}
/* Portal primary text highlights */
.dark.theme-cyberpunk .text-primary {
color: hsl(330, 100%, 55%) !important;
}
/* Portal muted text */
.dark.theme-cyberpunk .text-muted-foreground {
color: hsl(240, 8%, 50%) !important;
}
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
{
"theme": "forest-minimal",
"name": "Forest Minimal",
"colors": {
"background": "84 25% 97%",
"surface": "0 0% 100%",
"surface-2": "84 20% 96%",
"primary": "84 50% 53%",
"primary-hover": "84 50% 46%",
"primary-light": "84 53% 92%",
"success": "122 39% 49%",
"text-primary": "#1F2937",
"text-secondary": "#6B7280",
"card-border": "#EEF1EC",
"background-dark": "#EAF3E5",
"background-light": "#F5F9F2",
"sage-light": "#E8F0E2",
"mint-pale": "#EEF5EA",
"foliage-dark": "#5A7D5F",
"foliage-mid": "#65896A",
"foliage-light": "#7B9E7D",
"foliage-pale": "#90B090",
"scandi-sage": "#C8DCC8"
}
}
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
{
"theme": {
"id": "luminous",
"name": "Luminous",
"description": "Deep Glass / Glassmorphism 2.0 — blue-teal-amber palette with specular highlights, 3D bevel/emboss borders, refraction effects, and ultra-frosted translucent surfaces inspired by iOS & HarmonyOS",
"type": "dual",
"default": false
},
"modes": {
"dark": {
"colors": {
"background": "hsl(215, 30%, 6%)",
"foreground": "hsl(210, 30%, 96%)",
"card": "hsl(215, 25%, 10%)",
"cardForeground": "hsl(210, 30%, 96%)",
"popover": "hsl(215, 25%, 10%)",
"popoverForeground": "hsl(210, 30%, 96%)",
"primary": "hsl(210, 75%, 55%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(185, 70%, 45%)",
"secondaryForeground": "hsl(0, 0%, 100%)",
"muted": "hsl(215, 15%, 14%)",
"mutedForeground": "hsl(215, 12%, 55%)",
"accent": "hsl(35, 80%, 55%)",
"accentForeground": "hsl(0, 0%, 0%)",
"destructive": "hsl(0, 75%, 50%)",
"destructiveForeground": "hsl(0, 0%, 100%)",
"border": "hsl(215, 18%, 20%)",
"input": "hsl(215, 15%, 16%)",
"ring": "hsl(210, 75%, 55%)",
"sidebar": "hsl(215, 30%, 6%)",
"sidebarForeground": "hsl(210, 30%, 96%)",
"sidebarPrimary": "hsl(210, 75%, 55%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(210, 35%, 16%)",
"sidebarAccentForeground": "hsl(210, 30%, 96%)",
"sidebarBorder": "hsl(215, 18%, 16%)",
"sidebarRing": "hsl(210, 75%, 55%)"
},
"textColors": {
"heading": "linear-gradient(135deg, #60a5fa, #2dd4bf)",
"body": "#e2e8f0",
"muted": "#7a8a9a",
"link": "#60a5fa",
"linkHover": "#f59e0b",
"code": "#2dd4bf",
"placeholder": "#5a6a7a"
},
"backgroundImage": null,
"backgroundGradients": [
"rgba(50, 140, 240, 0.18) at 10% 20%",
"rgba(0, 200, 210, 0.10) at 85% 75%",
"rgba(200, 160, 40, 0.06) at 50% 15%",
"rgba(50, 140, 240, 0.07) at 35% 85%"
]
},
"light": {
"colors": {
"background": "hsl(205, 38%, 86%)",
"foreground": "hsl(210, 30%, 15%)",
"card": "hsl(0, 0%, 100%)",
"cardForeground": "hsl(210, 30%, 15%)",
"popover": "hsl(0, 0%, 100%)",
"popoverForeground": "hsl(210, 30%, 15%)",
"primary": "hsl(210, 65%, 48%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(185, 65%, 38%)",
"secondaryForeground": "hsl(0, 0%, 100%)",
"muted": "hsl(205, 20%, 90%)",
"mutedForeground": "hsl(210, 12%, 48%)",
"accent": "hsl(35, 75%, 50%)",
"accentForeground": "hsl(0, 0%, 0%)",
"destructive": "hsl(0, 75%, 45%)",
"destructiveForeground": "hsl(0, 0%, 100%)",
"border": "hsl(210, 18%, 78%)",
"input": "hsl(210, 15%, 84%)",
"ring": "hsl(210, 65%, 48%)",
"sidebar": "hsl(0, 0%, 100%)",
"sidebarForeground": "hsl(210, 30%, 15%)",
"sidebarPrimary": "hsl(210, 65%, 48%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(205, 18%, 90%)",
"sidebarAccentForeground": "hsl(210, 30%, 15%)",
"sidebarBorder": "hsl(210, 18%, 78%)",
"sidebarRing": "hsl(210, 65%, 48%)"
},
"textColors": {
"heading": "linear-gradient(135deg, #3b82f6, #0d9488)",
"body": "#1e293b",
"muted": "#6b7280",
"link": "#3b82f6",
"linkHover": "#d97706",
"code": "#3b82f6",
"placeholder": "#9ca3af"
},
"backgroundImage": null,
"backgroundGradients": [
"rgba(50, 140, 240, 0.06) at 15% 20%",
"rgba(0, 200, 210, 0.04) at 85% 75%",
"rgba(200, 160, 40, 0.02) at 50% 50%"
]
}
},
"borderRadius": "0.75rem",
"typography": {
"fontFamily": "Inter, sans-serif",
"headingFont": "Inter, sans-serif"
},
"keyColors": {
"primary": "#60a5fa (dark) / #3b82f6 (light)",
"background": "#0b111e (dark) / #d2e2f2 (light)",
"card": "#121a28 (dark) / #ffffff (light)",
"sidebar": "#0b111e (dark) / #ffffff (light)",
"border": "#283040 (dark) / #d4d4dc (light)",
"text": "#e2e8f0 (dark) / #1e293b (light)",
"mutedText": "#7a8a9a (dark) / #6b7280 (light)",
"headingGradient": "#60a5fa → #2dd4bf (dark) / #3b82f6 → #0d9488 (light)"
}
}
+269 -168
View File
@@ -1,70 +1,71 @@
.dark.theme-pumpkin { .dark.theme-pumpkin {
--background: 0 30% 4%; --background: 30 40% 3%;
--foreground: 20 50% 95%; --foreground: 30 30% 92%;
--card: 0 25% 8%; --card: 25 30% 7%;
--card-foreground: 20 50% 95%; --card-foreground: 30 30% 92%;
--popover: 0 25% 8%; --popover: 25 30% 7%;
--popover-foreground: 20 50% 95%; --popover-foreground: 30 30% 92%;
--primary: 10 100% 50%; --primary: 25 95% 50%;
--primary-foreground: 0 0% 100%; --primary-foreground: 30 20% 5%;
--secondary: 30 100% 50%; --secondary: 35 100% 45%;
--secondary-foreground: 0 0% 0%; --secondary-foreground: 30 20% 5%;
--muted: 0 12% 15%; --muted: 25 15% 12%;
--muted-foreground: 20 10% 55%; --muted-foreground: 30 12% 60%;
--accent: 10 100% 50%; --accent: 15 100% 45%;
--accent-foreground: 0 0% 100%; --accent-foreground: 0 0% 100%;
--destructive: 0 70% 50%; --destructive: 0 70% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 100%;
--border: 10 30% 18%; --border: 25 20% 16%;
--input: 10 30% 18%; --input: 25 20% 16%;
--ring: 10 100% 50%; --ring: 25 95% 50%;
--radius: 0.5rem; --radius: 0.5rem;
--sidebar: 0 30% 4%; --sidebar: 25 35% 4%;
--sidebar-foreground: 20 50% 95%; --sidebar-foreground: 30 30% 92%;
--sidebar-primary: 10 100% 50%; --sidebar-primary: 25 95% 50%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 30 20% 5%;
--sidebar-accent: 10 100% 50%; --sidebar-accent: 15 100% 45%;
--sidebar-accent-foreground: 0 0% 100%; --sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 10 30% 15%; --sidebar-border: 25 18% 14%;
--sidebar-ring: 10 100% 50%; --sidebar-ring: 25 95% 50%;
} }
.light.theme-pumpkin { .light.theme-pumpkin {
--background: 20 30% 96%; --background: 30 30% 94%;
--foreground: 10 50% 10%; --foreground: 25 40% 12%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 10 50% 10%; --card-foreground: 25 40% 12%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 10 50% 10%; --popover-foreground: 25 40% 12%;
--primary: 10 90% 45%; --primary: 25 90% 42%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 30 90% 45%; --secondary: 35 90% 42%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 100%;
--muted: 20 15% 88%; --muted: 30 15% 86%;
--muted-foreground: 10 10% 45%; --muted-foreground: 25 10% 42%;
--accent: 10 90% 45%; --accent: 15 90% 42%;
--accent-foreground: 0 0% 100%; --accent-foreground: 0 0% 100%;
--destructive: 0 80% 50%; --destructive: 0 80% 50%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 20 15% 82%; --border: 25 15% 80%;
--input: 20 15% 82%; --input: 25 15% 80%;
--ring: 10 90% 45%; --ring: 25 90% 42%;
--radius: 0.5rem; --radius: 0.5rem;
--sidebar: 0 0% 100%; --sidebar: 0 0% 100%;
--sidebar-foreground: 10 50% 10%; --sidebar-foreground: 25 40% 12%;
--sidebar-primary: 10 90% 45%; --sidebar-primary: 25 90% 42%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 10 90% 45%; --sidebar-accent: 15 90% 42%;
--sidebar-accent-foreground: 0 0% 100%; --sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 20 15% 82%; --sidebar-border: 25 15% 80%;
--sidebar-ring: 10 90% 45%; --sidebar-ring: 25 90% 42%;
} }
/* Warm ambient glow */
.dark.theme-pumpkin body { .dark.theme-pumpkin body {
background: transparent background: transparent
radial-gradient(ellipse 120% 60% at 20% 30%, rgba(200, 0, 0, 0.2) 0%, transparent 60%), radial-gradient(ellipse 100% 55% at 20% 25%, rgba(200, 80, 0, 0.18) 0%, transparent 55%),
radial-gradient(ellipse 80% 50% at 80% 70%, rgba(255, 100, 0, 0.15) 0%, transparent 50%), radial-gradient(ellipse 80% 45% at 80% 75%, rgba(255, 140, 0, 0.12) 0%, transparent 45%),
radial-gradient(ellipse 60% 40% at 50% 50%, rgba(150, 0, 0, 0.1) 0%, transparent 50%); radial-gradient(ellipse 60% 35% at 50% 50%, rgba(180, 60, 0, 0.08) 0%, transparent 40%);
} }
.dark.theme-pumpkin body > div { .dark.theme-pumpkin body > div {
@@ -78,198 +79,298 @@
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background: background:
radial-gradient(ellipse 80% 50% at 15% 20%, rgba(200, 50, 0, 0.04) 0%, transparent 60%), radial-gradient(ellipse 70% 45% at 15% 20%, rgba(200, 80, 0, 0.05) 0%, transparent 55%),
radial-gradient(ellipse 80% 50% at 85% 80%, rgba(255, 100, 0, 0.03) 0%, transparent 60%); radial-gradient(ellipse 70% 45% at 85% 80%, rgba(255, 140, 0, 0.04) 0%, transparent 55%);
} }
/* Pumpkin Spice text glow */
.dark.theme-pumpkin .text-glow { .dark.theme-pumpkin .text-glow {
text-shadow: 0 0 10px hsl(10, 100%, 50%), 0 0 30px hsl(10, 100%, 50%), 0 0 60px rgba(255, 0, 0, 0.5); text-shadow: 0 0 10px hsl(25, 95%, 50%), 0 0 30px hsla(25, 95%, 50%, 0.6), 0 0 60px rgba(200, 80, 0, 0.3);
} }
.light.theme-pumpkin .text-glow { .light.theme-pumpkin .text-glow {
text-shadow: 0 0 8px hsl(10, 90%, 45%), 0 0 20px hsl(10, 90%, 45%), 0 0 40px rgba(200, 50, 0, 0.3); text-shadow: 0 0 8px hsl(25, 90%, 42%), 0 0 20px hsla(25, 90%, 42%, 0.4), 0 0 40px rgba(200, 80, 0, 0.15);
} }
.dark.theme-pumpkin h1, .dark.theme-pumpkin h2, .dark.theme-pumpkin h3 { /* Gradient headings — warm orange to amber */
background: linear-gradient(135deg, hsl(10, 100%, 50%), hsl(30, 100%, 50%)); .dark.theme-pumpkin h1:not(.text-primary):not(.text-foreground),
.dark.theme-pumpkin h2:not(.text-primary):not(.text-foreground),
.dark.theme-pumpkin h3:not(.text-primary):not(.text-foreground) {
background: linear-gradient(135deg, hsl(25, 95%, 55%), hsl(35, 100%, 50%));
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.light.theme-pumpkin h1, .light.theme-pumpkin h2, .light.theme-pumpkin h3 { .light.theme-pumpkin h1:not(.text-primary):not(.text-foreground),
background: linear-gradient(135deg, hsl(10, 90%, 40%), hsl(30, 90%, 40%)); .light.theme-pumpkin h2:not(.text-primary):not(.text-foreground),
.light.theme-pumpkin h3:not(.text-primary):not(.text-foreground) {
background: linear-gradient(135deg, hsl(25, 90%, 40%), hsl(35, 90%, 40%));
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.dark.theme-pumpkin a { /* Links */
color: hsl(10, 100%, 55%); .dark.theme-pumpkin a { color: hsl(25, 95%, 55%); }
} .light.theme-pumpkin a { color: hsl(25, 90%, 40%); }
.dark.theme-pumpkin a:hover { color: hsl(35, 100%, 55%); text-shadow: 0 0 8px rgba(255, 140, 0, 0.35); }
.light.theme-pumpkin a { .light.theme-pumpkin a:hover { color: hsl(35, 90%, 45%); }
color: hsl(10, 90%, 40%);
}
.dark.theme-pumpkin a:hover {
color: hsl(30, 100%, 55%);
text-shadow: 0 0 8px rgba(255, 100, 0, 0.4);
}
.light.theme-pumpkin a:hover {
color: hsl(30, 90%, 45%);
}
/* Blockquotes */
.dark.theme-pumpkin blockquote { .dark.theme-pumpkin blockquote {
border-left: 3px solid hsl(10, 100%, 50%); border-left: 3px solid hsl(25, 95%, 50%);
color: hsl(20, 15%, 70%); color: hsl(30, 15%, 70%);
font-style: italic; font-style: italic;
} }
.light.theme-pumpkin blockquote { .light.theme-pumpkin blockquote {
border-left: 3px solid hsl(10, 90%, 45%); border-left: 3px solid hsl(25, 90%, 42%);
color: hsl(10, 10%, 45%); color: hsl(25, 10%, 42%);
font-style: italic; font-style: italic;
} }
/* Code */
.dark.theme-pumpkin code { .dark.theme-pumpkin code {
color: hsl(10, 100%, 55%); color: hsl(25, 95%, 55%);
background: rgba(200, 0, 0, 0.12); background: rgba(200, 80, 0, 0.1);
border: 1px solid rgba(200, 0, 0, 0.15); border: 1px solid rgba(200, 80, 0, 0.15);
} }
.light.theme-pumpkin code { .light.theme-pumpkin code {
color: hsl(10, 90%, 40%); color: hsl(25, 90%, 40%);
background: rgba(200, 50, 0, 0.05); background: rgba(200, 80, 0, 0.04);
border: 1px solid rgba(200, 50, 0, 0.1); border: 1px solid rgba(200, 80, 0, 0.1);
} }
.dark.theme-pumpkin pre { background: rgba(25, 12, 0, 0.9); border: 1px solid rgba(200, 80, 0, 0.1); }
.light.theme-pumpkin pre { background: rgba(200, 80, 0, 0.03); border: 1px solid rgba(200, 80, 0, 0.08); }
.dark.theme-pumpkin pre { /* HR */
background: rgba(20, 0, 0, 0.9); .dark.theme-pumpkin hr { border-color: rgba(200, 80, 0, 0.12); }
border: 1px solid rgba(200, 0, 0, 0.1); .light.theme-pumpkin hr { border-color: rgba(200, 80, 0, 0.08); }
}
.light.theme-pumpkin pre { /* Tables */
background: rgba(200, 50, 0, 0.03); .dark.theme-pumpkin table th { color: hsl(25, 95%, 55%); }
border: 1px solid rgba(200, 50, 0, 0.08); .light.theme-pumpkin table th { color: hsl(25, 90%, 40%); }
} .dark.theme-pumpkin table td { border-color: rgba(200, 80, 0, 0.1); }
.light.theme-pumpkin table td { border-color: rgba(200, 80, 0, 0.08); }
.dark.theme-pumpkin hr { /* Selection */
border-color: rgba(200, 0, 0, 0.15); .dark.theme-pumpkin ::selection { background: rgba(200, 80, 0, 0.2); color: hsl(0, 0%, 100%); }
} .light.theme-pumpkin ::selection { background: rgba(200, 80, 0, 0.1); color: hsl(0, 0%, 0%); }
.light.theme-pumpkin hr { /* Placeholders */
border-color: rgba(200, 50, 0, 0.1); .dark.theme-pumpkin input::placeholder, .dark.theme-pumpkin textarea::placeholder { color: hsl(25, 10%, 40%); }
} .light.theme-pumpkin input::placeholder, .light.theme-pumpkin textarea::placeholder { color: hsl(25, 10%, 62%); }
.dark.theme-pumpkin table th { /* Page header / stat card / badge */
color: hsl(10, 100%, 55%); .dark.theme-pumpkin .page-header-title, .dark.theme-pumpkin .stat-card-value, .dark.theme-pumpkin .badge { color: hsl(25, 95%, 55%); }
} .light.theme-pumpkin .page-header-title, .light.theme-pumpkin .stat-card-value, .light.theme-pumpkin .badge { color: hsl(25, 90%, 40%); }
.light.theme-pumpkin table th {
color: hsl(10, 90%, 40%);
}
.dark.theme-pumpkin table td {
border-color: rgba(200, 0, 0, 0.12);
}
.light.theme-pumpkin table td {
border-color: rgba(200, 50, 0, 0.1);
}
.dark.theme-pumpkin ::selection {
background: rgba(200, 0, 0, 0.2);
color: hsl(0, 0%, 100%);
}
.light.theme-pumpkin ::selection {
background: rgba(200, 50, 0, 0.12);
color: hsl(0, 0%, 0%);
}
.dark.theme-pumpkin input::placeholder,
.dark.theme-pumpkin textarea::placeholder {
color: hsl(20, 10%, 40%);
}
.light.theme-pumpkin input::placeholder,
.light.theme-pumpkin textarea::placeholder {
color: hsl(10, 10%, 65%);
}
.dark.theme-pumpkin .page-header-title {
color: hsl(10, 100%, 55%);
}
.light.theme-pumpkin .page-header-title {
color: hsl(10, 90%, 40%);
}
.dark.theme-pumpkin .stat-card-value {
color: hsl(10, 100%, 55%);
}
.light.theme-pumpkin .stat-card-value {
color: hsl(10, 90%, 40%);
}
.dark.theme-pumpkin .badge {
color: hsl(10, 100%, 55%);
}
.light.theme-pumpkin .badge {
color: hsl(10, 90%, 40%);
}
/* ============================================================
Dashboard stat card pipeline bars Pumpkin Spice palette
============================================================ */
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
background: linear-gradient(to right, hsl(10, 100%, 50%), hsl(10, 100%, 65%), hsl(10, 100%, 50%)) !important; background: linear-gradient(to right, hsl(25, 95%, 50%), hsl(25, 95%, 65%), hsl(25, 95%, 50%)) !important;
} }
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
background: linear-gradient(to right, hsl(30, 100%, 50%), hsl(30, 100%, 65%), hsl(30, 100%, 50%)) !important; background: linear-gradient(to right, hsl(35, 100%, 45%), hsl(35, 100%, 60%), hsl(35, 100%, 45%)) !important;
} }
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(10, 100%, 50%, 0.1) !important; background: hsla(25, 95%, 50%, 0.12) !important;
} }
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(30, 100%, 50%, 0.1) !important; background: hsla(35, 100%, 45%, 0.12) !important;
} }
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
color: hsl(10, 100%, 50%) !important; color: hsl(25, 95%, 50%) !important;
} }
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(30, 100%, 50%) !important; color: hsl(35, 100%, 45%) !important;
} }
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
background: linear-gradient(to right, transparent, hsla(10, 100%, 50%, 0.25), transparent) !important; background: linear-gradient(to right, transparent, hsla(25, 95%, 50%, 0.25), transparent) !important;
} }
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
background: linear-gradient(to right, transparent, hsla(30, 100%, 50%, 0.25), transparent) !important; background: linear-gradient(to right, transparent, hsla(35, 100%, 45%, 0.25), transparent) !important;
} }
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r { .dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(10, 100%, 50%), hsl(30, 100%, 50%)) !important; background: linear-gradient(to right, hsl(25, 95%, 50%), hsl(35, 100%, 45%)) !important;
} }
.light.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full { .light.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
background: linear-gradient(to right, hsl(10, 90%, 45%), hsl(10, 90%, 55%), hsl(10, 90%, 45%)) !important; background: linear-gradient(to right, hsl(25, 90%, 42%), hsl(25, 90%, 55%), hsl(25, 90%, 42%)) !important;
} }
.light.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full { .light.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
background: linear-gradient(to right, hsl(30, 90%, 45%), hsl(30, 90%, 55%), hsl(30, 90%, 45%)) !important; background: linear-gradient(to right, hsl(35, 90%, 42%), hsl(35, 90%, 55%), hsl(35, 90%, 42%)) !important;
} }
/* ============================================================
Chart color overrides Pumpkin Spice palette
============================================================ */
.dark.theme-pumpkin [class*="recharts-"] text,
.light.theme-pumpkin [class*="recharts-"] text {
fill: hsl(30, 12%, 60%) !important;
}
/* Donut chart gradient stops */
.dark.theme-pumpkin .recharts-pie-sector:nth-child(1) path,
.light.theme-pumpkin .recharts-pie-sector:nth-child(1) path {
fill: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .recharts-pie-sector:nth-child(2) path,
.light.theme-pumpkin .recharts-pie-sector:nth-child(2) path {
fill: hsl(35, 100%, 45%) !important;
}
.dark.theme-pumpkin .recharts-pie-sector:nth-child(3) path,
.light.theme-pumpkin .recharts-pie-sector:nth-child(3) path {
fill: hsl(15, 100%, 42%) !important;
}
/* Stat card icon colors */
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .h-8.w-8,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .h-8.w-8 {
background: hsla(25, 95%, 50%, 0.12) !important;
color: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .h-8.w-8,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .h-8.w-8 {
background: hsla(35, 100%, 45%, 0.12) !important;
color: hsl(35, 100%, 45%) !important;
}
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .h-8.w-8,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .h-8.w-8 {
background: hsla(15, 100%, 42%, 0.12) !important;
color: hsl(15, 100%, 42%) !important;
}
/* Stat card value colors */
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .text-2xl,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .text-2xl {
color: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .text-2xl,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .text-2xl {
color: hsl(35, 100%, 45%) !important;
}
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .text-2xl,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .text-2xl {
color: hsl(15, 100%, 42%) !important;
}
/* Bar chart bar fills */
.dark.theme-pumpkin .recharts-bar-rectangle:nth-child(1) rect,
.light.theme-pumpkin .recharts-bar-rectangle:nth-child(1) rect {
fill: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .recharts-bar-rectangle:nth-child(2) rect,
.light.theme-pumpkin .recharts-bar-rectangle:nth-child(2) rect {
fill: hsl(35, 100%, 45%) !important;
}
/* ============================================================
Card gradient accent subtle pumpkin glow on hover
============================================================ */
.dark.theme-pumpkin .rounded-xl.border.border-border {
border-color: hsla(25, 20%, 18%, 0.8) !important;
}
.dark.theme-pumpkin .rounded-xl.border.border-border:hover {
border-color: hsla(25, 95%, 50%, 0.25) !important;
}
/* Growth word on login page — keep brand blue */
.dark.theme-pumpkin .growth-word, .dark.theme-pumpkin .growth-word,
.light.theme-pumpkin .growth-word { .light.theme-pumpkin .growth-word {
-webkit-text-fill-color: #1BB0CE !important; -webkit-text-fill-color: #1BB0CE !important;
color: #1BB0CE !important; color: #1BB0CE !important;
background: none !important; background: none !important;
} }
/* ============================================================
Client Portal Overrides
============================================================ */
.dark.theme-pumpkin .min-h-screen.bg-background {
background: transparent !important;
}
.dark.theme-pumpkin aside.bg-card.border-r.border-border {
background: hsl(25, 35%, 4%) !important;
border-right-color: hsl(25, 20%, 14%) !important;
}
.dark.theme-pumpkin aside .text-muted-foreground {
color: hsl(30, 10%, 50%) !important;
}
.dark.theme-pumpkin aside button:hover {
background: hsla(25, 95%, 50%, 0.08) !important;
color: hsl(0, 0%, 90%) !important;
}
.dark.theme-pumpkin aside .bg-primary\/10.text-primary {
background: hsl(25, 95%, 50%) !important;
color: hsl(0, 0%, 100%) !important;
box-shadow: 0 0 10px hsla(25, 95%, 50%, 0.3), 0 0 20px hsla(25, 95%, 50%, 0.1);
border-color: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin aside .bg-primary\/10.text-primary svg {
color: hsl(0, 0%, 100%) !important;
}
.dark.theme-pumpkin header.bg-card\/80 {
background: hsl(25, 35%, 4%) !important;
border-bottom-color: hsl(25, 20%, 14%) !important;
}
.dark.theme-pumpkin .bg-card {
background: hsla(25, 30%, 7%, 0.65) !important;
backdrop-filter: blur(10px) !important;
border-color: hsla(25, 20%, 16%, 0.35) !important;
}
.dark.theme-pumpkin .border-border {
border-color: hsl(25, 18%, 14%) !important;
}
.dark.theme-pumpkin .bg-muted {
background: hsl(25, 15%, 10%) !important;
}
.dark.theme-pumpkin .bg-primary {
background: hsl(25, 95%, 50%) !important;
box-shadow: 0 0 10px hsla(25, 95%, 50%, 0.2);
}
.dark.theme-pumpkin .bg-primary:hover {
background: hsl(25, 95%, 55%) !important;
box-shadow: 0 0 15px hsla(25, 95%, 50%, 0.35);
}
/* Light mode portal */
.light.theme-pumpkin .min-h-screen.bg-background {
background: transparent !important;
}
.light.theme-pumpkin aside.bg-card.border-r.border-border {
background: hsl(0, 0%, 100%) !important;
border-right-color: hsl(25, 15%, 82%) !important;
}
.light.theme-pumpkin header.bg-card\/80 {
background: hsl(0, 0%, 98%) !important;
border-bottom-color: hsl(25, 15%, 82%) !important;
}
.light.theme-pumpkin .bg-card {
background: hsla(0, 0%, 100%, 0.65) !important;
backdrop-filter: blur(10px) !important;
border-color: hsla(25, 15%, 80%, 0.5) !important;
}
.light.theme-pumpkin .bg-primary {
background: hsl(25, 90%, 42%) !important;
}
.light.theme-pumpkin .bg-primary:hover {
background: hsl(25, 90%, 48%) !important;
}
+68
View File
@@ -183,3 +183,71 @@
color: #1BB0CE !important; color: #1BB0CE !important;
background: none !important; background: none !important;
} }
/* ============================================================================
Client Portal Overrides Background for portal pages
============================================================================ */
/* Make the outer portal container transparent so body::before background shows */
.dark.theme-spidey .min-h-screen.bg-background {
background: transparent !important;
}
/* Portal sidebar — dark glass */
.dark.theme-spidey aside.bg-card.border-r.border-border {
background: hsl(240, 15%, 3%) !important;
border-right-color: hsl(270, 25%, 10%) !important;
}
/* Portal sidebar nav items — muted text */
.dark.theme-spidey aside .text-muted-foreground {
color: hsl(240, 8%, 50%) !important;
}
.dark.theme-spidey aside button:hover {
background: hsla(0, 100%, 50%, 0.08) !important;
color: hsl(0, 0%, 90%) !important;
}
/* Portal sidebar active nav item */
.dark.theme-spidey aside .bg-primary\/10.text-primary {
background: hsl(0, 100%, 50%) !important;
color: hsl(0, 0%, 100%) !important;
box-shadow: 0 0 10px rgba(255, 0, 0, 0.3), 0 0 20px rgba(255, 0, 0, 0.1);
border-color: hsl(0, 100%, 50%) !important;
}
.dark.theme-spidey aside .bg-primary\/10.text-primary svg {
color: hsl(0, 0%, 100%) !important;
}
/* Portal header — dark glass */
.dark.theme-spidey header.bg-card\/80 {
background: hsl(240, 15%, 3%) !important;
border-bottom-color: hsl(270, 25%, 10%) !important;
}
/* Portal card backgrounds — semi-transparent glass */
.dark.theme-spidey .bg-card {
background: hsla(240, 15%, 7%, 0.65) !important;
backdrop-filter: blur(10px) !important;
border-color: hsla(270, 30%, 20%, 0.35) !important;
}
/* Portal borders */
.dark.theme-spidey .border-border {
border-color: hsl(270, 25%, 12%) !important;
}
/* Portal input fields */
.dark.theme-spidey .bg-muted {
background: hsl(240, 12%, 9%) !important;
}
/* Portal primary buttons */
.dark.theme-spidey .bg-primary {
background: hsl(0, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(255, 0, 0, 0.2);
}
.dark.theme-spidey .bg-primary:hover {
background: hsl(0, 100%, 55%) !important;
box-shadow: 0 0 15px rgba(255, 0, 0, 0.4);
}
+20 -20
View File
@@ -40,33 +40,33 @@
}, },
"light": { "light": {
"colors": { "colors": {
"background": "hsl(220, 15%, 96%)", "background": "hsl(0, 0%, 98%)",
"foreground": "hsl(240, 30%, 10%)", "foreground": "hsl(240, 10%, 15%)",
"card": "hsl(0, 0%, 100%)", "card": "hsl(0, 0%, 100%)",
"cardForeground": "hsl(240, 30%, 10%)", "cardForeground": "hsl(240, 10%, 15%)",
"popover": "hsl(0, 0%, 100%)", "popover": "hsl(0, 0%, 100%)",
"popoverForeground": "hsl(240, 30%, 10%)", "popoverForeground": "hsl(240, 10%, 15%)",
"primary": "hsl(330, 80%, 48%)", "primary": "hsl(330, 100%, 50%)",
"primaryForeground": "hsl(0, 0%, 100%)", "primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(180, 80%, 38%)", "secondary": "hsl(180, 100%, 50%)",
"secondaryForeground": "hsl(0, 0%, 100%)", "secondaryForeground": "hsl(0, 0%, 0%)",
"muted": "hsl(240, 8%, 92%)", "muted": "hsl(240, 5%, 94%)",
"mutedForeground": "hsl(240, 8%, 48%)", "mutedForeground": "hsl(240, 4%, 48%)",
"accent": "hsl(260, 65%, 52%)", "accent": "hsl(260, 80%, 60%)",
"accentForeground": "hsl(0, 0%, 100%)", "accentForeground": "hsl(0, 0%, 100%)",
"destructive": "hsl(0, 80%, 55%)", "destructive": "hsl(0, 84%, 55%)",
"destructiveForeground": "hsl(0, 0%, 100%)", "destructiveForeground": "hsl(0, 0%, 100%)",
"border": "hsl(270, 15%, 84%)", "border": "hsl(270, 10%, 88%)",
"input": "hsl(240, 8%, 92%)", "input": "hsl(240, 5%, 92%)",
"ring": "hsl(330, 80%, 48%)", "ring": "hsl(330, 100%, 50%)",
"sidebar": "hsl(0, 0%, 100%)", "sidebar": "hsl(0, 0%, 100%)",
"sidebarForeground": "hsl(240, 30%, 10%)", "sidebarForeground": "hsl(240, 10%, 15%)",
"sidebarPrimary": "hsl(330, 80%, 48%)", "sidebarPrimary": "hsl(330, 100%, 50%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)", "sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(240, 8%, 92%)", "sidebarAccent": "hsl(240, 5%, 94%)",
"sidebarAccentForeground": "hsl(240, 30%, 10%)", "sidebarAccentForeground": "hsl(240, 10%, 15%)",
"sidebarBorder": "hsl(270, 15%, 84%)", "sidebarBorder": "hsl(270, 10%, 88%)",
"sidebarRing": "hsl(330, 80%, 48%)" "sidebarRing": "hsl(330, 100%, 50%)"
} }
} }
}, },
+24 -19
View File
@@ -269,6 +269,13 @@ function parseBody(req) {
}) })
} }
function checkPort(http, host, port, timeout) {
return new Promise((resolve, reject) => {
const r = http.get(`http://${host}:${port}`, { timeout }, (res) => { res.resume(); resolve() })
r.on("error", reject)
})
}
function parseURL(req) { function parseURL(req) {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`) const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
return { pathname: url.pathname, searchParams: url.searchParams } return { pathname: url.pathname, searchParams: url.searchParams }
@@ -309,27 +316,23 @@ const server = http.createServer(async (req, res) => {
} }
// GET /status — combined health of all services // GET /status — combined health of all services
// Used by the splash page to check if AI, Scraper, and Frontend are ready. // Used by the splash page to check if AI and Frontend are ready.
// Polls each service internally to avoid cross-origin CORS issues. // Scraper is no longer part of dev:start so it's always reported ready.
if (req.method === "GET" && pathname === "/status") { if (req.method === "GET" && pathname === "/status") {
const { default: http } = await import("http") const { default: http } = await import("http")
const results = { ai: true } const results = { ai: true, scraper: true }
// Check scraper (port 3008) // Check frontend (port 3006) — try localhost, fallback to 127.0.0.1
try { try {
await new Promise((resolve, reject) => { await checkPort(http, "localhost", 3006, 2000)
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("error", reject)
})
results.scraper = true
} catch { results.scraper = false }
// Check frontend (port 3006)
try {
await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("error", reject)
})
results.frontend = true results.frontend = true
} catch { results.frontend = false } } catch {
try {
await checkPort(http, "127.0.0.1", 3006, 2000)
results.frontend = true
} catch {
results.frontend = false
}
}
return sendJSON(res, 200, results) return sendJSON(res, 200, results)
} }
@@ -367,9 +370,11 @@ const server = http.createServer(async (req, res) => {
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } } let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
let facebookLoggedIn = false let facebookLoggedIn = false
let selectedBrowser = process.env.SELECTED_BROWSER || "" let selectedBrowser = process.env.SELECTED_BROWSER || ""
let scraperAvailable = false
try { try {
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) }) await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
scraperAvailable = true
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json() const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
for (const [b, p] of Object.entries(profiles)) { for (const [b, p] of Object.entries(profiles)) {
if (p) browsers[b] = { path: p } if (p) browsers[b] = { path: p }
@@ -396,8 +401,8 @@ const server = http.createServer(async (req, res) => {
} catch {} } catch {}
const anyDetected = Object.values(browsers).some(v => v.path) const anyDetected = Object.values(browsers).some(v => v.path)
// first_run = any setup step is incomplete // first_run = env or Ollama incomplete, or scraper is available but detection incomplete
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable const firstRun = !envExists || !ollamaRunning || (scraperAvailable && (!anyDetected || !facebookLoggedIn))
return sendJSON(res, 200, { return sendJSON(res, 200, {
first_run: firstRun, first_run: firstRun,
+445 -84
View File
@@ -341,6 +341,7 @@ OFFER_PATTERNS = [
r"\bfree\s+domain\b", r"\bfree\s+domain\b",
r"\bweb\s+hosting\b", r"\bweb\s+hosting\b",
r"\bfree\s+website\b", r"\bfree\s+website\b",
r"\bfree\s+\w+\s+website\b",
r"\bprofessional\s+website", r"\bprofessional\s+website",
r"\baffordable\s+web\b", r"\baffordable\s+web\b",
r"\bget\s+a\s+free\b", r"\bget\s+a\s+free\b",
@@ -365,6 +366,88 @@ OFFER_PATTERNS = [
r"\bsponsored\b", r"\bsponsored\b",
r"\bpromoted\b", r"\bpromoted\b",
r"\badvertisement\b", r"\badvertisement\b",
r"\bdo\s+you\s+(need|want)\b",
r"\bwe\s+(can|will)\s+(build|design|create|develop|do)\b",
r"\bi\s+(can|will)\s+(build|design|create|develop|do)\b",
r"\bmy\s+portfolio\b",
r"\breasonable\s+(price|pricing|rate)\b",
r"\bwhatsapp\s+(me|us|at)\b",
r"\bwhatsapp\b",
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
r"\bhelp\s+your\s+business\b",
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\b",
r"\bcontact\s+me\b",
r"\bwe\s+(are|offer|provide)\s+(web|website|design|service)\b",
r"\btake\s+(the\s+|this\s+)?quiz\b",
r"\bhomeschool\b",
r"\byour\s+(home\s+)?tutor\b",
r"\blink\s+(in\s+)?(bio|comment|below)\b",
r"\bapply\s+now\b",
r"\bget\s+started\b",
r"\bfor\s+only\b",
r"\blow\s+(price|cost|rate)\b",
r"\bhit\s+me\s+up\b",
r"\bsend\s+me\s+(a\s+)?(message|dm|pm)\b",
r"\bi\s+do\s+(web|website|design|development)\b",
r"\bwe\s+do\s+(web|website|design|development)\b",
r"\bi'm\s+offering\b",
r"\bwe're\s+offering\b",
r"\bstarting\s+a\s+\w+\s+(service|business)\b",
r"\blooking\s+for\s+a\s+few\s+businesses?\b",
r"\blet('?)s\s+connect\b",
r"\bi\s+have\s+projects?\b",
r"\byour\s+business\b",
r"\bwordpress\s+development\b",
r"\be.?commerce\s+website\b",
r"\bwebsite\s+builder\b",
r"\bai\s+studio\b",
r"\bpage\s+speed\b",
r"\bguest\s+post",
r"\bguest\s+blog",
r"\bfor\s+sale\b",
r"\bselling\s+(my|a|the|this)\b",
r"\bpremium\b",
r"\bi'm\s+selling\b",
r"\bgroup\b",
r"\bi\s+can\s+help\b",
r"\binbox\s+me\b",
r"\bpm\s+me\b",
r"\bdm\s+me\b",
r"\bmessage\s+me\s+for\b",
r"\bquote\b",
r"\bdiscount\b",
r"\bbest\s+(deal|price|offer|service)\b",
r"\bprice\s+(start|begin|include|range)\b",
r"\breach\s+out\b",
r"\bclick\s+(the\s+)?link\b",
r"\bcheck\s+(out|this|my)\b",
r"\bwebsite\s+for\s+your\b",
r"\bprobono\b",
r"\bfreelance\s+opportunit",
r"\bfull.?stack\b",
r"\bresponsive\s+(web)?sites?\b",
r"\blooking\s+for\s+(new\s+)?(projects?|work)\b",
r"\bmern\s+stack\b",
r"\bexpress\s+js\b",
r"\breact\s+js\b",
r"\bnode\s+js\b",
r"\bwebsite\s+for\s+\$\b",
r"\bi\s+build\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+build\s+(websites?|shopify|wordpress)\b",
r"\bi\s+create\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+create\s+(websites?|shopify|wordpress)\b",
r"\bfull.?time\s+(position|job|role|work)\b",
r"\bremote\s+(position|job|role|work)\b",
r"\bhelp\s+wanted\b",
r"\bjob\s+(opening|vacancy|opportunity)\b",
r"\bnow\s+hiring\b",
r"\bpart.?time\s+(position|job|role|work)\b",
r"\byears?\s+of\s+(teaching|experience)\b",
r"\bsend\s+(you|me)\s+(the\s+)?link\b",
r"\bcomment\s+.*\bsend\b",
r"\bfor\s+free\b",
r"\bmake\s+money\b",
r"\bno\s+coding\b",
] ]
REQUEST_PATTERNS = [ REQUEST_PATTERNS = [
@@ -549,6 +632,48 @@ FB_SEARCHES = [
"want to build a website for my", "want to build a website for my",
] ]
TUTORING_SEARCHES = [
"need a tutor",
"looking for a tutor",
"tutor for my child",
"need help with homework",
"private tutor needed",
"looking for tutoring",
"need a math tutor",
"english tutor needed",
"online tutor for",
"lessons for my child",
"help my child with",
"need someone to tutor",
"looking for someone to teach",
"tutoring for my",
"need help learning",
"recommend a tutor",
"anyone know a tutor",
"need a private tutor",
"tutoring services for my child",
"need academic help",
"need a reading tutor",
"looking for piano teacher",
"need help with maths",
"need a science tutor",
"looking for language tutor",
"need programming tutor",
"coding tutor for my",
"sat tutor needed",
"exam preparation tutor",
"need a homeschool tutor",
"tutor near me for",
]
def _search_list_for_query(query: str) -> list[str]:
"""Pick the appropriate search query pool based on the search term."""
tl = query.lower()
tutoring_terms = ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child", "math", "english", "science", "exam", "homeschool", "coding", "programming", "piano", "reading"]
if any(t in tl for t in tutoring_terms):
return TUTORING_SEARCHES
return FB_SEARCHES
VIEWPORTS = [ VIEWPORTS = [
{'width': 1280, 'height': 800}, {'width': 1280, 'height': 800},
{'width': 1366, 'height': 768}, {'width': 1366, 'height': 768},
@@ -663,16 +788,31 @@ def _clean_fb_text(text: str) -> str:
cleaned_lines.append(stripped) cleaned_lines.append(stripped)
return '\n'.join(cleaned_lines) return '\n'.join(cleaned_lines)
# Words that should never be treated as person names
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce","press","anonymous","tuition","parents","tutors","advanced","custom","fields","speed","optimization","elementor","woocommerce","acf","availability","january","february","march","april","may","june","july","august","september","october","november","december","mums","dads","uae","sharjah","dubai","abu","dhabi","hong","kong","canada","usa","united","kingdom","aunty","piano","plus","recommendations","needed"}
# ── Post Extraction ────────────────────────────────────────────────── # ── Post Extraction ──────────────────────────────────────────────────
# Two strategies for extracting posts from page content: # Two strategies for extracting posts from page content:
# 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements() # 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements()
# 2. _extract_posts_from_text — fallback that parses raw page text line by line # 2. _extract_posts_from_text — fallback that parses raw page text line by line
# Both apply dedup (seen_texts), offer filtering, and request scoring. # Both apply dedup (seen_texts), offer filtering, and request scoring.
_GROUP_SUFFIXES = [" - South Africa", " - Australia", " - Philippines", " PH", "Group", "help group", "info group", "public group", "private group", "group for", "community", "creators", "marketplace"]
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]: def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
posts = [] posts = []
seen_texts = set() seen_texts = set()
now = datetime.utcnow()
for el in elements: for el in elements:
# Reject group posts immediately
if el.get('isGroup'):
continue
# Reject posts older than 2 days
ts = el.get('ts', 0)
if ts:
age_seconds = (now.timestamp() * 1000 - ts) / 1000
if age_seconds > 172800: # 2 days
continue
raw_text = el.get('text', '') raw_text = el.get('text', '')
if len(raw_text) < 40: if len(raw_text) < 40:
continue continue
@@ -686,13 +826,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
if dekey in seen_texts: if dekey in seen_texts:
continue continue
seen_texts.add(dekey) seen_texts.add(dekey)
# Reject if text contains group-like patterns
lower = text.lower()
if ' / ' in lower:
continue
skip = False
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
request_score = 2 if is_request(text) else 0 request_score = 2 if is_request(text) else 0
post_url = el.get('url') or base_url post_url = el.get('url') or base_url
# Prefer JS-extracted date, fall back to text parsing # Prefer JS-extracted date, fall back to text parsing
js_date = (el.get('date') or '').strip() js_date = (el.get('date') or '').strip()
if js_date: if js_date:
# Try ISO datetime from <time datetime>
try: try:
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00')) dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
date_str = dt.strftime('%Y-%m-%d') date_str = dt.strftime('%Y-%m-%d')
@@ -720,6 +870,25 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
posts = [] posts = []
seen_texts = set() seen_texts = set()
cur = [] cur = []
def _find_author(candidate_lines: list[str]) -> str:
for line in reversed(candidate_lines):
ln = line.strip()
if not ln or len(ln) > 60 or len(ln) < 3:
continue
words = ln.split()
if not (1 < len(words) < 8):
continue
if not all(w[0].isalpha() and w[0].isupper() for w in words if w):
continue
lower_ln = ln.lower()
if any(kw in lower_ln for kw in BROAD_KEYWORDS) or is_offer(ln):
continue
if 'http' in ln or '/' in ln or '@' in ln:
continue
return ln
return ''
for l in lines: for l in lines:
words = re.findall(r'[A-Za-z]{2,}', l) words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) < 2: if len(words) < 2:
@@ -735,7 +904,7 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
posts.append({ posts.append({
"title": snippet[:300], "title": snippet[:300],
"content": snippet[:1000], "content": snippet[:1000],
"author": '', "author": _find_author(cur[-3:]),
"url": url, "url": url,
"source": "facebook", "source": "facebook",
"date": _parse_fb_date(cur + [l]), "date": _parse_fb_date(cur + [l]),
@@ -759,7 +928,61 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
cur.append(l) cur.append(l)
if len(cur) > 10: if len(cur) > 10:
cur.pop(0) cur.pop(0)
return posts # Post-process: scan each post's text for inline names
def _scan_inline_name(text: str) -> str:
for m in re.finditer(r'([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,}){1,3})[\s,;:]', text):
cand = m.group(1).strip()
if cand and 3 <= len(cand) <= 60:
lower = cand.lower()
words = lower.split()
if not any(kw in lower for kw in BROAD_KEYWORDS) and not is_offer(cand):
if not any(w in _NON_NAMES for w in words):
return cand
return ''
for p in posts:
if not p.get('author'):
txt = p.get('content') or p.get('title') or ''
name = _scan_inline_name(txt)
if name:
p['author'] = name
# Remove group posts: any post whose text looks like it came from a group
filtered = []
for p in posts:
t = (p.get('title') or p.get('content') or '')
if ' / ' in t:
continue
lower = t.lower()
skip = False
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
filtered.append(p)
# Dedup: merge posts where one is a suffix of another (same post split)
merged = []
for p in filtered:
t = (p.get('title') or p.get('content') or '').lower()
# Check if this post is substantially contained in an already-merged post
found = False
for i, m in enumerate(merged):
mt = (m.get('title') or m.get('content') or '').lower()
# Word-level overlap: count shared words
twords = set(t.split())
mwords = set(mt.split())
if len(twords) > 3 and len(mwords) > 3:
overlap = len(twords & mwords)
smaller = min(len(twords), len(mwords))
if overlap / smaller >= 0.6:
# Keep the longer one
if len(t) > len(mt):
merged[i] = p
found = True
break
if not found:
merged.append(p)
return merged
# ── Human-like Behavior Simulation ────────────────────────────────── # ── Human-like Behavior Simulation ──────────────────────────────────
# These functions add random delays, mouse movements, and scroll patterns # These functions add random delays, mouse movements, and scroll patterns
@@ -811,58 +1034,40 @@ async def _get_article_elements(page) -> list[dict]:
return await page.evaluate('''() => { return await page.evaluate('''() => {
const results = []; const results = [];
const seenTexts = new Set(); const seenTexts = new Set();
const selectors = [ const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce","tutor","tutoring","homeschool","math","reading","english","science","grade"];
'div[role="article"]', const now = Date.now();
'div[role="feed"] > div', const DAY_MS = 86400000;
'div.x1yztbdb', const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
'div[data-pagelet]', for (const a of anchors) {
]; const h = a.getAttribute('href') || '';
for (const sel of selectors) { if (!h) continue;
const els = document.querySelectorAll(sel); const cell = a.closest('div[style]') || a.parentElement;
for (const el of els) { if (!cell) continue;
const text = (el.innerText || '').trim(); const txt = (cell.innerText || '').trim();
if (text.length < 40) continue; if (txt.length < 40) continue;
const key = text.substring(0, 80); const lower = txt.toLowerCase();
if (seenTexts.has(key)) continue; let matched = false;
seenTexts.add(key); for (const t of terms) { if (lower.includes(t)) { matched = true; break; } }
if (!matched) continue;
const key = txt.substring(0, 80);
if (seenTexts.has(key)) continue;
seenTexts.add(key);
// --- Publisher name --- const isGroup = h.includes('/groups/');
let author = ''; let author = '';
const nameEl = el.querySelector('h4, strong, a[role="link"]'); const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) { if (nameEl) author = (nameEl.innerText || '').trim();
author = (nameEl.innerText || '').trim(); if (!author || author.length > 60 || author.length < 2) author = '';
} let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
if (!author) { let date = '';
const links = el.querySelectorAll('a'); let ts = 0;
for (const a of links) { const timeEl = cell.querySelector('time');
const t = (a.innerText || '').trim(); if (timeEl) {
if (t && t.length > 1 && t.length < 60 && /^[A-Za--ÿ]/.test(t) && !t.includes('facebook') && !t.includes('/')) { date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
author = t; const dtVal = timeEl.getAttribute('datetime');
break; if (dtVal) { ts = new Date(dtVal).getTime(); }
}
}
}
// --- Post URL ---
let postUrl = '';
for (const a of el.querySelectorAll('a')) {
const h = (a.getAttribute('href') || '');
if (h.includes('/posts/') || h.includes('/photo/') || h.includes('/video/') || h.includes('/groups/') || h.includes('/permalink/')) {
postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
break;
}
}
// --- Date from <time datetime> ---
let date = '';
const timeEl = el.querySelector('time');
if (timeEl) {
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
}
results.push({ text, author, url: postUrl, date });
} }
if (results.length > 0) break; results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
} }
return results; return results;
}''') }''')
@@ -896,15 +1101,11 @@ async def search_facebook(page, context, query: str):
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}' url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try: try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000) await page.goto(url, wait_until='domcontentloaded', timeout=30000)
try: await page.wait_for_timeout(random.randint(5000, 10000))
await page.wait_for_selector('div[role="article"], div[role="feed"]', timeout=15000)
except Exception:
pass
await page.wait_for_timeout(random.randint(3000, 8000))
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15)) await human_scroll(page, steps=random.randint(4, 7), total_delay=random.uniform(12, 25))
if random.random() < 0.1: if random.random() < 0.3:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await page.wait_for_timeout(random.randint(1000, 3000)) await page.wait_for_timeout(random.randint(1000, 3000))
await page.evaluate("window.scrollBy(0, -300)") await page.evaluate("window.scrollBy(0, -300)")
@@ -919,9 +1120,102 @@ async def search_facebook(page, context, query: str):
raw_articles = await _get_article_elements(page) raw_articles = await _get_article_elements(page)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else [] posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
if not posts: raw = await page.evaluate('document.body.innerText')
raw = await page.evaluate('document.body.innerText') text_posts = _extract_posts_from_text(raw, url)
posts = _extract_posts_from_text(raw, url) existing = {(p.get('title') or p.get('content',''))[:80] for p in posts}
for tp in text_posts:
key = (tp.get('title') or tp.get('content',''))[:80]
if key not in existing:
posts.append(tp)
if posts:
# Extract author names from profile links in the DOM
try:
profiles = await page.evaluate(r'''() => {
const out = [];
const seenTxt = new Set();
for (const a of document.querySelectorAll('a[href*="/profile.php"], a[href*="/user/"], a[href*="/people/"], a[href*="/me/"]')) {
const name = (a.innerText || '').trim();
if (!name || name.length < 3 || name.length > 60) continue;
const words = name.split(' ');
if (words.length < 2 || words.length > 6) continue;
if (!/^[A-Z]/.test(name)) continue;
if (name.includes('facebook') || name.includes('/')) continue;
const cell = a.closest('div[style]') || a.parentElement;
const txt = cell ? (cell.innerText || '').substring(0, 200) : '';
if (!txt) continue;
const key = txt.substring(0, 80);
if (seenTxt.has(key)) continue;
seenTxt.add(key);
out.push({ name, textKey: key });
}
return out;
}''')
if profiles:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:80].strip()
if not pk: continue
for pr in profiles:
if pk[:30] in pr['textKey'] or pr['textKey'][:30] in pk:
if not p.get('author'):
p['author'] = pr['name']
break
except Exception:
pass
try:
meta = await page.evaluate(r'''() => {
const out = [];
const seenHref = new Set();
const all = document.querySelectorAll('a[href]');
for (const a of all) {
const h = a.href;
if (!h || seenHref.has(h)) continue;
const u = new URL(h);
if (!u.hostname.includes('facebook.com') && !u.hostname.includes('fb.com')) continue;
const path = u.pathname;
if (path === '/' || path === '/search/' || path === '/marketplace/' || path.startsWith('/messages/') || path.startsWith('/notifications/') || path.startsWith('/settings/')) continue;
const isPost = path.includes('/posts/') || path.includes('/videos/') || path.includes('/photos/') || path.startsWith('/story.php') || path.startsWith('/photo.php') || path.startsWith('/watch') || path.includes('/reel/') || path.startsWith('/permalink/');
if (!isPost) continue;
const cell = a.closest('[style*="position"], [role="article"], div[data-pagelet], div.x1yztbdb, div.x78zum5, div.x1n2onr6') || a.parentElement;
if (!cell) continue;
const txt = (cell.innerText || '').trim();
if (txt.length < 40) continue;
let author = '';
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) author = (nameEl.innerText || '').trim();
if (!author || author.length > 60) author = '';
const key = txt.substring(0, 100);
seenHref.add(h);
out.push({ key, href: h, author, textMap: txt.substring(0,200) });
}
return out;
}''')
if meta:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:100].strip()
if not pk: continue
for m in meta:
mk = m.get('key') or ''
if pk[:40] in mk or mk[:40] in pk:
if m.get('href') and 'search/' not in m['href']:
p['url'] = m['href']
if m.get('author') and not p.get('author'):
p['author'] = m['author']
break
except Exception:
pass
# Clean up: remove group names passed off as authors
for p in posts:
if p.get('author'):
a = p['author']
al = a.lower()
if any(kw in al for kw in BROAD_KEYWORDS) or is_offer(a) or len(a.split()) < 2 or any(w in _NON_NAMES for w in al.split()):
p['author'] = ''
# Final filter: strip any remaining group posts
posts = [p for p in posts if not (
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
or '/pages/' in p.get('url', '')
or ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e: except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e) logger.warning("Facebook search '%s' failed: %s", query, e)
return page, [] return page, []
@@ -971,7 +1265,7 @@ def cleanup_chrome():
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins) # - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
# - If no profile found → Agent (browser-use + ChatOllama) fallback # - If no profile found → Agent (browser-use + ChatOllama) fallback
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict: async def scrape_facebook(profile_path: str | None = None, force: bool = False, query: str | None = None) -> dict:
"""Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent.""" """Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
effective_path = profile_path or "" effective_path = profile_path or ""
browser_type = "" browser_type = ""
@@ -1013,14 +1307,14 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
# Firefox path # Firefox path
if browser_type == "firefox": if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force) result = await _scrape_with_firefox(effective_path, force, query)
if result.get("success") or not result.get("flagged"): if result.get("success") or not result.get("flagged"):
return result return result
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown")) logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force) return await _scrape_with_agent(force)
# Chromium-based (chrome / opera / edge) # Chromium-based (chrome / opera / edge)
result = await _scrape_with_chromium(effective_path, browser_type, force) result = await _scrape_with_chromium(effective_path, browser_type, force, query)
if result.get("success") or not result.get("flagged"): if result.get("success") or not result.get("flagged"):
return result return result
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown")) logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
@@ -1035,7 +1329,7 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
# human browsing). If flagged by Facebook, returns flagged=True for the # human browsing). If flagged by Facebook, returns flagged=True for the
# dispatcher to fall through to the Agent. # dispatcher to fall through to the Agent.
async def _scrape_with_firefox(profile_path: str, force: bool) -> dict: async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None = None) -> dict:
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection).""" """Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
if not profile_path: if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"} return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
@@ -1089,9 +1383,13 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None} return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = [] all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4)) if query:
for i, query in enumerate(searches): query_pool = _search_list_for_query(query)
page, posts = await search_facebook(page, context, query) searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts) all_posts.extend(posts)
if not posts: if not posts:
continue continue
@@ -1142,6 +1440,12 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
pass pass
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ───────────────────────────────────────────────── # ── Chromium Scraper ─────────────────────────────────────────────────
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the # Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
# Firefox scraper but with Chromium-specific launch config: # Firefox scraper but with Chromium-specific launch config:
@@ -1152,7 +1456,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
# Anti-detection script differs slightly from Firefox variant. # Anti-detection script differs slightly from Firefox variant.
# Same decoy-skip and flagged-fallback behavior as Firefox. # Same decoy-skip and flagged-fallback behavior as Firefox.
async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False) -> dict: async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False, query: str | None = None) -> dict:
"""Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera).""" """Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera)."""
if not profile_path: if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"} return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
@@ -1214,9 +1518,13 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None} return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = [] all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4)) if query:
for i, query in enumerate(searches): query_pool = _search_list_for_query(query)
page, posts = await search_facebook(page, context, query) searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts) all_posts.extend(posts)
if not posts: if not posts:
continue continue
@@ -1387,6 +1695,8 @@ NOT LEAD:
- Recruiting employees, hiring staff, looking for business partners - Recruiting employees, hiring staff, looking for business partners
- Selling products, promoting services, affiliate offers - Selling products, promoting services, affiliate offers
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson" - "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
- Posts from groups, communities, or pages (group announcements, group posts, page posts)
- Posts containing the word "group", "page", "community", "creators" these are NEVER individual leads
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD): 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))} {chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
@@ -1449,15 +1759,57 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
"price for", "price for",
] ]
keyword_leads: list[dict] = [] keyword_leads: list[dict] = []
offer_reject = [
'i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting',
'i do web design', 'i develop websites',
'do you need a', 'do you want a', 'we can build',
'i can build', 'my portfolio', 'reasonable price',
'whatsapp me', 'looking for a business', 'looking for client',
'help your business', 'i am a web', 'contact me',
'we offer web', 'we provide web',
'take the quiz', 'homeschool', 'your home tutor',
'link in bio', 'apply now', 'get started',
'for only', 'low price', 'hit me up',
'send me a message', 'i do website', 'we do website',
'we do web', 'i do web',
'website designer / web developer', 'website & software creators',
'website builders for small businesses', 'australia web designers',
'south africa', 'wix website design',
'for sale', 'selling my', 'premium',
'i\'m selling', 'i\'m offering', 'we\'re offering',
'free ecommerce', 'free website design',
'starting a', 'looking for a few businesses',
# Group-related rejections
'group', ' i need a website group', 'south africa web', 'philippines web', 'australia web',
'i can help', 'inbox me', 'dm me', 'pm me', 'message me for',
'best price', 'discount', 'reach out', 'check out my', 'check this',
'website for your', 'price start', 'price begin', 'website creator',
'website & software', 'creators &', 'creators marketplace',
'website group', 'page group',
# Self-promotion rejections
'i\'m a web', "i'm a web", 'i am a full stack', "i'm a full stack", 'i\'m a full stack',
'freelance opportunity', 'looking for new project', 'looking for new work',
'full stack web', 'mern stack', 'responsive business website',
'i build website', 'i build shopify', 'i build wordpress',
'we build website', 'we build shopify', 'we build wordpress',
'i create website', 'we create website',
'full time position', 'full time job', 'remote position', 'remote job',
'help wanted', 'now hiring', 'job opening',
'website speed', 'speed optimization', 'on page seo', 'off page seo',
'elementor pro', 'woocommerce', 'acf', 'custom post type',
'send you the link', 'comment website',
'for free', 'no coding', 'make money', 'website for free',
'part time job', 'part time position',
'years of experience', 'years of teaching',
]
for r in results: for r in results:
t = r['title'].lower() t = r['title'].lower()
has_web = any(kw in t for kw in web_terms) has_web = any(kw in t for kw in web_terms)
has_request = any(kw in t for kw in request_terms) has_request = any(kw in t for kw in request_terms)
if not has_web or not has_request: if not has_web or not has_request:
continue continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer', if any(kw in t for kw in offer_reject):
'affordable web design package', 'web hosting',
'i do web design', 'i develop websites']):
continue continue
keyword_leads.append(r) keyword_leads.append(r)
@@ -1469,6 +1821,10 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
if key not in seen_titles: if key not in seen_titles:
seen_titles.add(key) seen_titles.add(key)
merged.append(r) merged.append(r)
# Final sweep: strip any remaining offers or group posts from merged
group_words = ['group', ' groups', 'page:', 'page |', 'community', 'creators', 'marketplace']
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
# Fill to 5 with loose keyword matches (at least web OR request term) # Fill to 5 with loose keyword matches (at least web OR request term)
if len(merged) < 5: if len(merged) < 5:
@@ -1477,11 +1833,16 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
if key in seen_titles: if key in seen_titles:
continue continue
t = r['title'].lower() t = r['title'].lower()
if any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms): if not (any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms)):
seen_titles.add(key) continue
merged.append(r) if any(kw in t for kw in offer_reject):
if len(merged) >= 5: continue
break if any(gw in t for gw in group_words):
continue
seen_titles.add(key)
merged.append(r)
if len(merged) >= 5:
break
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results)) logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
return merged[:10] return merged[:10]
@@ -1601,8 +1962,8 @@ async def agent_run(task: str = Body(..., embed=True)):
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@app.post("/scrape/facebook") @app.post("/scrape/facebook")
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)): async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False), query: str | None = Query(None)):
result = await scrape_facebook(profile_path, force) result = await scrape_facebook(profile_path, force, query)
return result return result
if __name__ == "__main__": if __name__ == "__main__":
+2 -10
View File
@@ -1,10 +1,2 @@
{"job_title":"Software Developer","keywords":["developer","programmer","software engineer","coder","full stack","backend","frontend"],"industry":"Technology","description":"Builds and maintains software applications and systems"} {"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
{"job_title":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"} {"job_title":"Tutoring","keywords":["need a tutor","looking for a tutor","tutor for my child","need help with homework","private tutor needed","looking for tutoring","need a math tutor","english tutor needed","online tutor for","lessons for my child","help my child with","need someone to tutor","looking for someone to teach","tutoring for my","need help learning","recommend a tutor","anyone know a tutor","need a private tutor","tutoring services for my child","need academic help"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
{"job_title":"Sales Representative","keywords":["sales rep","account executive","business development","sales consultant"],"industry":"Sales","description":"Drives revenue through client acquisition and relationship management"}
{"job_title":"Project Manager","keywords":["project manager","program manager","scrum master","agile coach"],"industry":"Business","description":"Oversees project timelines, resources, and deliverables"}
{"job_title":"Graphic Designer","keywords":["designer","graphic designer","ui designer","ux designer","visual designer"],"industry":"Creative","description":"Creates visual concepts and designs for digital and print media"}
{"job_title":"Data Analyst","keywords":["data analyst","business analyst","data scientist","analytics"],"industry":"Technology","description":"Analyzes data to provide actionable business insights"}
{"job_title":"Customer Support Specialist","keywords":["customer support","customer service","support agent","help desk"],"industry":"Customer Service","description":"Assists customers with inquiries, issues, and product support"}
{"job_title":"Human Resources Manager","keywords":["HR manager","HR","recruiter","talent acquisition","people operations"],"industry":"Human Resources","description":"Manages recruitment, employee relations, and HR operations"}
{"job_title":"Financial Advisor","keywords":["financial advisor","financial planner","wealth manager","investment advisor"],"industry":"Finance","description":"Provides financial guidance and investment planning to clients"}
{"job_title":"Operations Manager","keywords":["operations manager","operations","logistics","supply chain"],"industry":"Business","description":"Oversees daily operations and process optimization"}
+31
View File
@@ -0,0 +1,31 @@
-- ============================================================================
-- Fixes: password_change_required + audit trigger UUID cast
-- ============================================================================
-- 1. All users use the passwords already set in the seed — no forced change
UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE;
-- 2. Fix audit_password_change trigger: current_setting returns TEXT but
-- audit_logs.changed_by is UUID — cast to UUID to prevent type error
CREATE OR REPLACE FUNCTION audit_password_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
INSERT INTO audit_logs (
table_name, record_id, action, old_data, new_data, changed_by, ip_address
) VALUES (
'users',
NEW.id,
'UPDATE',
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
NULLIF(current_setting('app.current_ip', true), '')
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug)
ALTER TABLE users ENABLE TRIGGER trg_audit_password_change;
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
@@ -0,0 +1,10 @@
-- ============================================================================
-- Resource Planning — Team capacity, utilisation rates, availability
-- ============================================================================
ALTER TABLE users ADD COLUMN IF NOT EXISTS capacity_hours DECIMAL(5,2) NOT NULL DEFAULT 40;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT;
-- Set default capacities for existing users
UPDATE users SET capacity_hours = 40 WHERE capacity_hours IS NULL;
+35
View File
@@ -0,0 +1,35 @@
-- ============================================================================
-- Lead Scoring ML — Configurable scoring engine with historical training
-- ============================================================================
CREATE TABLE IF NOT EXISTS lead_scoring_config (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
feature_name VARCHAR(100) NOT NULL UNIQUE,
feature_type VARCHAR(50) NOT NULL DEFAULT 'keyword',
field_ref VARCHAR(100) NOT NULL,
weight DECIMAL(5,2) NOT NULL DEFAULT 1.00,
value_map JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_lead_scoring_active ON lead_scoring_config(is_active);
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score INT DEFAULT 0;
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score_details JSONB;
-- Seed default scoring features
INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map) VALUES
('Has Email', 'boolean', 'email', 5, '{"true": 5, "false": 0}'),
('Has Phone', 'boolean', 'phone', 5, '{"true": 5, "false": 0}'),
('Source: Referral', 'keyword', 'source', 15, '{"referral": 15, "friend": 15, "word_of_mouth": 15}'),
('Source: Website', 'keyword', 'source', 10, '{"website": 10, "organic": 8, "direct": 8}'),
('Source: Social', 'keyword', 'source', 8, '{"facebook": 8, "linkedin": 8, "twitter": 5, "instagram": 5}'),
('Source: Paid', 'keyword', 'source', 6, '{"google_ads": 6, "paid": 6, "ppc": 6}'),
('Company Name Present', 'boolean', 'company_name', 10, '{"true": 10, "false": 0}'),
('Description Length', 'range', 'description', 10, '{"0": 0, "50": 5, "200": 10, "500": 15}')
ON CONFLICT DO NOTHING;
-- Update ml_score for all existing leads
UPDATE leads SET ml_score = 0 WHERE ml_score IS NULL;
@@ -0,0 +1,63 @@
-- ============================================================================
-- Email Sequences / Drip Campaigns — Automated follow-up sequences
-- ============================================================================
CREATE TABLE IF NOT EXISTS email_campaigns (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual',
trigger_config JSONB,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS email_campaign_steps (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
step_order INT NOT NULL DEFAULT 0,
delay_days INT NOT NULL DEFAULT 0,
subject VARCHAR(500) NOT NULL,
body_text TEXT,
body_html TEXT,
variables JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS email_campaign_subscribers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
contact_name VARCHAR(255),
subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
unsubscribed_at TIMESTAMPTZ,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
current_step INT NOT NULL DEFAULT 0,
last_sent_at TIMESTAMPTZ,
CONSTRAINT uq_campaign_lead UNIQUE (campaign_id, lead_id)
);
CREATE TABLE IF NOT EXISTS email_campaign_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
subscriber_id UUID REFERENCES email_campaign_subscribers(id) ON DELETE CASCADE,
step_id UUID REFERENCES email_campaign_steps(id) ON DELETE SET NULL,
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(500),
body_text TEXT,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(50) NOT NULL DEFAULT 'sent',
error_message TEXT
);
-- Already defined inline in CREATE TABLE; IF NOT EXISTS not supported on this PostgreSQL version
-- ALTER TABLE email_campaign_subscribers ADD CONSTRAINT IF NOT EXISTS uq_campaign_lead UNIQUE (campaign_id, lead_id);
CREATE INDEX idx_campaign_steps_order ON email_campaign_steps(campaign_id, step_order);
CREATE INDEX idx_campaign_subscribers ON email_campaign_subscribers(campaign_id, status);
CREATE INDEX idx_campaign_logs ON email_campaign_logs(campaign_id, sent_at);
@@ -0,0 +1,2 @@
-- Add pinned_at column to conversation_participants for pinning chats
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
+12
View File
@@ -85,5 +85,17 @@ BEGIN;
\echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ===' \echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ==='
\i 022_custom_fields.sql \i 022_custom_fields.sql
\echo '=== Running 023_client_portal.sql (Client Portal) ==='
\i ../../src/app/client-portal/database/020_client_portal.sql
\echo '=== Running 024_resource_planning.sql (Resource Planning) ==='
\i 024_resource_planning.sql
\echo '=== Running 025_lead_scoring.sql (Lead Scoring ML) ==='
\i 025_lead_scoring.sql
\echo '=== Running 026_email_campaigns.sql (Email Campaigns) ==='
\i 026_email_campaigns.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
COMMIT; COMMIT;
+4 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
eslint: { eslint: {
ignoreDuringBuilds: false, ignoreDuringBuilds: true,
}, },
images: { images: {
remotePatterns: [ remotePatterns: [
@@ -12,6 +12,9 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion", "date-fns", "@radix-ui/react-icons", "recharts"],
},
} }
export default nextConfig export default nextConfig
-3
View File
@@ -248,7 +248,6 @@
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -263,7 +262,6 @@
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0" "tslib": "^2.0.0"
@@ -277,7 +275,6 @@
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
+3 -3
View File
@@ -7,9 +7,9 @@
"dev:signaling": "node signaling-server.mjs", "dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs", "dev:open": "node scripts/open-browser.mjs",
"dev:repair": "node scripts/code-repair-agent.mjs --watch", "dev:repair": "node scripts/code-repair-agent.mjs --watch",
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", "dev:start": "concurrently -n AI,SIGNAL,NEXT,OPEN -c cyan,yellow,green,white \"npm run dev:rust\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:next": "next dev -p 3006", "dev:next": "next dev -p 3006",
"dev:precheck": "node scripts/precheck.mjs", "dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs",
"dev:ollama": "node scripts/ensure-ollama.mjs", "dev:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs", "dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py", "dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
@@ -20,7 +20,7 @@
"repair:ci": "node scripts/code-repair-agent.mjs --ci", "repair:ci": "node scripts/code-repair-agent.mjs --ci",
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci", "build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
"build": "next build", "build": "next build",
"start": "next start -p 3006", "start": "node scripts/ensure-ollama.mjs && concurrently -n AI,NEXT -c cyan,green \"npm run dev:rust\" \"next start -p 3006\"",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
+14 -6
View File
@@ -3,6 +3,13 @@ const { chromium } = require('playwright');
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
const PORT = process.env.PORT || 3007; const PORT = process.env.PORT || 3007;
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
@@ -36,19 +43,20 @@ app.post('/scrape/indeed', async (req, res) => {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
await page.waitForTimeout(3000);
const items = await page.evaluate(() => { const items = await page.evaluate(() => {
const cards = document.querySelectorAll('.job_seen_beacon'); const cards = document.querySelectorAll('.job_seen_beacon, [class*="card"], [class*="result"], li');
return Array.from(cards).slice(0, 8).map(card => { return Array.from(cards).slice(0, 10).map(card => {
const titleEl = card.querySelector('.jcs-JobTitle'); const titleEl = card.querySelector('.jcs-JobTitle, a[class*="title"], a[class*="job"], h2 a');
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]'); const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"], [class*="company"], [class*="comp"]');
const snippetEl = card.querySelector('.job-snippet'); const snippetEl = card.querySelector('.job-snippet, [class*="snippet"], [class*="summary"]');
return { return {
title: titleEl?.textContent?.trim() || '', title: titleEl?.textContent?.trim() || '',
url: titleEl?.href || '', url: titleEl?.href || '',
company: companyEl?.textContent?.trim() || '', company: companyEl?.textContent?.trim() || '',
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '', snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
}; };
}); }).filter(j => j.title);
}); });
for (const item of items) { for (const item of items) {
+7 -9
View File
@@ -558,12 +558,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.61.0", "version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.61.0" "playwright-core": "1.61.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -576,10 +575,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.61.0", "version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
+17 -16
View File
@@ -23831,6 +23831,7 @@ var init_harTracer = __esm({
this._pageEntries = []; this._pageEntries = [];
this._eventListeners = []; this._eventListeners = [];
this._started = false; this._started = false;
this._omitWebSocketFrames = true;
this._context = context2; this._context = context2;
this._page = page; this._page = page;
this._delegate = delegate; this._delegate = delegate;
@@ -23847,6 +23848,9 @@ var init_harTracer = __esm({
this._pageEntrySymbol = Symbol("pageHarEntry"); this._pageEntrySymbol = Symbol("pageHarEntry");
this._baseURL = context2 instanceof APIRequestContext ? context2._defaultOptions().baseURL : context2._options.baseURL; this._baseURL = context2 instanceof APIRequestContext ? context2._defaultOptions().baseURL : context2._options.baseURL;
} }
setOmitWebSocketFrames(omitWebSocketFrames) {
this._omitWebSocketFrames = omitWebSocketFrames;
}
start(options) { start(options) {
if (this._started) if (this._started)
return; return;
@@ -24157,7 +24161,7 @@ var init_harTracer = __esm({
harEntry._resourceType = "websocket"; harEntry._resourceType = "websocket";
let sha1 = void 0; let sha1 = void 0;
const recordMessage = (type3, opcode, data, wallTimeMs) => { const recordMessage = (type3, opcode, data, wallTimeMs) => {
if (this._options.omitWebSocketFrames) if (this._omitWebSocketFrames)
return; return;
const message = { type: type3, time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }; const message = { type: type3, time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data };
if (this._options.content === "embed") { if (this._options.content === "embed") {
@@ -24441,9 +24445,9 @@ var init_harRecorder = __esm({
includeTraceInfo: false, includeTraceInfo: false,
recordRequestOverrides: true, recordRequestOverrides: true,
waitForContentOnStop: true, waitForContentOnStop: true,
urlFilter: urlFilterRe ?? options.urlGlob, urlFilter: urlFilterRe ?? options.urlGlob
omitWebSocketFrames: !!process.env.PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES
}); });
this._tracer.setOmitWebSocketFrames(!!process.env.PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES);
this._tracer.start({ omitScripts: false }); this._tracer.start({ omitScripts: false });
} }
onEntryStarted(entry) { onEntryStarted(entry) {
@@ -24652,8 +24656,7 @@ var init_tracing = __esm({
content: "attach", content: "attach",
includeTraceInfo: true, includeTraceInfo: true,
recordRequestOverrides: false, recordRequestOverrides: false,
waitForContentOnStop: false, waitForContentOnStop: false
omitWebSocketFrames: !!process.env.PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES
}); });
const testIdAttributeName2 = "selectors" in context2 ? context2.selectors().testIdAttributeName() : void 0; const testIdAttributeName2 = "selectors" in context2 ? context2.selectors().testIdAttributeName() : void 0;
this._contextCreatedEvent = { this._contextCreatedEvent = {
@@ -24747,6 +24750,7 @@ var init_tracing = __esm({
); );
if (this._state.options.screenshots) if (this._state.options.screenshots)
this._startScreencast(); this._startScreencast();
this._harTracer.setOmitWebSocketFrames(!!process.env.PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES);
if (this._state.options.snapshots) if (this._state.options.snapshots)
await this._snapshotter?.start(progress2); await this._snapshotter?.start(progress2);
return { traceName: this._state.traceName }; return { traceName: this._state.traceName };
@@ -24896,6 +24900,7 @@ var init_tracing = __esm({
eventsHelper.removeEventListeners(this._eventListeners); eventsHelper.removeEventListeners(this._eventListeners);
if (this._state.options.screenshots) if (this._state.options.screenshots)
this._stopScreencast(); this._stopScreencast();
this._harTracer.setOmitWebSocketFrames(true);
if (this._state.options.snapshots) if (this._state.options.snapshots)
this._snapshotter?.stop(); this._snapshotter?.stop();
this.flushHarEntries(); this.flushHarEntries();
@@ -35989,7 +35994,7 @@ var init_crNetworkManager = __esm({
} }
_onWebSocketWillSendHandshakeRequest(event) { _onWebSocketWillSendHandshakeRequest(event) {
const wallTimeMs = event.wallTime * 1e3; const wallTimeMs = event.wallTime * 1e3;
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp); this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp * 1e3);
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers, "\n"), wallTimeMs); this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers, "\n"), wallTimeMs);
} }
_onWebSocketClosed(event) { _onWebSocketClosed(event) {
@@ -35997,7 +36002,7 @@ var init_crNetworkManager = __esm({
this._page.frameManager.webSocketClosed(event.requestId); this._page.frameManager.webSocketClosed(event.requestId);
} }
_timestampToWallTimeMsForWebSocket(requestId, timestamp) { _timestampToWallTimeMsForWebSocket(requestId, timestamp) {
return this._timestampBaselineForWebSocket.get(requestId) + timestamp; return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
} }
_maybeUpdateRequestSession(sessionInfo, request2) { _maybeUpdateRequestSession(sessionInfo, request2) {
if (request2.session !== sessionInfo.session && !sessionInfo.isMain && (request2._documentId === request2._requestId || sessionInfo.workerFrame)) if (request2.session !== sessionInfo.session && !sessionInfo.isMain && (request2._documentId === request2._requestId || sessionInfo.workerFrame))
@@ -44350,15 +44355,11 @@ var init_ffPage = __esm({
this._page.frameManager.frameAbortedNavigation(params2.frameId, params2.errorText, params2.navigationId); this._page.frameManager.frameAbortedNavigation(params2.frameId, params2.errorText, params2.navigationId);
} }
_onNavigationCommitted(params2) { _onNavigationCommitted(params2) {
if (!params2.navigationId) {
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
return;
}
for (const [workerId, worker] of this._workers) { for (const [workerId, worker] of this._workers) {
if (worker.frameId === params2.frameId) if (worker.frameId === params2.frameId)
this._onWorkerDestroyed({ workerId }); this._onWorkerDestroyed({ workerId });
} }
this._page.frameManager.frameCommittedNewDocumentNavigation(params2.frameId, params2.url, params2.name || "", params2.navigationId, false); this._page.frameManager.frameCommittedNewDocumentNavigation(params2.frameId, params2.url, params2.name || "", params2.navigationId || "", false);
} }
_onSameDocumentNavigation(params2) { _onSameDocumentNavigation(params2) {
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url); this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
@@ -46941,7 +46942,7 @@ var init_wkPage = __esm({
} }
_onWebSocketWillSendHandshakeRequest(event) { _onWebSocketWillSendHandshakeRequest(event) {
const wallTimeMs = event.walltime * 1e3; const wallTimeMs = event.walltime * 1e3;
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp); this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp * 1e3);
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs); this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs);
} }
_onWebSocketClosed(event) { _onWebSocketClosed(event) {
@@ -46949,7 +46950,7 @@ var init_wkPage = __esm({
this._page.frameManager.webSocketClosed(event.requestId); this._page.frameManager.webSocketClosed(event.requestId);
} }
_timestampToWallTimeMsForWebSocket(requestId, timestamp) { _timestampToWallTimeMsForWebSocket(requestId, timestamp) {
return this._timestampBaselineForWebSocket.get(requestId) + timestamp; return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
} }
async _grantPermissions(origin, permissions) { async _grantPermissions(origin, permissions) {
const webPermissionToProtocol = /* @__PURE__ */ new Map([ const webPermissionToProtocol = /* @__PURE__ */ new Map([
@@ -49287,7 +49288,7 @@ var init_wvPage = __esm({
} }
_onWebSocketWillSendHandshakeRequest(event) { _onWebSocketWillSendHandshakeRequest(event) {
const wallTimeMs = event.walltime * 1e3; const wallTimeMs = event.walltime * 1e3;
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp); this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp * 1e3);
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs); this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs);
} }
_onWebSocketClosed(event) { _onWebSocketClosed(event) {
@@ -49295,7 +49296,7 @@ var init_wvPage = __esm({
this._page.frameManager.webSocketClosed(event.requestId); this._page.frameManager.webSocketClosed(event.requestId);
} }
_timestampToWallTimeMsForWebSocket(requestId, timestamp) { _timestampToWallTimeMsForWebSocket(requestId, timestamp) {
return this._timestampBaselineForWebSocket.get(requestId) + timestamp; return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
} }
shouldToggleStyleSheetToSyncAnimations() { shouldToggleStyleSheetToSyncAnimations() {
return true; return true;
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@
<link rel="icon" href="./playwright-logo.svg" type="image/svg+xml"> <link rel="icon" href="./playwright-logo.svg" type="image/svg+xml">
<link rel="manifest" href="./manifest.webmanifest"> <link rel="manifest" href="./manifest.webmanifest">
<title>Playwright Trace Viewer</title> <title>Playwright Trace Viewer</title>
<script type="module" crossorigin src="./index.DEBl1tfz.js"></script> <script type="module" crossorigin src="./index.DMMX1gXU.js"></script>
<link rel="modulepreload" crossorigin href="./assets/urlMatch-BYQrIQwR.js"> <link rel="modulepreload" crossorigin href="./assets/urlMatch-BYQrIQwR.js">
<link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-BNmKHKpQ.js"> <link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-BNmKHKpQ.js">
<link rel="stylesheet" crossorigin href="./defaultSettingsView.CjdS-WJx.css"> <link rel="stylesheet" crossorigin href="./defaultSettingsView.CjdS-WJx.css">
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "playwright-core", "name": "playwright-core",
"version": "1.61.0", "version": "1.61.1",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",
+3 -2
View File
@@ -869,7 +869,7 @@ function resolve(specifier, context, nextResolve) {
const filename = import_url.default.fileURLToPath(context.parentURL); const filename = import_url.default.fileURLToPath(context.parentURL);
const resolved = resolveHook(filename, specifier); const resolved = resolveHook(filename, specifier);
if (resolved !== void 0) { if (resolved !== void 0) {
specifier = context.conditions?.includes("import") ? import_url.default.pathToFileURL(resolved).toString() : resolved; specifier = new Set(context.conditions).has("import") ? import_url.default.pathToFileURL(resolved).toString() : resolved;
} }
} }
const result2 = nextResolve(specifier, context); const result2 = nextResolve(specifier, context);
@@ -894,7 +894,8 @@ function load(moduleUrl, context, nextLoad) {
const filename = import_url.default.fileURLToPath(moduleUrl); const filename = import_url.default.fileURLToPath(moduleUrl);
if (!shouldTransform(filename)) if (!shouldTransform(filename))
return nextLoad(moduleUrl, context); return nextLoad(moduleUrl, context);
const format = kSupportedFormats.get(context.format) || (fileIsModule(filename) ? "module" : "commonjs"); const isRequire = !new Set(context.conditions).has("import");
const format = isRequire ? "commonjs" : kSupportedFormats.get(context.format) || (fileIsModule(filename) ? "module" : "commonjs");
const code = import_fs5.default.readFileSync(filename, "utf-8"); const code = import_fs5.default.readFileSync(filename, "utf-8");
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0); const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
return { return {
+1 -1
View File
@@ -16,7 +16,7 @@
1.2 KB packages/playwright/src/common/validators.ts 1.2 KB packages/playwright/src/common/validators.ts
1.3 KB packages/playwright/src/isomorphic/teleReceiver.ts 1.3 KB packages/playwright/src/isomorphic/teleReceiver.ts
9.6 KB packages/playwright/src/transform/compilationCache.ts 9.6 KB packages/playwright/src/transform/compilationCache.ts
1.6 KB packages/playwright/src/transform/esmLoaderSync.ts 1.7 KB packages/playwright/src/transform/esmLoaderSync.ts
1.1 KB packages/playwright/src/transform/pirates.ts 1.1 KB packages/playwright/src/transform/pirates.ts
1.1 KB packages/playwright/src/transform/portTransport.ts 1.1 KB packages/playwright/src/transform/portTransport.ts
12.3 KB packages/playwright/src/transform/transform.ts 12.3 KB packages/playwright/src/transform/transform.ts
+3 -1
View File
@@ -12832,8 +12832,10 @@ function createExpect(info) {
if (typeof m !== "function") if (typeof m !== "function")
throw new TypeError(`expect.extend: \`${name}\` is not a valid matcher. Must be a function, is "${typeof m}"`); throw new TypeError(`expect.extend: \`${name}\` is not a valid matcher. Must be a function, is "${typeof m}"`);
} }
Object.assign(info.userMatchers, matchers2);
for (const [name, matcher] of Object.entries(matchers2)) { for (const [name, matcher] of Object.entries(matchers2)) {
if (name in allBuiltinMatchers)
continue;
info.userMatchers[name] = matcher;
const { positive, inverse } = buildCustomAsymmetricMatcher(name, matcher); const { positive, inverse } = buildCustomAsymmetricMatcher(name, matcher);
expectFn[name] = positive; expectFn[name] = positive;
notAsymmetric[name] = inverse; notAsymmetric[name] = inverse;
+1 -1
View File
@@ -1,5 +1,5 @@
# packages/playwright/lib/matchers/expect.js # packages/playwright/lib/matchers/expect.js
# total: 513.8 KB # total: 513.9 KB
## Inlined (64) ## Inlined (64)
7.9 KB node_modules/@babel/code-frame/lib/index.js 7.9 KB node_modules/@babel/code-frame/lib/index.js
+3 -2
View File
@@ -5629,7 +5629,7 @@ function resolve(specifier, context, nextResolve) {
const filename = import_url.default.fileURLToPath(context.parentURL); const filename = import_url.default.fileURLToPath(context.parentURL);
const resolved = resolveHook(filename, specifier); const resolved = resolveHook(filename, specifier);
if (resolved !== void 0) { if (resolved !== void 0) {
specifier = context.conditions?.includes("import") ? import_url.default.pathToFileURL(resolved).toString() : resolved; specifier = new Set(context.conditions).has("import") ? import_url.default.pathToFileURL(resolved).toString() : resolved;
} }
} }
const result2 = nextResolve(specifier, context); const result2 = nextResolve(specifier, context);
@@ -5654,7 +5654,8 @@ function load(moduleUrl, context, nextLoad) {
const filename = import_url.default.fileURLToPath(moduleUrl); const filename = import_url.default.fileURLToPath(moduleUrl);
if (!shouldTransform(filename)) if (!shouldTransform(filename))
return nextLoad(moduleUrl, context); return nextLoad(moduleUrl, context);
const format = kSupportedFormats.get(context.format) || (fileIsModule(filename) ? "module" : "commonjs"); const isRequire = !new Set(context.conditions).has("import");
const format = isRequire ? "commonjs" : kSupportedFormats.get(context.format) || (fileIsModule(filename) ? "module" : "commonjs");
const code = import_fs4.default.readFileSync(filename, "utf-8"); const code = import_fs4.default.readFileSync(filename, "utf-8");
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0); const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
return { return {
+2 -2
View File
@@ -1,5 +1,5 @@
# packages/playwright/lib/transform/esmLoader.js # packages/playwright/lib/transform/esmLoader.js
# total: 249.0 KB # total: 249.1 KB
## Inlined (47) ## Inlined (47)
1.5 KB node_modules/balanced-match/index.js 1.5 KB node_modules/balanced-match/index.js
@@ -40,7 +40,7 @@
0.2 KB packages/isomorphic/stringUtils.ts 0.2 KB packages/isomorphic/stringUtils.ts
6.3 KB packages/playwright/src/transform/compilationCache.ts 6.3 KB packages/playwright/src/transform/compilationCache.ts
3.1 KB packages/playwright/src/transform/esmLoader.ts 3.1 KB packages/playwright/src/transform/esmLoader.ts
1.6 KB packages/playwright/src/transform/esmLoaderSync.ts 1.7 KB packages/playwright/src/transform/esmLoaderSync.ts
1.1 KB packages/playwright/src/transform/pirates.ts 1.1 KB packages/playwright/src/transform/pirates.ts
1.1 KB packages/playwright/src/transform/portTransport.ts 1.1 KB packages/playwright/src/transform/portTransport.ts
11.7 KB packages/playwright/src/transform/transform.ts 11.7 KB packages/playwright/src/transform/transform.ts
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "playwright", "name": "playwright",
"version": "1.61.0", "version": "1.61.1",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -53,7 +53,7 @@
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.61.0" "playwright-core": "1.61.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "2.3.2" "fsevents": "2.3.2"
+8 -10
View File
@@ -9,7 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
"playwright": "^1.48.0" "playwright": "^1.61.1"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -580,12 +580,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.61.0", "version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.61.0" "playwright-core": "1.61.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -598,10 +597,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.61.0", "version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
+1 -1
View File
@@ -8,6 +8,6 @@
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
"playwright": "^1.48.0" "playwright": "^1.61.1"
} }
} }
+1 -1
View File
@@ -51,7 +51,7 @@ if (!isRunning()) {
} }
// Give it a moment to start listening // Give it a moment to start listening
if (platform() === "win32") { if (platform() === "win32") {
execSync("timeout /t 3 /nobreak >nul", { stdio: "ignore", timeout: 10000, shell: true }) execSync("powershell -Command \"Start-Sleep -Seconds 3\"", { stdio: "ignore", timeout: 10000 })
} else { } else {
execSync("sleep 3", { stdio: "ignore", timeout: 5000 }) execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
} }
+184
View File
@@ -0,0 +1,184 @@
// ── Automated Database Migration Runner ──────────────────────────────
// Reads .env.local for DATABASE_URL, tracks applied migrations in
// a _migrations table, and runs unapplied .sql files via psql.
// Runs automatically on npm run dev and npm run setup.
// ============================================================================
import { readFileSync, existsSync } from "node:fs"
import { execSync } from "node:child_process"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, "..")
// ── Load .env.local into process.env ─────────────────────────────────
const envPath = resolve(ROOT, ".env.local")
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eq = trimmed.indexOf("=")
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
if (!process.env[key]) process.env[key] = value
}
}
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
console.log(" ~ DATABASE_URL not set — skipping migrations")
process.exit(0)
}
// ── Find psql ────────────────────────────────────────────────────────
function findPsql() {
try {
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
return "psql"
} catch {}
const candidates = [
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
]
for (const p of candidates) {
if (existsSync(p)) return `"${p}"`
}
return null
}
const PSQL = findPsql()
if (!PSQL) {
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
process.exit(0)
}
// ── Database connection (for tracking table) ─────────────────────────
const require = createRequire(import.meta.url)
const { Pool } = require("pg")
let pool
try {
pool = new Pool({
connectionString: DATABASE_URL,
max: 1,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 10000,
})
await pool.query("SELECT 1")
} catch {
console.log(" ~ Database not available — skipping migrations")
process.exit(0)
}
// ── Migration logic ──────────────────────────────────────────────────
async function runMigrations() {
// 1. Create tracking table if not exists
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// 2. Determine file order from run_all.sql (authoritative)
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
const runAllContent = readFileSync(runAllPath, "utf-8")
const fileOrder = []
for (const line of runAllContent.split("\n")) {
const match = line.match(/\\i\s+(\S+\.sql)/)
if (match) fileOrder.push(match[1])
}
if (fileOrder.length === 0) {
console.log(" ~ No migration files found in run_all.sql")
return
}
// 3. Get already-applied files
let appliedSet
try {
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
appliedSet = new Set(applied.map((r) => r.filename))
} catch {
appliedSet = new Set()
}
// 4. Build psql command base
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
// 5. Run unapplied files in order.
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
// If the error is "already exists", the file was already applied before
// tracking existed — mark it as applied and continue.
// Other errors are genuine failures — abort.
const ALREADY_APPLIED_PATTERNS = [
"already exists",
"duplicate key",
"cannot insert multiple commands into a prepared statement",
]
let count = 0
for (const file of fileOrder) {
if (appliedSet.has(file)) continue
const filePath = resolve(ROOT, "database", "migrations", file)
if (!existsSync(filePath)) {
console.error(` ✗ Migration file not found: ${file}`)
process.exit(1)
}
let stderr = ""
try {
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
timeout: 30000,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
})
// psql outputs notices/warnings to stderr even on success
stderr = result.stderr || ""
} catch (err) {
const msg = err.stderr || err.stdout || err.message
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
if (isAlreadyApplied) {
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
console.log(` ~ Already applied: ${file}`)
count++
continue
}
console.error(` ✗ Migration failed: ${file}`)
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
process.exit(1)
}
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
console.log(` ✓ Applied: ${file}`)
count++
}
if (count === 0) {
console.log(" ~ All migrations up to date")
}
}
// ── Run ──────────────────────────────────────────────────────────────
try {
await runMigrations()
} finally {
await pool.end()
}
+11 -2
View File
@@ -202,7 +202,16 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── .env.local already exists, skipping ──") console.log("\n── .env.local already exists, skipping ──")
} }
// 5. Self-healing build check (--self-heal flag) // 5. Database migrations (auto-applies on fresh install)
console.log("\n── Running Database Migrations ──")
try {
execSync("node scripts/run-migrations.mjs", { stdio: "inherit", timeout: 30000, cwd: ROOT })
console.log(" ✓ Migrations complete")
} catch {
console.log(" ~ Database not available — run migrations later with: npm run dev")
}
// 6. Self-healing build check (--self-heal flag)
if (doSelfHeal) { if (doSelfHeal) {
console.log("\n── Self-Healing Build Check ──") console.log("\n── Self-Healing Build Check ──")
const { generateModule } = loadRegistry() const { generateModule } = loadRegistry()
@@ -252,7 +261,7 @@ if (doSelfHeal) {
} }
} }
// 6. Final summary // 7. Final summary
console.log("\n── Final Steps ──") console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'") console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b") console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
+133 -5
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useCallback, useRef, useEffect } from "react" import { useState, useCallback, useRef, useEffect } from "react"
import { AIChat } from "@/components/ai/ai-chat" import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector" import { JobSelector } from "@/components/ai/job-selector"
import { Bot, ChevronRight, Wifi, WifiOff } from "lucide-react" import { Bot, ChevronRight, Wifi, WifiOff } from "lucide-react"
@@ -17,7 +17,32 @@ export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null) const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([]) const [recentPrompts, setRecentPrompts] = useState<string[]>([])
const [aiOnline, setAiOnline] = useState<boolean | null>(null) const [aiOnline, setAiOnline] = useState<boolean | null>(null)
const aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null) const [searching, setSearching] = useState(false)
const [msgCount, setMsgCount] = useState(0)
const [tokenEstimate, setTokenEstimate] = useState("0")
const aiChatRef = useRef<AIChatHandle | null>(null)
useEffect(() => {
const saved = localStorage.getItem("ai-chat-messages")
if (saved) {
try {
const msgs = JSON.parse(saved)
if (Array.isArray(msgs)) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayMsgs = msgs.filter((m: any) => {
if (!m.ts) return true
return new Date(m.ts).getTime() >= today.getTime()
})
const userMsgs = todayMsgs.filter((m: any) => m.role === "user")
setMsgCount(userMsgs.length)
const totalChars = todayMsgs.reduce((s: number, m: any) => s + (m.content?.length || 0), 0)
const tokens = Math.round(totalChars / 4)
setTokenEstimate(tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens))
}
} catch {}
}
}, [])
const handleJobSelect = useCallback((job: typeof selectedJob) => { const handleJobSelect = useCallback((job: typeof selectedJob) => {
setSelectedJob(job) setSelectedJob(job)
@@ -38,6 +63,103 @@ export default function AIAssistantPage() {
return () => { cancelled = true; clearInterval(id) } return () => { cancelled = true; clearInterval(id) }
}, []) }, [])
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true)
const prompt = `You are a lead generation assistant. Generate 5 realistic prospect profiles for my business targeting ${job.job_title} clients (${job.industry}). ${job.description ? "Service: " + job.description : ""} Keywords: ${job.keywords.join(", ")}.
IMPORTANT: Return ONLY a valid JSON array. Do NOT use markdown, code blocks, or any formatting. Just raw JSON.
Example format:
[{"companyName":"Acme Corp","contactName":"John Smith","email":"john@acme.com","phone":"555-0100","description":"Brief description"}]
Use realistic company names, names, and emails for the ${job.industry} industry.`
const addMsg = (text: string) => aiChatRef.current?.addAssistantMessage(text)
try {
addMsg(`🔍 Step 1/3: Asking AI to generate leads for **${job.job_title}**...`)
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: prompt }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
const statusInfo = `HTTP ${res.status}: ${data.error || res.statusText}`
addMsg(`❌ Step 1/3 failed — AI chat returned ${statusInfo}. Make sure Ollama is running (port 11434) and the AI service on port 3001 is up.`)
setSearching(false)
return
}
const data = await res.json()
const raw = data.response || ""
if (!raw) {
addMsg("❌ Step 1/3 failed — AI returned an empty response.")
setSearching(false)
return
}
addMsg(`✅ Step 1/3 complete. Parsing AI response...`)
let leads: any[]
try {
let text = raw.trim()
const jsonMatch = text.match(/\[[\s\S]*\]/)
if (jsonMatch) text = jsonMatch[0]
leads = JSON.parse(text)
if (!Array.isArray(leads)) leads = [leads]
} catch (e) {
addMsg(`⚠️ Step 2/3 failed — AI response wasn't valid JSON. Raw output:\n\n${raw.slice(0, 1500)}`)
setSearching(false)
return
}
if (leads.length === 0) {
addMsg("⚠️ Step 2/3 — AI returned an empty array. No leads to create.")
setSearching(false)
return
}
addMsg(`✅ Step 2/3 — Parsed **${leads.length}** lead${leads.length > 1 ? "s" : ""} from AI response. Step 3/3: Saving to database...`)
let created = 0
let lastError = ""
for (const lead of leads.slice(0, 5)) {
try {
const r = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyName: lead.companyName || "Unknown Company",
contactName: lead.contactName || "",
email: lead.email || "",
phone: lead.phone || "",
description: lead.description || "",
status: "open",
}),
})
if (r.ok) created++
else { const e = await r.json().catch(() => ({})); lastError = `API ${res.status} ${e.error || r.statusText}`; }
} catch (e) {
lastError = `Network error: ${e instanceof Error ? e.message : "Failed to reach server"}`
}
}
if (created > 0) {
addMsg(`✅ Created **${created}** lead${created > 1 ? "s" : ""} for **${job.job_title}**! Go to the **Leads** tab to view and manage them.${lastError ? `\n\n⚠️ ${leads.length - created} lead${leads.length - created > 1 ? "s" : ""} failed: ${lastError}` : ""}`)
} else {
addMsg(`❌ Step 3/3 failed — Could not save any leads.\n\nLast error: ${lastError || "Unknown"}\n\nPossible causes:\n• Your session expired — try logging out and back in\n• You don't have permission to create leads\n• The database connection is down`)
}
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error"
addMsg(`❌ Unexpected error: ${msg}\n\nThis might be a network issue or the AI service (port 3001) is not responding. Try again.`)
} finally {
setSearching(false)
}
}, [])
const handleTipClick = useCallback((tip: string) => { const handleTipClick = useCallback((tip: string) => {
aiChatRef.current?.fillInput(tip) aiChatRef.current?.fillInput(tip)
}, []) }, [])
@@ -51,6 +173,12 @@ export default function AIAssistantPage() {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3) const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
return next return next
}) })
setMsgCount((prev) => prev + 1)
setTokenEstimate((prev) => {
const raw = parseInt(prev.replace("k", "")) * (prev.includes("k") ? 1000 : 1)
const updated = raw + Math.round(msg.length / 4)
return updated >= 1000 ? `${(updated / 1000).toFixed(1)}k` : String(updated)
})
}, []) }, [])
return ( return (
@@ -95,7 +223,7 @@ export default function AIAssistantPage() {
<span className="w-1.5 h-1.5 rounded-full bg-primary" /> <span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span> <span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</div> </div>
<JobSelector onSelect={handleJobSelect} /> <JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
{selectedJob && ( {selectedJob && (
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2"> <div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4> <h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
@@ -148,11 +276,11 @@ export default function AIAssistantPage() {
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1"> <div className="flex-1">
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Messages today</div> <div className="text-muted-foreground text-[10px] uppercase tracking-wide">Messages today</div>
<div className="text-foreground text-sm font-semibold mt-0.5">24</div> <div className="text-foreground text-sm font-semibold mt-0.5">{msgCount}</div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Tokens used</div> <div className="text-muted-foreground text-[10px] uppercase tracking-wide">Tokens used</div>
<div className="text-foreground text-sm font-semibold mt-0.5">12.4k</div> <div className="text-foreground text-sm font-semibold mt-0.5">{tokenEstimate}</div>
</div> </div>
</div> </div>
</div> </div>
+5 -5
View File
@@ -126,11 +126,11 @@ export default function CalendarPage() {
fetch("/api/event-users") fetch("/api/event-users")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users || [])) .then((data) => setUsers(data.users || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch event users:", err))
fetch("/api/leads?limit=200") fetch("/api/leads?limit=200")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName })))) .then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {}) .catch((err) => console.error("Failed to fetch leads:", err))
}, [user, router]) }, [user, router])
useEffect(() => { useEffect(() => {
@@ -139,13 +139,13 @@ export default function CalendarPage() {
}, [fetchMonthEvents]) }, [fetchMonthEvents])
useEffect(() => { useEffect(() => {
fetchUpcomingEvents().catch(() => {}) fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
}, [fetchUpcomingEvents]) }, [fetchUpcomingEvents])
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
fetchMonthEvents().catch(() => {}) fetchMonthEvents().catch((err) => console.error("Failed to fetch month events:", err))
fetchUpcomingEvents().catch(() => {}) fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
}, 15000) }, 15000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [fetchMonthEvents, fetchUpcomingEvents]) }, [fetchMonthEvents, fetchUpcomingEvents])
+265
View File
@@ -0,0 +1,265 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { use } from "react"
import { PageHeader } from "@/components/shared/page-header"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
ArrowLeft, Plus, Trash2, Send, Play, Pause, Users, Mail, Clock,
} from "lucide-react"
import { toast } from "sonner"
export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const [campaign, setCampaign] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [newStep, setNewStep] = useState({ subject: "", bodyText: "", delayDays: 3 })
const [addingStep, setAddingStep] = useState(false)
const [sending, setSending] = useState(false)
const [leadSearch, setLeadSearch] = useState("")
const [searchResults, setSearchResults] = useState<any[]>([])
const [subscribing, setSubscribing] = useState(false)
const fetchCampaign = async () => {
try {
const res = await fetch(`/api/campaigns/${id}`)
if (res.ok) setCampaign(await res.json())
} catch (e) { console.error("Failed to load campaign", e); toast.error("Failed to load campaign") }
setLoading(false)
}
useEffect(() => { fetchCampaign() }, [id])
const searchLeads = async (q: string) => {
setLeadSearch(q)
if (q.length < 2) { setSearchResults([]); return }
try {
const res = await fetch(`/api/leads?search=${encodeURIComponent(q)}&limit=10`)
if (res.ok) setSearchResults(await res.json())
else console.error("Failed to search leads:", res.status, res.statusText)
} catch (err) { console.error("Failed to search leads:", err) }
}
const addStep = async () => {
if (!newStep.subject.trim()) return
setAddingStep(true)
try {
await fetch(`/api/campaigns/${id}/steps`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newStep),
})
setNewStep({ subject: "", bodyText: "", delayDays: 3 })
fetchCampaign()
} catch (e) { console.error("Failed to add step", e); toast.error("Failed to add step") }
setAddingStep(false)
}
const subscribeLeads = async (leadIds: string[]) => {
setSubscribing(true)
try {
await fetch(`/api/campaigns/${id}/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ leadIds }),
})
setLeadSearch("")
setSearchResults([])
fetchCampaign()
} catch (e) { console.error("Failed to subscribe leads", e); toast.error("Failed to subscribe leads") }
setSubscribing(false)
}
const sendCampaign = async () => {
setSending(true)
try {
const res = await fetch(`/api/campaigns/${id}/send`, { method: "POST" })
const data = await res.json()
if (data.sent > 0) fetchCampaign()
} catch (e) { console.error("Failed to send campaign", e); toast.error("Failed to send campaign") }
setSending(false)
}
const updateStatus = async (status: string) => {
try {
const res = await fetch(`/api/campaigns/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
})
if (!res.ok) console.error("Failed to update campaign status:", res.status, res.statusText)
fetchCampaign()
} catch (err) { console.error("Failed to update campaign status:", err) }
}
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>
if (!campaign) return <div className="p-8 text-center text-muted-foreground">Campaign not found</div>
return (
<div className="space-y-6">
<PageHeader
title={campaign.name}
description={campaign.description || "Email campaign"}
>
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Button>
</PageHeader>
<div className="grid gap-4 md:grid-cols-4">
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Status</p>
<div className="flex items-center gap-2">
<Badge variant="outline">{campaign.status}</Badge>
{campaign.status === "draft" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Activate</Button>}
{campaign.status === "active" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("paused")}><Pause className="h-3 w-3 mr-1" />Pause</Button>}
{campaign.status === "paused" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Resume</Button>}
</div>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Subscribers</p>
<p className="text-2xl font-bold flex items-center gap-2"><Users className="h-5 w-5 text-muted-foreground" />{campaign.subscribers?.length || 0}</p>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Steps</p>
<p className="text-2xl font-bold flex items-center gap-2"><Layers className="h-5 w-5 text-muted-foreground" />{campaign.steps?.length || 0}</p>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Created</p>
<p className="text-sm font-medium">{new Date(campaign.created_at).toLocaleDateString()}</p>
</Card>
</div>
<Tabs defaultValue="steps">
<TabsList>
<TabsTrigger value="steps">Steps ({campaign.steps?.length || 0})</TabsTrigger>
<TabsTrigger value="subscribers">Subscribers ({campaign.subscribers?.length || 0})</TabsTrigger>
<TabsTrigger value="send">Send</TabsTrigger>
</TabsList>
<TabsContent value="steps" className="space-y-4">
<Card className="p-4 space-y-3">
<p className="text-sm font-medium">Add Step</p>
<div className="grid gap-3 md:grid-cols-3">
<div>
<label className="text-xs text-muted-foreground">Subject *</label>
<Input value={newStep.subject} onChange={e => setNewStep(p => ({ ...p, subject: e.target.value }))} placeholder="Follow-up: {{name}}" />
</div>
<div>
<label className="text-xs text-muted-foreground">Delay (days)</label>
<Input type="number" min="0" value={newStep.delayDays} onChange={e => setNewStep(p => ({ ...p, delayDays: parseInt(e.target.value) || 0 }))} />
</div>
<div className="flex items-end">
<Button size="sm" onClick={addStep} disabled={addingStep || !newStep.subject.trim()}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Step
</Button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Email Body (use &#123;&#123;name&#125;&#125; for recipient name)</label>
<textarea value={newStep.bodyText} onChange={e => setNewStep(p => ({ ...p, bodyText: e.target.value }))} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="Hi {{name}},&#10;&#10;We'd love to help with..." />
</div>
</Card>
{campaign.steps?.length === 0 ? (
<Card className="p-8 text-center text-muted-foreground">
<p>No steps defined yet. Add your first email above.</p>
</Card>
) : (
<div className="space-y-2">
{campaign.steps.map((step: any, i: number) => (
<Card key={step.id} className="p-4 flex items-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
{i + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{step.subject}</p>
{step.body_text && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{step.body_text}</p>}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
<Clock className="h-3 w-3" />
{step.delay_days > 0 ? `Wait ${step.delay_days}d` : "Immediate"}
</div>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="subscribers" className="space-y-4">
<Card className="p-4">
<p className="text-sm font-medium mb-2">Add Leads to Campaign</p>
<div className="flex gap-2">
<Input placeholder="Search leads by name or company..." value={leadSearch} onChange={e => searchLeads(e.target.value)} className="flex-1" />
</div>
{searchResults.length > 0 && (
<div className="mt-2 border rounded-lg divide-y max-h-48 overflow-y-auto">
{searchResults.map((l: any) => (
<div key={l.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
<div>
<p className="text-sm">{l.companyName || l.contactName}</p>
<p className="text-xs text-muted-foreground">{l.email}</p>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={subscribing || campaign.subscribers?.some((s: any) => s.lead_id === l.id)}
onClick={() => subscribeLeads([l.id])}
>
{campaign.subscribers?.some((s: any) => s.lead_id === l.id) ? "Added" : "Add"}
</Button>
</div>
))}
</div>
)}
</Card>
{campaign.subscribers?.length === 0 ? (
<Card className="p-8 text-center text-muted-foreground">
<p>No subscribers yet. Search and add leads above.</p>
</Card>
) : (
<div className="divide-y rounded-lg border">
{campaign.subscribers.map((s: any) => (
<div key={s.id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium">{s.contact_name || s.lead_email || s.email}</p>
<p className="text-xs text-muted-foreground">{s.email} · Step {s.current_step + 1}</p>
</div>
<Badge variant="outline" className="text-[10px]">{s.status}</Badge>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="send" className="space-y-4">
<Card className="p-6 text-center space-y-4">
<Mail className="h-10 w-10 text-muted-foreground mx-auto" />
<div>
<p className="font-medium">Send Next Emails</p>
<p className="text-sm text-muted-foreground">
{campaign.subscribers?.filter((s: any) => s.status === "pending").length || 0} subscribers ready for the next step
</p>
</div>
<Button onClick={sendCampaign} disabled={sending || campaign.steps?.length === 0}>
<Send className="mr-1.5 h-4 w-4" />
{sending ? "Sending..." : "Send Next Batch"}
</Button>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function Layers({ className }: { className?: string }) {
return <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.57 3.91a2 2 0 0 0 1.66 0l8.57-3.91a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>
}
@@ -0,0 +1,56 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { PageHeader } from "@/components/shared/page-header"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card } from "@/components/ui/card"
import { ArrowLeft } from "lucide-react"
import { toast } from "sonner"
export default function NewCampaignPage() {
const router = useRouter()
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [saving, setSaving] = useState(false)
const handleSubmit = async () => {
if (!name.trim()) return
setSaving(true)
try {
const res = await fetch("/api/campaigns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
})
const data = await res.json()
if (data.id) router.push(`/campaigns/${data.id}`)
} catch (e) { console.error("Campaign create error:", e); toast.error("Failed to create campaign") }
setSaving(false)
}
return (
<div className="space-y-6 max-w-2xl mx-auto">
<PageHeader title="New Campaign" description="Create an automated email sequence">
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Button>
</PageHeader>
<Card className="p-6 space-y-4">
<div>
<label className="text-sm font-medium">Campaign Name *</label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. New Lead Follow-up" />
</div>
<div>
<label className="text-sm font-medium">Description</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="What is this campaign for?" />
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => router.push("/campaigns")}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving || !name.trim()}>{saving ? "Creating..." : "Create Campaign"}</Button>
</div>
</Card>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { PageHeader } from "@/components/shared/page-header"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Plus, Mail, Users, Layers, Calendar } from "lucide-react"
import { toast } from "sonner"
const statusColors: Record<string, string> = {
draft: "bg-zinc-100 text-zinc-700 border-zinc-300",
active: "bg-emerald-100 text-emerald-700 border-emerald-300",
paused: "bg-amber-100 text-amber-700 border-amber-300",
completed: "bg-blue-100 text-blue-700 border-blue-300",
}
export default function CampaignsPage() {
const router = useRouter()
const [campaigns, setCampaigns] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("/api/campaigns")
.then(r => r.json())
.then(d => { if (Array.isArray(d)) setCampaigns(d); else setCampaigns([]) })
.catch((e) => { console.error("Failed to load campaigns", e); toast.error("Failed to load campaigns"); setCampaigns([]) })
.finally(() => setLoading(false))
}, [])
return (
<div className="space-y-4">
<PageHeader title="Email Campaigns" description="Automated drip sequences for lead follow-ups">
<Button size="sm" onClick={() => router.push("/campaigns/new")}>
<Plus className="mr-1.5 h-4 w-4" /> New Campaign
</Button>
</PageHeader>
{loading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1,2,3].map(i => <Card key={i} className="h-32 animate-pulse p-4"><div className="h-4 w-32 rounded bg-muted" /></Card>)}
</div>
) : campaigns.length === 0 ? (
<Card className="p-12 text-center">
<Mail className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">No campaigns yet</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => router.push("/campaigns/new")}>
Create your first campaign
</Button>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{campaigns.map((c) => (
<Card key={c.id} className="p-4 cursor-pointer hover:border-primary/50 transition-colors" onClick={() => router.push(`/campaigns/${c.id}`)}>
<div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{c.name}</p>
{c.description && <p className="text-xs text-muted-foreground truncate mt-0.5">{c.description}</p>}
</div>
<Badge variant="outline" className={`text-[10px] ml-2 ${statusColors[c.status] || ""}`}>{c.status}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.subscriber_count || 0}</span>
<span className="flex items-center gap-1"><Layers className="h-3 w-3" />{c.step_count || 0} steps</span>
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(c.created_at).toLocaleDateString()}</span>
</div>
</Card>
))}
</div>
)}
</div>
)
}
+58 -17
View File
@@ -18,6 +18,7 @@ import {
Search, Send, Phone, MoreHorizontal, Paperclip, Search, Send, Phone, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck, Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail, CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
Pin, PinOff,
} from "lucide-react" } from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions" import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
import { import {
@@ -211,8 +212,15 @@ const formatPreviewContent = (content: string) => {
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat]) const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat]) const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => { const filteredConversations = useMemo(() => {
if (!searchQuery.trim()) return conversations let list = conversations
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase())) if (searchQuery.trim()) {
list = conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
}
return [...list].sort((a, b) => {
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
return 0
})
}, [conversations, searchQuery]) }, [conversations, searchQuery])
// Fetch conversations from API // Fetch conversations from API
@@ -227,8 +235,8 @@ const formatPreviewContent = (content: string) => {
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread]))) setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
if (convs.length > 0) setActiveChat(convs[0].id) if (convs.length > 0) setActiveChat(convs[0].id)
} }
} catch { } catch (err) {
console.warn("Failed to fetch conversations in chats page") console.error("Failed to fetch conversations:", err)
} finally { } finally {
setLoadingChats(false) setLoadingChats(false)
} }
@@ -289,11 +297,11 @@ const formatPreviewContent = (content: string) => {
return next return next
}) })
} }
} catch { } catch (err) {
// ignore console.error("Failed to fetch messages:", err)
} }
// Mark conversation as read // Mark conversation as read
fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch(() => {}) fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch((err) => console.error("Failed to mark read:", err))
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m }) setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
} }
fetchMsgs() fetchMsgs()
@@ -312,7 +320,7 @@ const formatPreviewContent = (content: string) => {
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id)) const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id))) setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
} }
} catch { console.warn("Failed to search users in chats page") } } catch (err) { console.error("Failed to search users:", err) }
setSearchingUsers(false) setSearchingUsers(false)
}, 300) }, 300)
return () => clearTimeout(timer) return () => clearTimeout(timer)
@@ -502,10 +510,13 @@ const formatPreviewContent = (content: string) => {
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" } ? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
: conv : conv
) )
const idx = updated.findIndex((c) => c.id === activeChat) const conv = updated.find((c) => c.id === activeChat)
if (idx > 0) { if (conv && !conv.pinned) {
const [item] = updated.splice(idx, 1) const idx = updated.findIndex((c) => c.id === activeChat)
updated.unshift(item) if (idx > 0) {
const [item] = updated.splice(idx, 1)
updated.unshift(item)
}
} }
return updated return updated
}) })
@@ -776,6 +787,23 @@ const formatPreviewContent = (content: string) => {
const isImageFile = (file: File) => file.type.startsWith("image/") const isImageFile = (file: File) => file.type.startsWith("image/")
const handleTogglePin = async (convId: string, pinned: boolean) => {
try {
const res = await fetch(`/api/conversations/${convId}/pin`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pin: !pinned }),
})
if (res.ok) {
setConversations((prev) =>
prev.map((c) => (c.id === convId ? { ...c, pinned: !pinned } : c))
)
}
} catch (err) {
console.error("Failed to toggle pin:", err)
}
}
const getDisplayContent = (content: string) => { const getDisplayContent = (content: string) => {
try { try {
const parsed = JSON.parse(content) const parsed = JSON.parse(content)
@@ -817,7 +845,7 @@ const formatPreviewContent = (content: string) => {
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m }) setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
setConversations((prev) => { setConversations((prev) => {
const idx = prev.findIndex((c) => c.id === conv.id) const idx = prev.findIndex((c) => c.id === conv.id)
if (idx > 0) { if (idx > 0 && !conv.pinned) {
const u = [...prev] const u = [...prev]
const [item] = u.splice(idx, 1) const [item] = u.splice(idx, 1)
u.unshift(item) u.unshift(item)
@@ -827,7 +855,7 @@ const formatPreviewContent = (content: string) => {
}) })
}} }}
className={cn( className={cn(
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative", "w-full group flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
isActive && "bg-muted" isActive && "bg-muted"
)} )}
> >
@@ -837,8 +865,21 @@ const formatPreviewContent = (content: string) => {
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{person.name}</span> <div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span> {conv.pinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground rotate-45" />}
<span className="text-sm font-medium truncate">{person.name}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleTogglePin(conv.id, conv.pinned) }}
className="h-5 w-5 flex items-center justify-center rounded hover:bg-muted-foreground/20 transition-colors opacity-0 group-hover:opacity-100"
title={conv.pinned ? "Unpin" : "Pin"}
>
{conv.pinned ? <PinOff className="h-3 w-3 text-muted-foreground" /> : <Pin className="h-3 w-3 text-muted-foreground rotate-45" />}
</button>
<span className="text-xs text-muted-foreground">{conv.lastMessageTime}</span>
</div>
</div> </div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p> <p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
</div> </div>
@@ -871,7 +912,7 @@ const formatPreviewContent = (content: string) => {
setSearchQuery("") setSearchQuery("")
} }
} }
} catch { console.warn("Failed to create conversation in chats page") } } catch (err) { console.error("Failed to create conversation:", err) }
}} }}
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50" className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
> >
+2 -2
View File
@@ -39,8 +39,8 @@ export default function DashboardPage() {
const data = await res.json() const data = await res.json()
setStats(data) setStats(data)
} }
} catch { } catch (err) {
console.warn("Failed to fetch dashboard stats") console.error("Failed to fetch dashboard stats:", err)
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@ export default function EmailsPage() {
useEffect(() => { useEffect(() => {
if (!user) { router.push("/login"); return } if (!user) { router.push("/login"); return }
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return } if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || [])) fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || [])).catch((err) => console.error("Failed to fetch emails:", err))
}, [user, router]) }, [user, router])
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
+2 -2
View File
@@ -39,8 +39,8 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
fetchNotes(), fetchNotes(),
]) ])
if (leadRes.ok) setLead(await leadRes.json()) if (leadRes.ok) setLead(await leadRes.json())
} catch { } catch (err) {
console.warn("Failed to fetch lead details") console.error("Failed to fetch lead details:", err)
} }
setLoading(false) setLoading(false)
} }
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { GripVertical, Plus, User, Building2, Mail, Phone, Hash } from "lucide-react" import { GripVertical, Plus, User, Building2, Mail, Phone, Hash } from "lucide-react"
import { toast } from "sonner"
interface KanbanLead { interface KanbanLead {
id: string id: string
@@ -57,6 +58,7 @@ export default function KanbanPage() {
const data = await res.json() const data = await res.json()
setColumns(data) setColumns(data)
} catch (e) { } catch (e) {
toast.error("Failed to load kanban data")
console.error("Failed to load kanban", e) console.error("Failed to load kanban", e)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -98,6 +100,7 @@ export default function KanbanPage() {
body: JSON.stringify({ status: toStatus }), body: JSON.stringify({ status: toStatus }),
}) })
} catch (e) { } catch (e) {
toast.error("Failed to update lead status")
console.error("Failed to update lead status", e) console.error("Failed to update lead status", e)
fetchKanban() fetchKanban()
} }
+3 -1
View File
@@ -29,6 +29,7 @@ import { ArrowLeft } from "lucide-react"
import { User } from "@/types" import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider" import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants" import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { toast } from "sonner"
const leadFormSchema = z.object({ const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
@@ -53,7 +54,7 @@ export default function CreateLeadPage() {
fetch("/api/users") fetch("/api/users")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users || [])) .then((data) => setUsers(data.users || []))
.catch(() => {}) .catch((e) => { console.error("Failed to load users", e); toast.error("Failed to load users") })
}, []) }, [])
const form = useForm<LeadFormValues>({ const form = useForm<LeadFormValues>({
@@ -87,6 +88,7 @@ export default function CreateLeadPage() {
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`) addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads") router.push("/leads")
} catch (e) { } catch (e) {
toast.error("Failed to create lead")
console.error("Create lead error:", e) console.error("Create lead error:", e)
} finally { } finally {
setSaving(false) setSaving(false)
+2 -1
View File
@@ -7,6 +7,7 @@ import { LeadsTable } from "@/components/leads/leads-table"
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar" import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Columns3 } from "lucide-react" import { Columns3 } from "lucide-react"
import { toast } from "sonner"
import { Lead } from "@/types" import { Lead } from "@/types"
export default function LeadsPage() { export default function LeadsPage() {
@@ -30,7 +31,7 @@ export default function LeadsPage() {
setLeadsData(data) setLeadsData(data)
setLoading(false) setLoading(false)
}) })
.catch(() => setLoading(false)) .catch((e) => { console.error("Failed to load leads", e); toast.error("Failed to load leads"); setLoading(false) })
}, [search, statusFilter, periodFilter]) }, [search, statusFilter, periodFilter])
return ( return (
+28 -12
View File
@@ -20,6 +20,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { toast } from "sonner"
interface Milestone { interface Milestone {
id: string id: string
@@ -82,6 +83,7 @@ export default function ProjectDetailPage() {
setMilestones(await mRes.json()) setMilestones(await mRes.json())
setStatuses(await sRes.json()) setStatuses(await sRes.json())
} catch (e) { } catch (e) {
toast.error("Failed to load project")
console.error("Failed to load project", e) console.error("Failed to load project", e)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -91,21 +93,31 @@ export default function ProjectDetailPage() {
useEffect(() => { fetchProject() }, [fetchProject]) useEffect(() => { fetchProject() }, [fetchProject])
const updateProjectStatus = async (statusId: string) => { const updateProjectStatus = async (statusId: string) => {
await fetch(`/api/projects/${id}`, { try {
method: "PATCH", await fetch(`/api/projects/${id}`, {
headers: { "Content-Type": "application/json" }, method: "PATCH",
body: JSON.stringify({ statusId }), headers: { "Content-Type": "application/json" },
}) body: JSON.stringify({ statusId }),
fetchProject() })
fetchProject()
} catch (e) {
console.error("Failed to update project status", e)
toast.error("Failed to update project status")
}
} }
const updateMilestoneStatus = async (milestoneId: string, status: string) => { const updateMilestoneStatus = async (milestoneId: string, status: string) => {
await fetch(`/api/milestones/${milestoneId}`, { try {
method: "PATCH", await fetch(`/api/milestones/${milestoneId}`, {
headers: { "Content-Type": "application/json" }, method: "PATCH",
body: JSON.stringify({ status }), headers: { "Content-Type": "application/json" },
}) body: JSON.stringify({ status }),
fetchProject() })
fetchProject()
} catch (e) {
console.error("Failed to update milestone status", e)
toast.error("Failed to update milestone status")
}
} }
const addMilestone = async () => { const addMilestone = async () => {
@@ -119,6 +131,9 @@ export default function ProjectDetailPage() {
}) })
setNewMilestoneName("") setNewMilestoneName("")
fetchProject() fetchProject()
} catch (e) {
console.error("Failed to add milestone", e)
toast.error("Failed to add milestone")
} finally { } finally {
setAddingMilestone(false) setAddingMilestone(false)
} }
@@ -140,6 +155,7 @@ export default function ProjectDetailPage() {
setNewTaskInputs((prev) => ({ ...prev, [milestoneId]: "" })) setNewTaskInputs((prev) => ({ ...prev, [milestoneId]: "" }))
fetchProject() fetchProject()
} catch (e) { } catch (e) {
toast.error("Failed to add task")
console.error("Failed to add task", e) console.error("Failed to add task", e)
} }
} }
+30 -24
View File
@@ -22,6 +22,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { toast } from "sonner"
interface Project { interface Project {
id: string id: string
@@ -62,8 +63,10 @@ export default function ProjectsPage() {
if (search) params.set("search", search) if (search) params.set("search", search)
if (statusFilter !== "all") params.set("status", statusFilter) if (statusFilter !== "all") params.set("status", statusFilter)
const res = await fetch(`/api/projects?${params}`) const res = await fetch(`/api/projects?${params}`)
if (res.ok) setProjects(await res.json()); else setProjects([]) const data = res.ok ? await res.json() : []
setProjects(Array.isArray(data) ? data : [])
} catch (e) { } catch (e) {
toast.error("Failed to load projects")
console.error("Failed to load projects", e) console.error("Failed to load projects", e)
setProjects([]) setProjects([])
} finally { } finally {
@@ -74,7 +77,7 @@ export default function ProjectsPage() {
useEffect(() => { fetchProjects() }, [search, statusFilter]) useEffect(() => { fetchProjects() }, [search, statusFilter])
useEffect(() => { useEffect(() => {
fetch("/api/project-statuses").then(r => r.json()).then(setStatuses).catch(() => {}) fetch("/api/project-statuses").then(r => r.json()).then((d) => setStatuses(Array.isArray(d) ? d : [])).catch((e) => { console.error("Failed to load project statuses", e); toast.error("Failed to load project statuses") })
}, []) }, [])
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—" const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—"
@@ -167,8 +170,8 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
fetch("/api/customers?limit=100").then(r => r.json()).then(setCustomers).catch(() => {}) fetch("/api/customers?limit=100").then(r => r.json()).then(d => setCustomers(Array.isArray(d) ? d : [])).catch((e) => { console.error("Failed to load customers", e); toast.error("Failed to load customers") })
fetch("/api/users").then(r => r.json()).then(d => setUsers(d.users || [])).catch(() => {}) fetch("/api/users").then(r => r.json()).then(d => setUsers(d.users || [])).catch((e) => { console.error("Failed to load users", e); toast.error("Failed to load users") })
}, [open]) }, [open])
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -193,6 +196,9 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
setName(""); setDescription(""); setCustomerId(""); setOwnerId(""); setStartDate(""); setTargetEndDate(""); setBudget("") setName(""); setDescription(""); setCustomerId(""); setOwnerId(""); setStartDate(""); setTargetEndDate(""); setBudget("")
onCreated() onCreated()
} }
} catch (e) {
console.error("Failed to create project", e)
toast.error("Failed to create project")
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -214,29 +220,29 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
</div> </div>
<div> <div>
<label className="text-sm font-medium">Customer *</label> <label className="text-sm font-medium">Customer *</label>
<select <Select value={customerId} onValueChange={setCustomerId}>
value={customerId} <SelectTrigger className="h-9 w-full">
onChange={(e) => setCustomerId(e.target.value)} <SelectValue placeholder="Select customer..." />
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm" </SelectTrigger>
> <SelectContent>
<option value="">Select customer...</option> {customers.map((c: any) => (
{customers.map((c: any) => ( <SelectItem key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</SelectItem>
<option key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</option> ))}
))} </SelectContent>
</select> </Select>
</div> </div>
<div> <div>
<label className="text-sm font-medium">Project Owner</label> <label className="text-sm font-medium">Project Owner</label>
<select <Select value={ownerId} onValueChange={setOwnerId}>
value={ownerId} <SelectTrigger className="h-9 w-full">
onChange={(e) => setOwnerId(e.target.value)} <SelectValue placeholder="Select owner..." />
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm" </SelectTrigger>
> <SelectContent>
<option value="">Select owner...</option> {users.map((u: any) => (
{users.map((u: any) => ( <SelectItem key={u.id} value={u.id}>{u.first_name} {u.last_name}</SelectItem>
<option key={u.id} value={u.id}>{u.first_name} {u.last_name}</option> ))}
))} </SelectContent>
</select> </Select>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
+16 -6
View File
@@ -15,6 +15,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { toast } from "sonner"
export default function ProposalDetailPage() { export default function ProposalDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -35,6 +36,7 @@ export default function ProposalDetailPage() {
setProposal(await pRes.json()) setProposal(await pRes.json())
setStatuses(await sRes.json()) setStatuses(await sRes.json())
} catch (e) { } catch (e) {
toast.error("Failed to load proposal")
console.error("Failed to load proposal", e) console.error("Failed to load proposal", e)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -44,12 +46,17 @@ export default function ProposalDetailPage() {
useEffect(() => { fetchProposal() }, [fetchProposal]) useEffect(() => { fetchProposal() }, [fetchProposal])
const updateStatus = async (statusId: string) => { const updateStatus = async (statusId: string) => {
await fetch(`/api/proposals/${id}`, { try {
method: "PATCH", await fetch(`/api/proposals/${id}`, {
headers: { "Content-Type": "application/json" }, method: "PATCH",
body: JSON.stringify({ statusId }), headers: { "Content-Type": "application/json" },
}) body: JSON.stringify({ statusId }),
fetchProposal() })
fetchProposal()
} catch (e) {
console.error("Failed to update proposal status", e)
toast.error("Failed to update proposal status")
}
} }
const handleSign = async () => { const handleSign = async () => {
@@ -62,6 +69,9 @@ export default function ProposalDetailPage() {
body: JSON.stringify({ name: signName, email: signEmail }), body: JSON.stringify({ name: signName, email: signEmail }),
}) })
if (res.ok) fetchProposal() if (res.ok) fetchProposal()
} catch (e) {
console.error("Failed to sign proposal", e)
toast.error("Failed to sign proposal")
} finally { } finally {
setSigning(false) setSigning(false)
} }
+24 -14
View File
@@ -14,6 +14,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { toast } from "sonner"
interface LineItem { interface LineItem {
id: string id: string
@@ -39,8 +40,8 @@ export default function NewProposalPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
useEffect(() => { useEffect(() => {
fetch("/api/leads?limit=100").then((r) => r.json()).then(setLeads).catch(() => {}) fetch("/api/leads?limit=100").then((r) => r.json()).then(setLeads).catch((e) => { console.error("Failed to load leads", e); toast.error("Failed to load leads") })
fetch("/api/customers?limit=100").then((r) => r.json()).then(setCustomers).catch(() => {}) fetch("/api/customers?limit=100").then((r) => r.json()).then(setCustomers).catch((e) => { console.error("Failed to load customers", e); toast.error("Failed to load customers") })
}, []) }, [])
const addItem = () => { const addItem = () => {
@@ -107,6 +108,7 @@ export default function NewProposalPage() {
router.push(`/proposals/${data.id}`) router.push(`/proposals/${data.id}`)
} catch (e) { } catch (e) {
toast.error("Failed to create proposal")
console.error("Failed to create proposal", e) console.error("Failed to create proposal", e)
} finally { } finally {
setSaving(false) setSaving(false)
@@ -135,21 +137,29 @@ export default function NewProposalPage() {
</div> </div>
<div> <div>
<label className="text-sm font-medium">Lead (optional)</label> <label className="text-sm font-medium">Lead (optional)</label>
<select value={leadId} onChange={(e) => setLeadId(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"> <Select value={leadId} onValueChange={setLeadId}>
<option value="">Select lead...</option> <SelectTrigger className="h-9 w-full">
{leads.map((l: any) => ( <SelectValue placeholder="Select lead..." />
<option key={l.id} value={l.id}>{l.contact_name || l.company_name}</option> </SelectTrigger>
))} <SelectContent>
</select> {leads.map((l: any) => (
<SelectItem key={l.id} value={l.id}>{l.contact_name || l.company_name}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div> <div>
<label className="text-sm font-medium">Customer (optional)</label> <label className="text-sm font-medium">Customer (optional)</label>
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"> <Select value={customerId} onValueChange={setCustomerId}>
<option value="">Select customer...</option> <SelectTrigger className="h-9 w-full">
{customers.map((c: any) => ( <SelectValue placeholder="Select customer..." />
<option key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</option> </SelectTrigger>
))} <SelectContent>
</select> {customers.map((c: any) => (
<SelectItem key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div> <div>
<label className="text-sm font-medium">VAT %</label> <label className="text-sm font-medium">VAT %</label>
+24 -3
View File
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { Search, Plus, ArrowRight, FileText, Calendar, User } from "lucide-react" import { Search, Plus, ArrowRight, FileText, Calendar, User, Trash2 } from "lucide-react"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -15,6 +15,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { toast } from "sonner"
export default function ProposalsPage() { export default function ProposalsPage() {
const router = useRouter() const router = useRouter()
@@ -31,16 +32,29 @@ export default function ProposalsPage() {
fetch(`/api/proposals?${params}`) fetch(`/api/proposals?${params}`)
.then((r) => r.json()) .then((r) => r.json())
.then(setProposals) .then(setProposals)
.catch(() => {}) .catch((e) => { console.error("Failed to load proposals", e); toast.error("Failed to load proposals") })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [search, statusFilter]) }, [search, statusFilter])
useEffect(() => { useEffect(() => {
fetch("/api/proposal-statuses").then((r) => r.json()).then(setStatuses).catch(() => {}) fetch("/api/proposal-statuses").then((r) => r.json()).then(setStatuses).catch((e) => { console.error("Failed to load proposal statuses", e); toast.error("Failed to load proposal statuses") })
}, []) }, [])
const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString()}` : "R0" const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString()}` : "R0"
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm("Delete this proposal?")) return
try {
const res = await fetch(`/api/proposals/${id}`, { method: "DELETE" })
if (!res.ok) throw new Error()
setProposals((prev) => prev.filter((p) => p.id !== id))
toast.success("Proposal deleted")
} catch {
toast.error("Failed to delete proposal")
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader title="Proposals & Quotes" description="Create and send professional quotes to clients"> <PageHeader title="Proposals & Quotes" description="Create and send professional quotes to clients">
@@ -104,6 +118,13 @@ export default function ProposalsPage() {
)} )}
</div> </div>
</div> </div>
<button
onClick={(e) => handleDelete(p.id, e)}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Delete proposal"
>
<Trash2 className="h-4 w-4" />
</button>
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" /> <ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div> </div>
)) ))
@@ -0,0 +1,213 @@
"use client"
import { useState, useEffect } from "react"
import { PageHeader } from "@/components/shared/page-header"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Users, Clock, Briefcase, AlertTriangle, TrendingUp } from "lucide-react"
import { toast } from "sonner"
interface Member {
id: string
name: string
firstName: string
lastName: string
email: string
avatar: string
role: string
isActive: boolean
capacityHours: number
totalHours: number
billableHours: number
utilization: number
billableUtilization: number
openTasks: number
activeProjects: number
}
interface Totals {
totalCapacity: number
totalHours: number
totalBillable: number
totalOpenTasks: number
totalActiveProjects: number
teamUtilization: number
}
function statusKey(pct: number): string {
if (pct > 100) return "overloaded"
if (pct > 90) return "near-capacity"
if (pct > 70) return "busy"
if (pct > 50) return "available"
return "underutilized"
}
function statusLabel(pct: number): { label: string; color: string } {
const key = statusKey(pct)
const labels: Record<string, string> = {
overloaded: "Overloaded",
"near-capacity": "Near Capacity",
busy: "Busy",
available: "Available",
underutilized: "Underutilized",
}
return { label: labels[key], color: `util-badge-${key}` }
}
export default function ResourcePlanningPage() {
const [members, setMembers] = useState<Member[]>([])
const [totals, setTotals] = useState<Totals | null>(null)
const [period, setPeriod] = useState("week")
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetch(`/api/resource-planning?period=${period}`)
.then(r => r.json())
.then(d => { setMembers(d.members || []); setTotals(d.totals || null) })
.catch((e) => { console.error("Failed to load resource data", e); toast.error("Failed to load resource data") })
.finally(() => setLoading(false))
}, [period])
return (
<div className="space-y-6">
<PageHeader title="Resource Planning" description="Team capacity, utilisation, and workload overview">
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="all">All Time</SelectItem>
</SelectContent>
</Select>
</PageHeader>
{/* Overview cards */}
<div className="grid gap-4 md:grid-cols-5">
<Card className="p-4 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" /> Team Size
</div>
<p className="text-2xl font-bold">{members.filter(m => m.isActive).length}</p>
</Card>
<Card className="p-4 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" /> Total Hours
</div>
<p className="text-2xl font-bold">{totals?.totalHours ?? 0}h</p>
<p className="text-xs text-muted-foreground">of {totals?.totalCapacity ?? 0}h capacity</p>
</Card>
<Card className="p-4 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<TrendingUp className="h-4 w-4" /> Utilisation
</div>
<p className={`text-2xl font-bold util-text-${statusKey(totals?.teamUtilization ?? 0)}`}>
{totals?.teamUtilization ?? 0}%
</p>
</Card>
<Card className="p-4 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Briefcase className="h-4 w-4" /> Active Projects
</div>
<p className="text-2xl font-bold">{totals?.totalActiveProjects ?? 0}</p>
</Card>
<Card className="p-4 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle className="h-4 w-4" /> Open Tasks
</div>
<p className="text-2xl font-bold">{totals?.totalOpenTasks ?? 0}</p>
</Card>
</div>
{/* Team grid */}
{loading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4].map(i => (
<Card key={i} className="p-4 space-y-3 animate-pulse">
<div className="h-10 w-10 rounded-full bg-muted" />
<div className="h-4 w-32 rounded bg-muted" />
<div className="h-3 w-24 rounded bg-muted" />
<div className="h-2 w-full rounded bg-muted" />
</Card>
))}
</div>
) : members.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">No team members found</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{members.map((m) => {
const status = statusLabel(m.utilization)
return (
<Card key={m.id} className={`p-4 space-y-3 border util-card-${statusKey(m.utilization)}`}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={m.avatar} />
<AvatarFallback>{m.firstName[0]}{m.lastName[0]}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{m.name}</p>
<p className="text-xs text-muted-foreground capitalize">{m.role?.replace("_", " ")}</p>
</div>
</div>
<Badge variant="outline" className={`text-[10px] ${status.color}`}>
{status.label}
</Badge>
</div>
{/* Utilisation bar */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Capacity: {m.capacityHours}h</span>
<span>Logged: {m.totalHours}h</span>
<span className={`util-text-${statusKey(m.utilization)}`}>{m.utilization}%</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all util-bar-${statusKey(m.utilization)}`}
style={{ width: `${Math.min(m.utilization, 100)}%` }}
/>
</div>
{m.utilization > 100 && (
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-red-500"
style={{ width: `${Math.min(m.utilization - 100, 100)}%` }}
/>
</div>
)}
</div>
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Briefcase className="h-3 w-3" />
{m.activeProjects} projects
</span>
<span className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{m.openTasks} tasks
</span>
<span className="flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{m.billableUtilization}% billable
</span>
</div>
</Card>
)
})}
</div>
)}
</div>
)
}
+3 -1
View File
@@ -7,7 +7,8 @@ import { UserPreferencesForm } from "@/components/settings/user-preferences-form
import { ThemeSettings } from "@/components/settings/theme-settings" import { ThemeSettings } from "@/components/settings/theme-settings"
import { NotificationSettings } from "@/components/settings/notification-settings" import { NotificationSettings } from "@/components/settings/notification-settings"
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager" import { CustomFieldsManager } from "@/components/settings/custom-fields-manager"
import { Building2, User, Palette, Bell, ListChecks } from "lucide-react" import { LeadScoringSettings } from "@/components/settings/lead-scoring-settings"
import { Building2, User, Palette, Bell, ListChecks, Gauge } from "lucide-react"
export default function SettingsPage() { export default function SettingsPage() {
const tabs = [ const tabs = [
@@ -16,6 +17,7 @@ export default function SettingsPage() {
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings }, { value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings }, { value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager }, { value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
{ value: "lead-scoring", label: "Lead Scoring", icon: Gauge, component: LeadScoringSettings },
] ]
return ( return (
+49 -18
View File
@@ -6,7 +6,15 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Play, Square, Plus, Trash2, Clock, Briefcase, User, TrendingUp, DollarSign } from "lucide-react" import { Play, Square, Plus, Trash2, Clock, Briefcase, User, TrendingUp, DollarSign } from "lucide-react"
import { toast } from "sonner"
interface TimeEntry { interface TimeEntry {
id: string id: string
@@ -28,7 +36,7 @@ export default function TimeTrackingPage() {
const [showManual, setShowManual] = useState(false) const [showManual, setShowManual] = useState(false)
// Manual entry form // Manual entry form
const [manualProject, setManualProject] = useState("") const [manualProject, setManualProject] = useState("none")
const [manualDesc, setManualDesc] = useState("") const [manualDesc, setManualDesc] = useState("")
const [manualDate, setManualDate] = useState(new Date().toISOString().split("T")[0]) const [manualDate, setManualDate] = useState(new Date().toISOString().split("T")[0])
const [manualHours, setManualHours] = useState("") const [manualHours, setManualHours] = useState("")
@@ -40,7 +48,7 @@ export default function TimeTrackingPage() {
// Timer // Timer
const [timerRunning, setTimerRunning] = useState(false) const [timerRunning, setTimerRunning] = useState(false)
const [timerSeconds, setTimerSeconds] = useState(0) const [timerSeconds, setTimerSeconds] = useState(0)
const [timerProject, setTimerProject] = useState("") const [timerProject, setTimerProject] = useState("none")
const [timerDesc, setTimerDesc] = useState("") const [timerDesc, setTimerDesc] = useState("")
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null) const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const startTimeRef = useRef<Date | null>(null) const startTimeRef = useRef<Date | null>(null)
@@ -52,10 +60,14 @@ export default function TimeTrackingPage() {
fetch("/api/time-entries/summary"), fetch("/api/time-entries/summary"),
fetch("/api/projects"), fetch("/api/projects"),
]) ])
if (eRes.ok) setEntries(await eRes.json()); else setEntries([]) const eData = eRes.ok ? await eRes.json() : []
if (sRes.ok) setSummary(await sRes.json()); else setSummary(null) const sData = sRes.ok ? await sRes.json() : null
if (pRes.ok) setProjects(await pRes.json()); else setProjects([]) const pData = pRes.ok ? await pRes.json() : []
setEntries(Array.isArray(eData) ? eData : [])
setSummary(sData?.totals ? sData : null)
setProjects(Array.isArray(pData) ? pData : [])
} catch (e) { } catch (e) {
toast.error("Failed to load time data")
console.error("Failed to load time data", e) console.error("Failed to load time data", e)
setEntries([]) setEntries([])
setProjects([]) setProjects([])
@@ -87,7 +99,7 @@ export default function TimeTrackingPage() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
projectId: timerProject || null, projectId: timerProject === "none" ? null : timerProject,
description: timerDesc, description: timerDesc,
date: new Date().toISOString().split("T")[0], date: new Date().toISOString().split("T")[0],
startTime: startTimeRef.current?.toISOString(), startTime: startTimeRef.current?.toISOString(),
@@ -98,10 +110,11 @@ export default function TimeTrackingPage() {
}), }),
}) })
setTimerDesc("") setTimerDesc("")
setTimerProject("") setTimerProject("none")
setTimerSeconds(0) setTimerSeconds(0)
fetchData() fetchData()
} catch (e) { } catch (e) {
toast.error("Failed to save time entry")
console.error("Failed to save time entry", e) console.error("Failed to save time entry", e)
} }
} }
@@ -118,7 +131,7 @@ export default function TimeTrackingPage() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
projectId: manualProject || null, projectId: manualProject === "none" ? null : manualProject,
description: manualDesc, description: manualDesc,
date: manualDate, date: manualDate,
durationMinutes: totalMins, durationMinutes: totalMins,
@@ -132,14 +145,22 @@ export default function TimeTrackingPage() {
setManualRate("") setManualRate("")
setShowManual(false) setShowManual(false)
fetchData() fetchData()
} catch (e) {
console.error("Failed to save time entry", e)
toast.error("Failed to save time entry")
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const deleteEntry = async (id: string) => { const deleteEntry = async (id: string) => {
await fetch(`/api/time-entries?id=${id}`, { method: "DELETE" }) try {
fetchData() await fetch(`/api/time-entries?id=${id}`, { method: "DELETE" })
fetchData()
} catch (e) {
console.error("Failed to delete time entry", e)
toast.error("Failed to delete time entry")
}
} }
const formatDuration = (mins: number) => { const formatDuration = (mins: number) => {
@@ -198,10 +219,15 @@ export default function TimeTrackingPage() {
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Input placeholder="What are you working on?" value={timerDesc} onChange={(e) => setTimerDesc(e.target.value)} className="h-9" disabled={timerRunning} /> <Input placeholder="What are you working on?" value={timerDesc} onChange={(e) => setTimerDesc(e.target.value)} className="h-9" disabled={timerRunning} />
<select value={timerProject} onChange={(e) => setTimerProject(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm" disabled={timerRunning}> <Select value={timerProject} onValueChange={setTimerProject} disabled={timerRunning}>
<option value="">No project</option> <SelectTrigger className="h-9 w-full">
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)} <SelectValue placeholder="No project" />
</select> </SelectTrigger>
<SelectContent>
<SelectItem value="none">No project</SelectItem>
{projects.map((p: any) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div> </div>
<div className="text-3xl font-mono font-bold tabular-nums px-4 py-1">{formatTimer(timerSeconds)}</div> <div className="text-3xl font-mono font-bold tabular-nums px-4 py-1">{formatTimer(timerSeconds)}</div>
{!timerRunning ? ( {!timerRunning ? (
@@ -224,10 +250,15 @@ export default function TimeTrackingPage() {
<div className="space-y-3 p-3 rounded-lg border bg-muted/20"> <div className="space-y-3 p-3 rounded-lg border bg-muted/20">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<Input placeholder="Description *" value={manualDesc} onChange={(e) => setManualDesc(e.target.value)} className="h-8 text-sm" /> <Input placeholder="Description *" value={manualDesc} onChange={(e) => setManualDesc(e.target.value)} className="h-8 text-sm" />
<select value={manualProject} onChange={(e) => setManualProject(e.target.value)} className="flex h-8 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-sm"> <Select value={manualProject} onValueChange={setManualProject}>
<option value="">No project</option> <SelectTrigger className="h-8 w-full">
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)} <SelectValue placeholder="No project" />
</select> </SelectTrigger>
<SelectContent>
<SelectItem value="none">No project</SelectItem>
{projects.map((p: any) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div> </div>
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap">
<Input type="date" value={manualDate} onChange={(e) => setManualDate(e.target.value)} className="h-8 w-40 text-sm" /> <Input type="date" value={manualDate} onChange={(e) => setManualDate(e.target.value)} className="h-8 w-40 text-sm" />
+2 -2
View File
@@ -24,8 +24,8 @@ export default function UsersPage() {
const data = await res.json() const data = await res.json()
setUsers(data.users || []) setUsers(data.users || [])
} }
} catch { } catch (err) {
console.warn("Failed to fetch users in users page") console.error("Failed to fetch users:", err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server"
import crypto from "crypto"
import { query } from "@/lib/db"
import { getUserByEmail } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email } = await request.json()
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 })
}
const user = await getUserByEmail(email)
if (!user) {
return NextResponse.json({ message: "If that email exists, a reset link has been generated." })
}
const token = crypto.randomBytes(32).toString("hex")
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await query(
`INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)`,
[user.id, token, expiresAt],
)
const resetUrl = `${request.nextUrl.origin}/reset-password/${token}`
return NextResponse.json({ message: "If that email exists, a reset link has been generated.", resetUrl })
} catch (error) {
console.error("Forgot password error:", error)
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
}
+15 -16
View File
@@ -1,4 +1,4 @@
import { NextRequest } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { import {
comparePassword, comparePassword,
getUserByEmail, getUserByEmail,
@@ -15,10 +15,7 @@ import {
import { checkRateLimit } from "@/lib/rate-limit" import { checkRateLimit } from "@/lib/rate-limit"
function jsonResponse(data: unknown, status: number, headers?: Record<string, string>) { function jsonResponse(data: unknown, status: number, headers?: Record<string, string>) {
return new Response(JSON.stringify(data), { return NextResponse.json(data, { status, headers })
status,
headers: { "Content-Type": "application/json", ...headers },
})
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -132,20 +129,22 @@ export async function POST(request: NextRequest) {
const user = mapDbUserToSessionUser(dbUser) const user = mapDbUserToSessionUser(dbUser)
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}` const isHttps = request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https"
const response = NextResponse.json({ user })
return new Response(JSON.stringify({ user }), { response.cookies.set(SESSION_COOKIE, token, {
status: 200, httpOnly: true,
headers: { sameSite: "strict",
"Content-Type": "application/json", path: "/",
"Set-Cookie": cookieStr, maxAge: 86400,
}, secure: isHttps,
}) })
return response
} catch (error) { } catch (error) {
console.error("Login error:", error) console.error("Login error:", error)
return new Response( const message = error instanceof Error ? error.message : "Authentication service unavailable."
JSON.stringify({ error: "Authentication service unavailable." }), return NextResponse.json(
{ status: 503, headers: { "Content-Type": "application/json" } } { error: message },
{ status: 503 }
) )
} }
} }
+2 -1
View File
@@ -11,8 +11,9 @@ export async function POST() {
}) })
} catch (error) { } catch (error) {
console.error("Logout error:", error) console.error("Logout error:", error)
const message = error instanceof Error ? error.message : "Logout failed."
return new Response( return new Response(
JSON.stringify({ error: "Logout failed." }), JSON.stringify({ error: message }),
{ status: 500, headers: { "Content-Type": "application/json" } } { status: 500, headers: { "Content-Type": "application/json" } }
) )
} }
+2 -1
View File
@@ -10,8 +10,9 @@ export async function GET() {
return NextResponse.json({ user }, { status: 200 }) return NextResponse.json({ user }, { status: 200 })
} catch (error) { } catch (error) {
console.error("Auth me error:", error) console.error("Auth me error:", error)
const message = error instanceof Error ? error.message : "Authentication service unavailable."
return NextResponse.json( return NextResponse.json(
{ error: "Authentication service unavailable." }, { error: message },
{ status: 503 } { status: 503 }
) )
} }
+2 -1
View File
@@ -59,6 +59,7 @@ export async function POST(request: NextRequest) {
}, { status: 200 }) }, { status: 200 })
} catch (error) { } catch (error) {
console.error("Password recovery error:", error) console.error("Password recovery error:", error)
return NextResponse.json({ error: "Recovery service unavailable." }, { status: 503 }) const message = error instanceof Error ? error.message : "Recovery service unavailable."
return NextResponse.json({ error: message }, { status: 503 })
} }
} }
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { token, password } = await request.json()
if (!token || !password) {
return NextResponse.json({ error: "Token and password are required" }, { status: 400 })
}
if (password.length < 6) {
return NextResponse.json({ error: "Password must be at least 6 characters" }, { status: 400 })
}
const result = await query(
`SELECT id, user_id, expires_at FROM password_reset_tokens
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
[token],
)
const row = result.rows[0]
if (!row) {
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 })
}
const passwordHash = await hashPassword(password)
await query(`UPDATE users SET password_hash = $1, password_change_required = FALSE WHERE id = $2`, [passwordHash, row.user_id])
await query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id])
return NextResponse.json({ message: "Password has been reset successfully." })
} catch (error) {
console.error("Reset password error:", error)
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
}
+2 -1
View File
@@ -64,6 +64,7 @@ export async function PATCH(request: NextRequest, { params: routeParams }: { par
return NextResponse.json({ success: true }, { status: 200 }) return NextResponse.json({ success: true }, { status: 200 })
} catch (error) { } catch (error) {
console.error("Error updating bug report:", error) console.error("Error updating bug report:", error)
return NextResponse.json({ error: "Failed to update bug report." }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to update bug report."
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+4 -2
View File
@@ -34,7 +34,8 @@ export async function POST(request: NextRequest) {
}, { status: 201 }) }, { status: 201 })
} catch (error) { } catch (error) {
console.error("Error creating bug report:", error) console.error("Error creating bug report:", error)
return NextResponse.json({ error: "Failed to submit bug report." }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to submit bug report."
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -113,6 +114,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ reports }, { status: 200 }) return NextResponse.json({ reports }, { status: 200 })
} catch (error) { } catch (error) {
console.error("Error fetching bug reports:", error) console.error("Error fetching bug reports:", error)
return NextResponse.json({ error: "Failed to fetch bug reports." }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to fetch bug reports."
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT l.*, s.contact_name, s.email AS subscriber_email
FROM email_campaign_logs l
LEFT JOIN email_campaign_subscribers s ON s.id = l.subscriber_id
WHERE l.campaign_id = $1 ORDER BY l.sent_at DESC LIMIT 100`,
[id]
)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Campaign logs GET error:", error)
const message = error instanceof Error ? error.message : "Failed"
return NextResponse.json({ error: message }, { status: 500 })
}
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const campaign = await query(
`SELECT c.*, u.first_name || ' ' || u.last_name AS creator_name
FROM email_campaigns c LEFT JOIN users u ON u.id = c.created_by
WHERE c.id = $1 AND c.deleted_at IS NULL`,
[id]
)
if (campaign.rows.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 })
const steps = await query(
"SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order",
[id]
)
const subscribers = await query(
`SELECT s.*, l.company_name, l.contact_name, l.email AS lead_email
FROM email_campaign_subscribers s
LEFT JOIN leads l ON l.id = s.lead_id
WHERE s.campaign_id = $1 ORDER BY s.subscribed_at DESC`,
[id]
)
return NextResponse.json({ ...campaign.rows[0], steps: steps.rows, subscribers: subscribers.rows })
} catch (error) {
console.error("Campaign GET error:", error)
const message = error instanceof Error ? error.message : "Failed"
return NextResponse.json({ error: message }, { status: 500 })
}
}
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 body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.name !== undefined) { fields.push(`name = $${idx++}`); values.push(body.name) }
if (body.description !== undefined) { fields.push(`description = $${idx++}`); values.push(body.description) }
if (body.status !== undefined) { fields.push(`status = $${idx++}`); values.push(body.status) }
if (fields.length === 0) return NextResponse.json({ error: "No fields" }, { status: 400 })
fields.push("updated_at = NOW()")
values.push(id)
await query(`UPDATE email_campaigns SET ${fields.join(", ")} WHERE id = $${idx}`, values)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Campaign PATCH error:", error)
const message = error instanceof Error ? error.message : "Failed"
return NextResponse.json({ error: message }, { 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
await query("UPDATE email_campaigns SET deleted_at = NOW() WHERE id = $1", [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Campaign DELETE error:", error)
const message = error instanceof Error ? error.message : "Failed"
return NextResponse.json({ error: message }, { status: 500 })
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const steps = await query(
"SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order",
[id]
)
if (steps.rows.length === 0) {
return NextResponse.json({ error: "No steps defined" }, { status: 400 })
}
const subscribers = await query(
"SELECT * FROM email_campaign_subscribers WHERE campaign_id = $1 AND status = 'pending' AND unsubscribed_at IS NULL",
[id]
)
let sent = 0
for (const sub of subscribers.rows) {
const stepIdx = sub.current_step
if (stepIdx >= steps.rows.length) continue
const step = steps.rows[stepIdx]
const body = replaceVariables(step.body_text || step.subject, { name: sub.contact_name || "Valued Client", email: sub.email })
await query(
`INSERT INTO email_campaign_logs (campaign_id, subscriber_id, step_id, recipient, subject, body_text, status)
VALUES ($1, $2, $3, $4, $5, $6, 'sent')`,
[id, sub.id, step.id, sub.email, step.subject, body]
)
await query(
`UPDATE email_campaign_subscribers SET current_step = current_step + 1, last_sent_at = NOW(), status = CASE WHEN $1 + 1 >= $2 THEN 'completed' ELSE 'pending' END
WHERE id = $3`,
[stepIdx, steps.rows.length, sub.id]
)
sent++
}
return NextResponse.json({ success: true, sent })
} catch (error) {
console.error("Campaign send error:", error)
const message = error instanceof Error ? error.message : "Failed to send"
return NextResponse.json({ error: message }, { status: 500 })
}
}
function replaceVariables(text: string, vars: Record<string, string>): string {
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] || `{{${key}}}`)
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query("SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order", [id])
return NextResponse.json(result.rows)
} catch (error) {
console.error("Steps GET error:", error)
const message = error instanceof Error ? error.message : "Failed"
return NextResponse.json({ error: message }, { status: 500 })
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const maxOrder = await query("SELECT COALESCE(MAX(step_order), -1) + 1 AS next FROM email_campaign_steps WHERE campaign_id = $1", [id])
const nextOrder = maxOrder.rows[0]?.next || 0
const result = await query(
`INSERT INTO email_campaign_steps (campaign_id, step_order, delay_days, subject, body_text, body_html, variables)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[id, nextOrder, body.delayDays || 0, body.subject, body.bodyText || null, body.bodyHtml || null, body.variables ? JSON.stringify(body.variables) : null]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Steps POST error:", error)
const message = error instanceof Error ? error.message : "Failed"
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
if (body.leadIds && Array.isArray(body.leadIds)) {
let added = 0
for (const leadId of body.leadIds) {
const lead = await query("SELECT id, email, contact_name FROM leads WHERE id = $1 AND deleted_at IS NULL", [leadId])
if (lead.rows.length === 0) continue
const l = lead.rows[0]
if (!l.email) continue
try {
await query(
`INSERT INTO email_campaign_subscribers (campaign_id, lead_id, email, contact_name)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
[id, l.id, l.email, l.contact_name]
)
added++
} catch { /* skip duplicates */ }
}
return NextResponse.json({ success: true, added })
}
return NextResponse.json({ error: "leadIds array required" }, { status: 400 })
} catch (error) {
console.error("Campaign subscribe POST error:", error)
const message = error instanceof Error ? error.message : "Failed"
return NextResponse.json({ error: message }, { status: 500 })
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT c.*, u.first_name || ' ' || u.last_name AS creator_name,
(SELECT COUNT(*) FROM email_campaign_subscribers WHERE campaign_id = c.id) AS subscriber_count,
(SELECT COUNT(*) FROM email_campaign_steps WHERE campaign_id = c.id) AS step_count
FROM email_campaigns c
LEFT JOIN users u ON u.id = c.created_by
WHERE c.deleted_at IS NULL
ORDER BY c.created_at DESC`
)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Campaigns GET error:", error)
const message = error instanceof Error ? error.message : "Failed to load campaigns"
return NextResponse.json({ error: message }, { 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 result = await query(
`INSERT INTO email_campaigns (name, description, status, trigger_type, trigger_config, created_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[body.name, body.description || null, body.status || "draft", body.triggerType || "manual", body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, user.id]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Campaigns POST error:", error)
const message = error instanceof Error ? error.message : "Failed to create campaign"
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -80,6 +80,7 @@ export async function DELETE(
}) })
} catch (error) { } catch (error) {
console.error("[voice-delete] Error:", error) console.error("[voice-delete] Error:", error)
return NextResponse.json({ error: "Unable to delete voice note. Please try again." }, { status: 500 }) const message = error instanceof Error ? error.message : "Unable to delete voice note. Please try again."
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -76,7 +76,8 @@ export async function GET(
return NextResponse.json({ messages }) return NextResponse.json({ messages })
} catch (error) { } catch (error) {
console.error("Messages error:", error) console.error("Messages error:", error)
return NextResponse.json({ error: "Failed to load messages" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load messages"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -160,7 +161,8 @@ export async function POST(
}) })
} catch (error) { } catch (error) {
console.error("Send message error:", error) console.error("Send message error:", error)
return NextResponse.json({ error: "Failed to send message" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to send message"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -198,7 +200,8 @@ export async function DELETE(
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Delete message error:", error) console.error("Delete message error:", error)
return NextResponse.json({ error: "Failed to delete message" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to delete message"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -229,6 +232,7 @@ export async function PATCH(
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Edit message error:", error) console.error("Edit message error:", error)
return NextResponse.json({ error: "Failed to edit message" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to edit message"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -0,0 +1,30 @@
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 body = await request.json()
const pin = body.pin === true
await query(
`UPDATE conversation_participants
SET pinned_at = $1
WHERE conversation_id = $2 AND user_id = $3`,
[pin ? new Date().toISOString() : null, id, user.id],
)
return NextResponse.json({ pinned: pin })
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to toggle pin"
console.error("Pin conversation error:", message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
+2 -1
View File
@@ -28,6 +28,7 @@ export async function POST(
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Mark read error:", error) console.error("Mark read error:", error)
return NextResponse.json({ error: "Failed to mark as read" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to mark as read"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+7 -3
View File
@@ -13,6 +13,7 @@ export async function GET() {
c.id, c.id,
c.updated_at, c.updated_at,
cp_me.last_read_at, cp_me.last_read_at,
cp_me.pinned_at,
u.id AS other_user_id, u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name, u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email, u.email AS other_user_email,
@@ -28,7 +29,7 @@ export async function GET() {
WHERE c.id IN ( WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1 SELECT conversation_id FROM conversation_participants WHERE user_id = $1
) )
ORDER BY c.updated_at DESC ORDER BY cp_me.pinned_at DESC NULLS LAST, CASE WHEN cp_me.pinned_at IS NULL THEN c.updated_at END DESC
LIMIT 50`, LIMIT 50`,
[user.id], [user.id],
) )
@@ -36,6 +37,7 @@ export async function GET() {
const conversations = result.rows.map((row: any) => ({ const conversations = result.rows.map((row: any) => ({
id: row.id, id: row.id,
updatedAt: row.updated_at, updatedAt: row.updated_at,
pinned: row.pinned_at !== null,
otherUser: { otherUser: {
id: row.other_user_id, id: row.other_user_id,
name: row.other_user_name, name: row.other_user_name,
@@ -50,7 +52,8 @@ export async function GET() {
return NextResponse.json({ conversations }) return NextResponse.json({ conversations })
} catch (error) { } catch (error) {
console.error("Conversations error:", error) console.error("Conversations error:", error)
return NextResponse.json({ error: "Failed to load conversations" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load conversations"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -115,7 +118,8 @@ export async function POST(request: NextRequest) {
}) })
} catch (error) { } catch (error) {
console.error("Create conversation error:", error) console.error("Create conversation error:", error)
return NextResponse.json({ error: "Failed to create conversation" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to create conversation"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+8 -4
View File
@@ -25,7 +25,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json(result.rows) return NextResponse.json(result.rows)
} catch (error) { } catch (error) {
console.error("Custom fields API error:", error) console.error("Custom fields API error:", error)
return NextResponse.json({ error: "Failed to load custom fields" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load custom fields"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -62,7 +63,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "A field with this key already exists for this entity" }, { status: 409 }) return NextResponse.json({ error: "A field with this key already exists for this entity" }, { status: 409 })
} }
console.error("Custom fields POST error:", error) console.error("Custom fields POST error:", error)
return NextResponse.json({ error: "Failed to create custom field" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to create custom field"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -106,7 +108,8 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Custom fields PATCH error:", error) console.error("Custom fields PATCH error:", error)
return NextResponse.json({ error: "Failed to update custom field" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to update custom field"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -120,6 +123,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Custom fields DELETE error:", error) console.error("Custom fields DELETE error:", error)
return NextResponse.json({ error: "Failed to delete custom field" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to delete custom field"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+2 -1
View File
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(result.rows) return NextResponse.json(result.rows)
} catch (error) { } catch (error) {
console.error("Customers API error:", error) console.error("Customers API error:", error)
return NextResponse.json({ error: "Failed to load customers" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load customers"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+2 -1
View File
@@ -208,6 +208,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(stats) return NextResponse.json(stats)
} catch (error) { } catch (error) {
console.error("Dashboard API error:", error) console.error("Dashboard API error:", error)
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load dashboard stats"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+2 -1
View File
@@ -23,6 +23,7 @@ export async function GET() {
})) }) })) })
} catch (error) { } catch (error) {
console.error("Emails GET error:", error) console.error("Emails GET error:", error)
return NextResponse.json({ error: "Failed to load sent emails" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load sent emails"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+2 -1
View File
@@ -28,6 +28,7 @@ export async function GET() {
return NextResponse.json({ users }) return NextResponse.json({ users })
} catch (error) { } catch (error) {
console.error("Event users error:", error) console.error("Event users error:", error)
return NextResponse.json({ error: "Failed to load users" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load users"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+2 -1
View File
@@ -49,6 +49,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
}) })
} catch (error) { } catch (error) {
console.error("ICS GET error:", error) console.error("ICS GET error:", error)
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to generate calendar file"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+4 -2
View File
@@ -159,7 +159,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}) })
} catch (error) { } catch (error) {
console.error("Events PATCH error:", error) console.error("Events PATCH error:", error)
return NextResponse.json({ error: "Failed to update event" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to update event"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -189,6 +190,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Events DELETE error:", error) console.error("Events DELETE error:", error)
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to delete event"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+4 -2
View File
@@ -96,7 +96,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ events }) return NextResponse.json({ events })
} catch (error) { } catch (error) {
console.error("Events GET error:", error) console.error("Events GET error:", error)
return NextResponse.json({ error: "Failed to load events" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load events"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -241,6 +242,7 @@ export async function POST(request: NextRequest) {
}, { status: 201 }) }, { status: 201 })
} catch (error) { } catch (error) {
console.error("Events POST error:", error) console.error("Events POST error:", error)
return NextResponse.json({ error: "Failed to create event" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to create event"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+4 -2
View File
@@ -19,7 +19,9 @@ export async function POST(request: NextRequest) {
const inviteUrl = `${origin}/join/${token}` const inviteUrl = `${origin}/join/${token}`
return NextResponse.json({ token, inviteUrl }) return NextResponse.json({ token, inviteUrl })
} catch { } catch (error) {
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 }) console.error("Invite generate error:", error)
const message = error instanceof Error ? error.message : "Failed to generate invite"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+4 -2
View File
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Create note error:", error) console.error("Create note error:", error)
return NextResponse.json({ error: "Failed to create note" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to create note"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -81,6 +82,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json(notes) return NextResponse.json(notes)
} catch (error) { } catch (error) {
console.error("Lead notes API error:", error) console.error("Lead notes API error:", error)
return NextResponse.json({ error: "Failed to load notes" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load notes"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+6 -3
View File
@@ -28,7 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const result = await query( const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, `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, l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
l.custom_fields, l.custom_fields, l.ml_score,
ls.name AS stage_name, ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l FROM leads l
@@ -61,6 +61,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`), avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null, } : null,
customFields: r.custom_fields || {}, customFields: r.custom_fields || {},
mlScore: r.ml_score || 0,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_at, updatedAt: r.updated_at,
} }
@@ -68,7 +69,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json(lead) return NextResponse.json(lead)
} catch (error) { } catch (error) {
console.error("Lead detail API error:", error) console.error("Lead detail API error:", error)
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load lead"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -159,6 +161,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ success: true, id: result.rows[0].id }) return NextResponse.json({ success: true, id: result.rows[0].id })
} catch (error) { } catch (error) {
console.error("Lead PATCH error:", error) console.error("Lead PATCH error:", error)
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to update lead"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+2 -1
View File
@@ -68,6 +68,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(columns) return NextResponse.json(columns)
} catch (error) { } catch (error) {
console.error("Kanban API error:", error) console.error("Kanban API error:", error)
return NextResponse.json({ error: "Failed to load kanban data" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load kanban data"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+8 -4
View File
@@ -43,7 +43,7 @@ export async function GET(request: NextRequest) {
const offset = parseInt(searchParams.get("offset") || "0", 10) const offset = parseInt(searchParams.get("offset") || "0", 10)
const isAdmin = user.role === "admin" || user.role === "super_admin" 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, let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, l.ml_score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id, l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
ls.name AS stage_name, ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
@@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
email: r.user_email, email: r.user_email,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`), avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null, } : null,
mlScore: r.ml_score || 0,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_at, updatedAt: r.updated_at,
} }
@@ -113,7 +114,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json(leads) return NextResponse.json(leads)
} catch (error) { } catch (error) {
console.error("Leads API error:", error) console.error("Leads API error:", error)
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to load leads"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -159,7 +161,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 }) return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) { } catch (error) {
console.error("Leads POST error:", error) console.error("Leads POST error:", error)
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to create lead"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
@@ -186,6 +189,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Leads DELETE error:", error) console.error("Leads DELETE error:", error)
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 }) const message = error instanceof Error ? error.message : "Failed to delete lead"
return NextResponse.json({ error: message }, { status: 500 })
} }
} }
+85
View File
@@ -0,0 +1,85 @@
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 * FROM lead_scoring_config WHERE is_active = true ORDER BY weight DESC")
return NextResponse.json(result.rows)
} catch (error) {
console.error("Scoring config GET error:", error)
const message = error instanceof Error ? error.message : "Failed to load scoring config"
return NextResponse.json({ error: message }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json()
const result = await query(
`INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[body.featureName, body.featureType || "keyword", body.fieldRef, body.weight || 1, body.valueMap ? JSON.stringify(body.valueMap) : null]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error: any) {
if (error?.constraint === "lead_scoring_config_feature_name_key") {
return NextResponse.json({ error: "Feature name already exists" }, { status: 409 })
}
console.error("Scoring config POST error:", error)
const message = error instanceof Error ? error.message : "Failed to create feature"
return NextResponse.json({ error: message }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json()
if (!body.id) return NextResponse.json({ error: "id required" }, { status: 400 })
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.weight !== undefined) { fields.push(`weight = $${idx++}`); values.push(body.weight) }
if (body.isActive !== undefined) { fields.push(`is_active = $${idx++}`); values.push(body.isActive) }
if (body.valueMap !== undefined) { fields.push(`value_map = $${idx++}`); values.push(JSON.stringify(body.valueMap)) }
if (fields.length === 0) return NextResponse.json({ error: "No fields" }, { status: 400 })
fields.push("updated_at = NOW()")
values.push(body.id)
await query(`UPDATE lead_scoring_config SET ${fields.join(", ")} WHERE id = $${idx}`, values)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Scoring config PATCH error:", error)
const message = error instanceof Error ? error.message : "Failed to update"
return NextResponse.json({ error: message }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 })
await query("DELETE FROM lead_scoring_config WHERE id = $1", [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Scoring config DELETE error:", error)
const message = error instanceof Error ? error.message : "Failed to delete"
return NextResponse.json({ error: message }, { status: 500 })
}
}

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