Compare commits

..

78 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
Rene 48f78cf2ca Center New Proposal page with mx-auto
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 11:51:31 +02:00
Rene c1745df329 Fix users.map crash in NewProjectDialog
Build & Auto-Repair / build (push) Has been cancelled
/api/users returns { users: [...] } but setUsers was receiving
the full wrapper object instead of the inner array.
2026-07-01 11:46:22 +02:00
caitlin 77cccba8a1 Fixed Dark Mode 2026-07-01 11:43:21 +02:00
Rene 72f872b2f9 Fix projects.map crash on API error response
Build & Auto-Repair / build (push) Has been cancelled
Added res.ok guards on projects and time-tracking pages so that
setProjects receives [] instead of an error object when the API
returns a non-200 response.
2026-07-01 11:35:34 +02:00
Rene 838ec9617a Fix ensure-ollama.mjs: sleep -> timeout for Windows compatibility
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 11:29:16 +02:00
JCBSComputer b75351112e Merge branch 'main' of https://git.coastit.co.za/CRM_ENVIROMENT/Newbie_CRM
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 11:25:15 +02:00
JCBSComputer 6016ab2855 Client portal: profile, activity pages + middleware fix 2026-07-01 11:25:08 +02:00
Rene 545065b527 Kanban board, Project Management, Proposals/Quotes, Time Tracking, Custom Fields
Build & Auto-Repair / build (push) Has been cancelled
- Kanban board with drag-and-drop per stage, colour-coded cards, score bars
- Project Management with milestones, Gantt timeline, inline add milestone/task
- Proposal/Quote builder with line items, VAT, terms, e-signature capture
- Time Tracking with stopwatch, manual entry, hours-by-project chart
- Custom Fields system: admin-defined fields per entity (leads/customers/
  projects/opportunities) with drag-and-drop reordering in settings
- Reusable CustomFieldsSection component for entity detail pages
- Customers API endpoint
- All builds pass with zero errors
2026-07-01 11:18:59 +02:00
JCBSComputer 5d971dc749 Fixed AI server Not booting
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 10:08:32 +02:00
JCBSComputer eedf528a33 Fixed AI
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 09:46:09 +02:00
JCBSComputer 8ef283a528 Merge caitlin/CRM_ENVR: AI chat ref+health, tips panel, cyberpunk theme, app-shell/bg improvements 2026-07-01 09:36:04 +02:00
JCBSComputer 4eba6fe476 Fixed Themes not showing 2026-06-30 16:48:59 +02:00
Uwais feb2549373 Add cyberpunk theme files: CSS overrides and color config 2026-06-30 16:19:39 +02:00
JCBSComputer 1e4950009d Optimization for AI Chat/ Fixed Themes not loading 2026-06-30 15:54:16 +02:00
Hannah_Bagga 7a5e833722 GIFs work now 2026-06-30 15:11:15 +02:00
JCBSComputer db487e4614 Added New Themes/Optimization 2026-06-30 14:28:39 +02:00
JCBSComputer 4b2ea3db2a Themes Name changed 2026-06-30 11:55:44 +02:00
Hannah_Bagga 1e8f979cf9 AI Assistant page looks better. 2026-06-29 22:00:05 +02:00
Hannah_Bagga 98145f0264 light and dark mode fixed and default mode is the default unless you pick Spidey mode 2026-06-29 19:28:35 +02:00
195 changed files with 16498 additions and 942 deletions
+7
View File
@@ -13,6 +13,13 @@ pnpm dev
# or
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.
+3
View File
@@ -12,6 +12,8 @@ cd frontend
# Install dependencies
npm install
npm install devenv
npm install build
# Start dev server
npm run dev
@@ -24,6 +26,7 @@ The app runs at **http://localhost:3000**
| Route | Description |
|-------|-------------|
| `/login` | Login page |
| `client-portal/login` | Client Login page |
| `/` | Dashboard |
| `/leads` | Lead management table |
| `/leads/:id` | Lead detail view |
+766
View File
@@ -0,0 +1,766 @@
.dark.theme-bw {
--background: 0 0% 5%;
--foreground: 0 0% 97%;
--card: 0 0% 10%;
--card-foreground: 0 0% 97%;
--popover: 0 0% 10%;
--popover-foreground: 0 0% 97%;
--primary: 0 0% 97%;
--primary-foreground: 0 0% 0%;
--secondary: 0 0% 68%;
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 15%;
--muted-foreground: 0 0% 72%;
--accent: 0 0% 25%;
--accent-foreground: 0 0% 97%;
--destructive: 0 0% 40%;
--destructive-foreground: 0 0% 97%;
--border: 0 0% 25%;
--input: 0 0% 25%;
--ring: 0 0% 70%;
--radius: 0.5rem;
--sidebar: 0 0% 4%;
--sidebar-foreground: 0 0% 97%;
--sidebar-primary: 0 0% 97%;
--sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 0 0% 15%;
--sidebar-accent-foreground: 0 0% 97%;
--sidebar-border: 0 0% 18%;
--sidebar-ring: 0 0% 70%;
}
.light.theme-bw {
--background: 0 0% 97%;
--foreground: 0 0% 8%;
--card: 0 0% 100%;
--card-foreground: 0 0% 8%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 8%;
--primary: 0 0% 8%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 55%;
--secondary-foreground: 0 0% 100%;
--muted: 0 0% 93%;
--muted-foreground: 0 0% 38%;
--accent: 0 0% 20%;
--accent-foreground: 0 0% 100%;
--destructive: 0 0% 60%;
--destructive-foreground: 0 0% 10%;
--border: 0 0% 78%;
--input: 0 0% 78%;
--ring: 0 0% 45%;
--radius: 0.5rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 0 0% 8%;
--sidebar-primary: 0 0% 8%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 0 0% 90%;
--sidebar-accent-foreground: 0 0% 8%;
--sidebar-border: 0 0% 80%;
--sidebar-ring: 0 0% 45%;
}
.dark.theme-bw body {
background: transparent
radial-gradient(ellipse 100% 60% at 30% 20%, hsla(0,0%,100%,0.03) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 70% 80%, hsla(0,0%,50%,0.02) 0%, transparent 50%);
position: relative;
}
.dark.theme-bw body > div {
background: transparent !important;
}
.dark.theme-bw body::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
background-image:
radial-gradient(1px 1px at 10% 20%, hsla(0,0%,100%,0.12) 0%, transparent 100%),
radial-gradient(1px 1px at 30% 70%, hsla(0,0%,100%,0.08) 0%, transparent 100%),
radial-gradient(1px 1px at 50% 10%, hsla(0,0%,100%,0.1) 0%, transparent 100%),
radial-gradient(1px 1px at 70% 50%, hsla(0,0%,100%,0.06) 0%, transparent 100%),
radial-gradient(1px 1px at 90% 30%, hsla(0,0%,100%,0.1) 0%, transparent 100%),
radial-gradient(1px 1px at 15% 85%, hsla(0,0%,100%,0.08) 0%, transparent 100%),
radial-gradient(1px 1px at 65% 90%, hsla(0,0%,100%,0.06) 0%, transparent 100%),
radial-gradient(1px 1px at 85% 15%, hsla(0,0%,100%,0.1) 0%, transparent 100%),
radial-gradient(1px 1px at 40% 40%, hsla(0,0%,100%,0.07) 0%, transparent 100%),
radial-gradient(1px 1px at 55% 60%, hsla(0,0%,100%,0.05) 0%, transparent 100%);
background-size: 200% 200%;
animation: bw-drift 40s ease-in-out infinite alternate;
}
.dark.theme-bw body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 80% 40% at 20% 30%, hsla(0,0%,100%,0.04) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 70%, hsla(0,0%,50%,0.02) 0%, transparent 50%),
radial-gradient(ellipse 50% 30% at 50% 50%, hsla(0,0%,100%,0.02) 0%, transparent 50%);
}
.light.theme-bw body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 80% 50% at 20% 30%, hsla(0,0%,0%,0.02) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 70%, hsla(0,0%,0%,0.01) 0%, transparent 50%),
repeating-linear-gradient(0deg, transparent, transparent 60px, hsla(0,0%,0%,0.015) 60px, hsla(0,0%,0%,0.015) 61px),
repeating-linear-gradient(90deg, transparent, transparent 60px, hsla(0,0%,0%,0.015) 60px, hsla(0,0%,0%,0.015) 61px);
}
@keyframes bw-drift {
0% { background-position: 0% 0%; opacity: 0.5; }
50% { background-position: 100% 100%; opacity: 1; }
100% { background-position: 0% 100%; opacity: 0.5; }
}
.dark.theme-bw .text-glow {
text-shadow: 0 0 10px hsla(0,0%,100%,0.15), 0 0 30px hsla(0,0%,100%,0.05);
}
.light.theme-bw .text-glow {
text-shadow: 0 0 8px hsla(0,0%,0%,0.08), 0 0 20px hsla(0,0%,0%,0.04);
}
.dark.theme-bw h1, .dark.theme-bw h2, .dark.theme-bw h3 {
background: linear-gradient(135deg, hsl(0, 0%, 90%), hsl(0, 0%, 60%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.light.theme-bw h1, .light.theme-bw h2, .light.theme-bw h3 {
background: linear-gradient(135deg, hsl(0, 0%, 20%), hsl(0, 0%, 50%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark.theme-bw a {
color: hsl(0, 0%, 80%);
text-decoration: none;
transition: color 0.2s;
}
.light.theme-bw a {
color: hsl(0, 0%, 30%);
text-decoration: none;
transition: color 0.2s;
}
.dark.theme-bw a:hover {
color: hsl(0, 0%, 100%);
text-shadow: 0 0 8px hsla(0,0%,100%,0.3);
}
.light.theme-bw a:hover {
color: hsl(0, 0%, 0%);
}
.dark.theme-bw blockquote {
border-left: 3px solid hsl(0, 0%, 50%);
color: hsl(0, 0%, 60%);
font-style: italic;
}
.light.theme-bw blockquote {
border-left: 3px solid hsl(0, 0%, 60%);
color: hsl(0, 0%, 50%);
font-style: italic;
}
.dark.theme-bw code {
color: hsl(0, 0%, 80%);
background: hsla(0, 0%, 100%, 0.08);
border: 1px solid hsla(0, 0%, 100%, 0.12);
}
.light.theme-bw code {
color: hsl(0, 0%, 30%);
background: hsla(0, 0%, 0%, 0.04);
border: 1px solid hsla(0, 0%, 0%, 0.1);
}
.dark.theme-bw pre {
background: rgba(0, 0, 0, 0.9);
border: 1px solid hsla(0, 0%, 100%, 0.1);
}
.light.theme-bw pre {
background: hsla(0, 0%, 0%, 0.02);
border: 1px solid hsla(0, 0%, 0%, 0.08);
}
.dark.theme-bw hr {
border-color: hsla(0, 0%, 100%, 0.12);
}
.light.theme-bw hr {
border-color: hsla(0, 0%, 0%, 0.08);
}
.dark.theme-bw table th {
color: hsl(0, 0%, 80%);
}
.light.theme-bw table th {
color: hsl(0, 0%, 30%);
}
.dark.theme-bw table td {
border-color: hsla(0, 0%, 100%, 0.1);
}
.light.theme-bw table td {
border-color: hsla(0, 0%, 0%, 0.08);
}
.dark.theme-bw ::selection {
background: hsla(0, 0%, 100%, 0.15);
color: hsl(0, 0%, 100%);
}
.light.theme-bw ::selection {
background: hsla(0, 0%, 0%, 0.1);
color: hsl(0, 0%, 0%);
}
.dark.theme-bw input::placeholder,
.dark.theme-bw textarea::placeholder {
color: hsl(0, 0%, 40%);
}
.light.theme-bw input::placeholder,
.light.theme-bw textarea::placeholder {
color: hsl(0, 0%, 65%);
}
.dark.theme-bw .page-header-title {
color: hsl(0, 0%, 95%);
}
.light.theme-bw .page-header-title {
color: hsl(0, 0%, 10%);
}
.dark.theme-bw .stat-card-value {
color: hsl(0, 0%, 95%);
}
.light.theme-bw .stat-card-value {
color: hsl(0, 0%, 10%);
}
.dark.theme-bw .badge {
color: hsl(0, 0%, 80%);
}
.light.theme-bw .badge {
color: hsl(0, 0%, 30%);
}
.dark.theme-bw .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(0, 0%, 80%), hsl(0, 0%, 95%), hsl(0, 0%, 80%)) !important;
}
.dark.theme-bw .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(0, 0%, 50%), hsl(0, 0%, 70%), hsl(0, 0%, 50%)) !important;
}
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(0, 0%, 100%, 0.08) !important;
}
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(0, 0%, 50%, 0.08) !important;
}
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
color: hsl(0, 0%, 95%) !important;
}
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(0, 0%, 70%) !important;
}
.dark.theme-bw .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(0, 0%, 100%, 0.2), transparent) !important;
}
.dark.theme-bw .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(0, 0%, 50%, 0.15), transparent) !important;
}
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(0, 0%, 80%), hsl(0, 0%, 50%)) !important;
}
.light.theme-bw .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(0, 0%, 30%), hsl(0, 0%, 50%), hsl(0, 0%, 30%)) !important;
}
.light.theme-bw .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(0, 0%, 50%), hsl(0, 0%, 65%), hsl(0, 0%, 50%)) !important;
}
.dark.theme-bw .growth-word {
-webkit-text-fill-color: hsl(0, 0%, 70%) !important;
color: hsl(0, 0%, 70%) !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;
}
+67
View File
@@ -0,0 +1,67 @@
{
"name": "Black & White",
"description": "Clean monochrome theme with pure grayscale tones and subtle texture",
"dark": {
"colors": {
"background": "0 0% 4%",
"foreground": "0 0% 95%",
"primary": "0 0% 95%",
"secondary": "0 0% 50%",
"accent": "0 0% 95%",
"muted": "0 0% 12%",
"border": "0 0% 20%",
"ring": "0 0% 70%",
"card": "0 0% 8%",
"popover": "0 0% 8%"
},
"textColors": {
"heading": "linear-gradient(135deg, hsl(0,0%,90%), hsl(0,0%,60%))",
"link": "hsl(0, 0%, 80%)",
"link-hover": "hsl(0, 0%, 100%)",
"code": "hsl(0, 0%, 80%)",
"blockquote": "hsl(0, 0%, 60%)",
"table-header": "hsl(0, 0%, 80%)",
"stat-card-value": "hsl(0, 0%, 95%)",
"badge": "hsl(0, 0%, 80%)",
"page-header-title": "hsl(0, 0%, 95%)",
"text-glow": "0 0 10px hsla(0,0%,100%,0.15), 0 0 30px hsla(0,0%,100%,0.05)"
},
"backgrounds": {
"body": "Deep black with subtle silver radial gradients and faint dots",
"glow-colors": ["hsla(0,0%,100%,0.04)", "hsla(0,0%,100%,0.02)"]
},
"effects": {
"body-background-animation": "bw-drift 40s ease-in-out infinite alternate"
}
},
"light": {
"colors": {
"background": "0 0% 97%",
"foreground": "0 0% 10%",
"primary": "0 0% 10%",
"secondary": "0 0% 40%",
"accent": "0 0% 10%",
"muted": "0 0% 90%",
"border": "0 0% 82%",
"ring": "0 0% 50%",
"card": "0 0% 100%",
"popover": "0 0% 100%"
},
"textColors": {
"heading": "linear-gradient(135deg, hsl(0,0%,20%), hsl(0,0%,50%))",
"link": "hsl(0, 0%, 30%)",
"link-hover": "hsl(0, 0%, 0%)",
"code": "hsl(0, 0%, 30%)",
"blockquote": "hsl(0, 0%, 50%)",
"table-header": "hsl(0, 0%, 30%)",
"stat-card-value": "hsl(0, 0%, 10%)",
"badge": "hsl(0, 0%, 30%)",
"page-header-title": "hsl(0, 0%, 10%)",
"text-glow": "0 0 8px hsla(0,0%,0%,0.08), 0 0 20px hsla(0,0%,0%,0.04)"
},
"backgrounds": {
"body": "White with subtle silver-gray radial gradients and faint grid texture",
"glow-colors": ["hsla(0,0%,0%,0.02)", "hsla(0,0%,0%,0.01)"]
}
}
}
+236
View File
@@ -280,3 +280,239 @@
.light.theme-cosmic .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(120, 80%, 40%), hsl(120, 80%, 50%), hsl(120, 80%, 40%)) !important;
}
.dark.theme-cosmic .growth-word,
.light.theme-cosmic .growth-word {
-webkit-text-fill-color: #1BB0CE !important;
color: #1BB0CE !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" }
}
}
-313
View File
@@ -1,313 +0,0 @@
.dark.theme-cyber {
--background: 240 30% 4%;
--foreground: 180 50% 95%;
--card: 240 25% 8%;
--card-foreground: 180 50% 95%;
--popover: 240 25% 8%;
--popover-foreground: 180 50% 95%;
--primary: 280 80% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 280 80% 55%;
--secondary-foreground: 0 0% 100%;
--muted: 240 15% 15%;
--muted-foreground: 180 10% 55%;
--accent: 280 80% 55%;
--accent-foreground: 0 0% 100%;
--destructive: 0 70% 50%;
--destructive-foreground: 210 40% 98%;
--border: 180 30% 20%;
--input: 180 30% 20%;
--ring: 280 80% 55%;
--radius: 0.5rem;
--sidebar: 240 30% 4%;
--sidebar-foreground: 180 50% 95%;
--sidebar-primary: 280 80% 55%;
--sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 280 80% 55%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 180 30% 15%;
--sidebar-ring: 280 80% 55%;
}
.light.theme-cyber {
--background: 180 20% 96%;
--foreground: 240 40% 10%;
--card: 0 0% 100%;
--card-foreground: 240 40% 10%;
--popover: 0 0% 100%;
--popover-foreground: 240 40% 10%;
--primary: 280 60% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 280 60% 50%;
--secondary-foreground: 0 0% 100%;
--muted: 180 15% 88%;
--muted-foreground: 240 10% 45%;
--accent: 280 60% 50%;
--accent-foreground: 0 0% 100%;
--destructive: 0 80% 50%;
--destructive-foreground: 0 0% 100%;
--border: 180 15% 82%;
--input: 180 15% 82%;
--ring: 280 60% 50%;
--radius: 0.5rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 240 40% 10%;
--sidebar-primary: 280 60% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 280 60% 50%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 180 15% 82%;
--sidebar-ring: 280 60% 50%;
}
.dark.theme-cyber body {
background: transparent
linear-gradient(135deg, rgba(0, 10, 20, 0.95), rgba(10, 0, 20, 0.9));
position: relative;
}
.dark.theme-cyber body > div {
background: transparent !important;
}
.dark.theme-cyber body::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
background-image:
linear-gradient(0deg, rgba(0, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
.dark.theme-cyber body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 100% 50% at 10% 20%, rgba(0, 255, 255, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 80% 50% at 90% 80%, rgba(255, 0, 255, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 50% 50%, rgba(0, 255, 255, 0.04) 0%, transparent 50%),
radial-gradient(20px 20px at 15% 15%, rgba(0, 255, 255, 0.15) 0%, transparent 100%),
radial-gradient(15px 15px at 85% 25%, rgba(255, 0, 255, 0.12) 0%, transparent 100%),
radial-gradient(12px 12px at 45% 75%, rgba(0, 255, 255, 0.1) 0%, transparent 100%),
radial-gradient(10px 10px at 70% 45%, rgba(255, 0, 255, 0.08) 0%, transparent 100%),
radial-gradient(8px 8px at 30% 60%, rgba(0, 255, 255, 0.12) 0%, transparent 100%),
radial-gradient(11px 11px at 60% 10%, rgba(255, 0, 255, 0.1) 0%, transparent 100%),
radial-gradient(9px 9px at 90% 65%, rgba(0, 255, 255, 0.08) 0%, transparent 100%),
radial-gradient(13px 13px at 5% 85%, rgba(255, 0, 255, 0.1) 0%, transparent 100%),
radial-gradient(7px 7px at 55% 55%, rgba(0, 255, 255, 0.06) 0%, transparent 100%);
animation: neon-drift 20s ease-in-out infinite alternate;
}
.light.theme-cyber body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 80% 50% at 15% 20%, rgba(0, 200, 200, 0.05) 0%, transparent 60%),
radial-gradient(ellipse 80% 50% at 85% 80%, rgba(200, 0, 200, 0.04) 0%, transparent 60%),
repeating-linear-gradient(0deg, transparent, transparent 40px, rgba(0, 200, 200, 0.02) 40px, rgba(0, 200, 200, 0.02) 41px),
repeating-linear-gradient(90deg, transparent, transparent 40px, rgba(0, 200, 200, 0.02) 40px, rgba(0, 200, 200, 0.02) 41px);
}
@keyframes neon-drift {
0% { background-position: 0% 0%; opacity: 0.6; }
50% { background-position: 100% 100%; opacity: 1; }
100% { background-position: 0% 100%; opacity: 0.6; }
}
.dark.theme-cyber .text-glow {
text-shadow: 0 0 10px hsl(180, 100%, 50%), 0 0 30px hsl(180, 100%, 50%), 0 0 60px rgba(0, 255, 255, 0.5);
}
.light.theme-cyber .text-glow {
text-shadow: 0 0 8px hsl(180, 100%, 40%), 0 0 20px hsl(180, 100%, 40%), 0 0 40px rgba(0, 200, 200, 0.3);
}
.dark.theme-cyber h1, .dark.theme-cyber h2, .dark.theme-cyber h3 {
background: linear-gradient(135deg, hsl(180, 100%, 50%), hsl(280, 80%, 55%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.light.theme-cyber h1, .light.theme-cyber h2, .light.theme-cyber h3 {
background: linear-gradient(135deg, hsl(180, 100%, 35%), hsl(280, 60%, 45%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark.theme-cyber a {
color: hsl(180, 100%, 55%);
text-decoration: none;
transition: color 0.2s;
}
.light.theme-cyber a {
color: hsl(180, 100%, 35%);
text-decoration: none;
transition: color 0.2s;
}
.dark.theme-cyber a:hover {
color: hsl(280, 80%, 60%);
text-shadow: 0 0 8px rgba(255, 0, 255, 0.4);
}
.light.theme-cyber a:hover {
color: hsl(280, 60%, 50%);
}
.dark.theme-cyber blockquote {
border-left: 3px solid hsl(280, 80%, 55%);
color: hsl(180, 20%, 70%);
font-style: italic;
}
.light.theme-cyber blockquote {
border-left: 3px solid hsl(280, 60%, 50%);
color: hsl(240, 10%, 45%);
font-style: italic;
}
.dark.theme-cyber code {
color: hsl(180, 100%, 55%);
background: rgba(0, 255, 255, 0.08);
border: 1px solid rgba(0, 255, 255, 0.15);
}
.light.theme-cyber code {
color: hsl(280, 60%, 45%);
background: rgba(0, 200, 200, 0.05);
border: 1px solid rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber pre {
background: rgba(0, 5, 15, 0.9);
border: 1px solid rgba(0, 255, 255, 0.1);
}
.light.theme-cyber pre {
background: rgba(0, 200, 200, 0.03);
border: 1px solid rgba(0, 200, 200, 0.08);
}
.dark.theme-cyber hr {
border-color: rgba(0, 255, 255, 0.15);
}
.light.theme-cyber hr {
border-color: rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber table th {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber table th {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber table td {
border-color: rgba(0, 255, 255, 0.12);
}
.light.theme-cyber table td {
border-color: rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber ::selection {
background: rgba(0, 255, 255, 0.2);
color: hsl(0, 0%, 0%);
}
.light.theme-cyber ::selection {
background: rgba(0, 200, 200, 0.12);
color: hsl(0, 0%, 0%);
}
.dark.theme-cyber input::placeholder,
.dark.theme-cyber textarea::placeholder {
color: hsl(180, 20%, 40%);
}
.light.theme-cyber input::placeholder,
.light.theme-cyber textarea::placeholder {
color: hsl(240, 10%, 65%);
}
.dark.theme-cyber .page-header-title {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber .page-header-title {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber .stat-card-value {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber .stat-card-value {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber .badge {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber .badge {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber .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(180, 100%, 50%), hsl(180, 100%, 70%), hsl(180, 100%, 50%)) !important;
}
.dark.theme-cyber .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(280, 80%, 55%), hsl(280, 80%, 70%), hsl(280, 80%, 55%)) !important;
}
.dark.theme-cyber .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(180, 100%, 50%, 0.1) !important;
}
.dark.theme-cyber .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(280, 80%, 55%, 0.1) !important;
}
.dark.theme-cyber .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
color: hsl(180, 100%, 50%) !important;
}
.dark.theme-cyber .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(280, 80%, 55%) !important;
}
.dark.theme-cyber .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(180, 100%, 50%, 0.25), transparent) !important;
}
.dark.theme-cyber .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(280, 80%, 55%, 0.25), transparent) !important;
}
.dark.theme-cyber .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(280, 80%, 55%)) !important;
}
.light.theme-cyber .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(180, 100%, 40%), hsl(180, 100%, 55%), hsl(180, 100%, 40%)) !important;
}
.light.theme-cyber .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(280, 60%, 50%), hsl(280, 60%, 65%), hsl(280, 60%, 50%)) !important;
}
+549
View File
@@ -0,0 +1,549 @@
.dark.theme-cyber2 {
--background: 240 30% 4%;
--foreground: 180 50% 95%;
--card: 240 25% 8%;
--card-foreground: 180 50% 95%;
--popover: 240 25% 8%;
--popover-foreground: 180 50% 95%;
--primary: 280 80% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 280 80% 55%;
--secondary-foreground: 0 0% 100%;
--muted: 240 15% 15%;
--muted-foreground: 180 10% 55%;
--accent: 280 80% 55%;
--accent-foreground: 0 0% 100%;
--destructive: 0 70% 50%;
--destructive-foreground: 210 40% 98%;
--border: 180 30% 20%;
--input: 180 30% 20%;
--ring: 280 80% 55%;
--radius: 0.5rem;
--sidebar: 240 30% 4%;
--sidebar-foreground: 180 50% 95%;
--sidebar-primary: 280 80% 55%;
--sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 280 80% 55%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 180 30% 15%;
--sidebar-ring: 280 80% 55%;
}
.light.theme-cyber2 {
--background: 180 20% 96%;
--foreground: 240 40% 10%;
--card: 0 0% 100%;
--card-foreground: 240 40% 10%;
--popover: 0 0% 100%;
--popover-foreground: 240 40% 10%;
--primary: 280 60% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 280 60% 50%;
--secondary-foreground: 0 0% 100%;
--muted: 180 15% 88%;
--muted-foreground: 240 10% 45%;
--accent: 280 60% 50%;
--accent-foreground: 0 0% 100%;
--destructive: 0 80% 50%;
--destructive-foreground: 0 0% 100%;
--border: 180 15% 82%;
--input: 180 15% 82%;
--ring: 280 60% 50%;
--radius: 0.5rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 240 40% 10%;
--sidebar-primary: 280 60% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 280 60% 50%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 180 15% 82%;
--sidebar-ring: 280 60% 50%;
}
.dark.theme-cyber2 body {
background: transparent
linear-gradient(135deg, rgba(0, 10, 20, 0.95), rgba(10, 0, 20, 0.9));
position: relative;
}
.dark.theme-cyber2 body > div {
background: transparent !important;
}
.dark.theme-cyber2 body::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
background-image:
linear-gradient(0deg, rgba(0, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
.dark.theme-cyber2 body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 100% 50% at 10% 20%, rgba(0, 255, 255, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 80% 50% at 90% 80%, rgba(255, 0, 255, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 50% 50%, rgba(0, 255, 255, 0.04) 0%, transparent 50%),
radial-gradient(20px 20px at 15% 15%, rgba(0, 255, 255, 0.15) 0%, transparent 100%),
radial-gradient(15px 15px at 85% 25%, rgba(255, 0, 255, 0.12) 0%, transparent 100%),
radial-gradient(12px 12px at 45% 75%, rgba(0, 255, 255, 0.1) 0%, transparent 100%),
radial-gradient(10px 10px at 70% 45%, rgba(255, 0, 255, 0.08) 0%, transparent 100%),
radial-gradient(8px 8px at 30% 60%, rgba(0, 255, 255, 0.12) 0%, transparent 100%),
radial-gradient(11px 11px at 60% 10%, rgba(255, 0, 255, 0.1) 0%, transparent 100%),
radial-gradient(9px 9px at 90% 65%, rgba(0, 255, 255, 0.08) 0%, transparent 100%),
radial-gradient(13px 13px at 5% 85%, rgba(255, 0, 255, 0.1) 0%, transparent 100%),
radial-gradient(7px 7px at 55% 55%, rgba(0, 255, 255, 0.06) 0%, transparent 100%);
animation: neon-drift 20s ease-in-out infinite alternate;
}
.light.theme-cyber2 body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 80% 50% at 15% 20%, rgba(0, 200, 200, 0.05) 0%, transparent 60%),
radial-gradient(ellipse 80% 50% at 85% 80%, rgba(200, 0, 200, 0.04) 0%, transparent 60%),
repeating-linear-gradient(0deg, transparent, transparent 40px, rgba(0, 200, 200, 0.02) 40px, rgba(0, 200, 200, 0.02) 41px),
repeating-linear-gradient(90deg, transparent, transparent 40px, rgba(0, 200, 200, 0.02) 40px, rgba(0, 200, 200, 0.02) 41px);
}
@keyframes neon-drift {
0% { background-position: 0% 0%; opacity: 0.6; }
50% { background-position: 100% 100%; opacity: 1; }
100% { background-position: 0% 100%; opacity: 0.6; }
}
.dark.theme-cyber2 .text-glow {
text-shadow: 0 0 10px hsl(180, 100%, 50%), 0 0 30px hsl(180, 100%, 50%), 0 0 60px rgba(0, 255, 255, 0.5);
}
.light.theme-cyber2 .text-glow {
text-shadow: 0 0 8px hsl(180, 100%, 40%), 0 0 20px hsl(180, 100%, 40%), 0 0 40px rgba(0, 200, 200, 0.3);
}
.dark.theme-cyber2 h1, .dark.theme-cyber2 h2, .dark.theme-cyber2 h3 {
background: linear-gradient(135deg, hsl(180, 100%, 50%), hsl(280, 80%, 55%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.light.theme-cyber2 h1, .light.theme-cyber2 h2, .light.theme-cyber2 h3 {
background: linear-gradient(135deg, hsl(180, 100%, 35%), hsl(280, 60%, 45%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark.theme-cyber2 a {
color: hsl(180, 100%, 55%);
text-decoration: none;
transition: color 0.2s;
}
.light.theme-cyber2 a {
color: hsl(180, 100%, 35%);
text-decoration: none;
transition: color 0.2s;
}
.dark.theme-cyber2 a:hover {
color: hsl(280, 80%, 60%);
text-shadow: 0 0 8px rgba(255, 0, 255, 0.4);
}
.light.theme-cyber2 a:hover {
color: hsl(280, 60%, 50%);
}
.dark.theme-cyber2 blockquote {
border-left: 3px solid hsl(280, 80%, 55%);
color: hsl(180, 20%, 70%);
font-style: italic;
}
.light.theme-cyber2 blockquote {
border-left: 3px solid hsl(280, 60%, 50%);
color: hsl(240, 10%, 45%);
font-style: italic;
}
.dark.theme-cyber2 code {
color: hsl(180, 100%, 55%);
background: rgba(0, 255, 255, 0.08);
border: 1px solid rgba(0, 255, 255, 0.15);
}
.light.theme-cyber2 code {
color: hsl(280, 60%, 45%);
background: rgba(0, 200, 200, 0.05);
border: 1px solid rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber2 pre {
background: rgba(0, 5, 15, 0.9);
border: 1px solid rgba(0, 255, 255, 0.1);
}
.light.theme-cyber2 pre {
background: rgba(0, 200, 200, 0.03);
border: 1px solid rgba(0, 200, 200, 0.08);
}
.dark.theme-cyber2 hr {
border-color: rgba(0, 255, 255, 0.15);
}
.light.theme-cyber2 hr {
border-color: rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber2 table th {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber2 table th {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber2 table td {
border-color: rgba(0, 255, 255, 0.12);
}
.light.theme-cyber2 table td {
border-color: rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber2 ::selection {
background: rgba(0, 255, 255, 0.2);
color: hsl(0, 0%, 0%);
}
.light.theme-cyber2 ::selection {
background: rgba(0, 200, 200, 0.12);
color: hsl(0, 0%, 0%);
}
.dark.theme-cyber2 input::placeholder,
.dark.theme-cyber2 textarea::placeholder {
color: hsl(180, 20%, 40%);
}
.light.theme-cyber2 input::placeholder,
.light.theme-cyber2 textarea::placeholder {
color: hsl(240, 10%, 65%);
}
.dark.theme-cyber2 .page-header-title {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber2 .page-header-title {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber2 .stat-card-value {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber2 .stat-card-value {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber2 .badge {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber2 .badge {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber2 .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(180, 100%, 50%), hsl(180, 100%, 70%), hsl(180, 100%, 50%)) !important;
}
.dark.theme-cyber2 .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(280, 80%, 55%), hsl(280, 80%, 70%), hsl(280, 80%, 55%)) !important;
}
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(180, 100%, 50%, 0.1) !important;
}
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(280, 80%, 55%, 0.1) !important;
}
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
color: hsl(180, 100%, 50%) !important;
}
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(280, 80%, 55%) !important;
}
.dark.theme-cyber2 .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(180, 100%, 50%, 0.25), transparent) !important;
}
.dark.theme-cyber2 .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(280, 80%, 55%, 0.25), transparent) !important;
}
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(280, 80%, 55%)) !important;
}
.light.theme-cyber2 .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(180, 100%, 40%), hsl(180, 100%, 55%), hsl(180, 100%, 40%)) !important;
}
.light.theme-cyber2 .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(280, 60%, 50%), hsl(280, 60%, 65%), hsl(280, 60%, 50%)) !important;
}
.dark.theme-cyber2 .growth-word,
.light.theme-cyber2 .growth-word {
-webkit-text-fill-color: #1BB0CE !important;
color: #1BB0CE !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);
}
File diff suppressed because it is too large Load Diff
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)"
}
}
+376
View File
@@ -0,0 +1,376 @@
.dark.theme-pumpkin {
--background: 30 40% 3%;
--foreground: 30 30% 92%;
--card: 25 30% 7%;
--card-foreground: 30 30% 92%;
--popover: 25 30% 7%;
--popover-foreground: 30 30% 92%;
--primary: 25 95% 50%;
--primary-foreground: 30 20% 5%;
--secondary: 35 100% 45%;
--secondary-foreground: 30 20% 5%;
--muted: 25 15% 12%;
--muted-foreground: 30 12% 60%;
--accent: 15 100% 45%;
--accent-foreground: 0 0% 100%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%;
--border: 25 20% 16%;
--input: 25 20% 16%;
--ring: 25 95% 50%;
--radius: 0.5rem;
--sidebar: 25 35% 4%;
--sidebar-foreground: 30 30% 92%;
--sidebar-primary: 25 95% 50%;
--sidebar-primary-foreground: 30 20% 5%;
--sidebar-accent: 15 100% 45%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 25 18% 14%;
--sidebar-ring: 25 95% 50%;
}
.light.theme-pumpkin {
--background: 30 30% 94%;
--foreground: 25 40% 12%;
--card: 0 0% 100%;
--card-foreground: 25 40% 12%;
--popover: 0 0% 100%;
--popover-foreground: 25 40% 12%;
--primary: 25 90% 42%;
--primary-foreground: 0 0% 100%;
--secondary: 35 90% 42%;
--secondary-foreground: 0 0% 100%;
--muted: 30 15% 86%;
--muted-foreground: 25 10% 42%;
--accent: 15 90% 42%;
--accent-foreground: 0 0% 100%;
--destructive: 0 80% 50%;
--destructive-foreground: 0 0% 100%;
--border: 25 15% 80%;
--input: 25 15% 80%;
--ring: 25 90% 42%;
--radius: 0.5rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 25 40% 12%;
--sidebar-primary: 25 90% 42%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 15 90% 42%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 25 15% 80%;
--sidebar-ring: 25 90% 42%;
}
/* Warm ambient glow */
.dark.theme-pumpkin body {
background: transparent
radial-gradient(ellipse 100% 55% at 20% 25%, rgba(200, 80, 0, 0.18) 0%, transparent 55%),
radial-gradient(ellipse 80% 45% at 80% 75%, rgba(255, 140, 0, 0.12) 0%, transparent 45%),
radial-gradient(ellipse 60% 35% at 50% 50%, rgba(180, 60, 0, 0.08) 0%, transparent 40%);
}
.dark.theme-pumpkin body > div {
background: transparent !important;
}
.light.theme-pumpkin body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 70% 45% at 15% 20%, rgba(200, 80, 0, 0.05) 0%, transparent 55%),
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 {
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 {
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);
}
/* Gradient headings — warm orange to amber */
.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-text-fill-color: transparent;
background-clip: text;
}
.light.theme-pumpkin h1:not(.text-primary):not(.text-foreground),
.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-text-fill-color: transparent;
background-clip: text;
}
/* Links */
.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:hover { color: hsl(35, 90%, 45%); }
/* Blockquotes */
.dark.theme-pumpkin blockquote {
border-left: 3px solid hsl(25, 95%, 50%);
color: hsl(30, 15%, 70%);
font-style: italic;
}
.light.theme-pumpkin blockquote {
border-left: 3px solid hsl(25, 90%, 42%);
color: hsl(25, 10%, 42%);
font-style: italic;
}
/* Code */
.dark.theme-pumpkin code {
color: hsl(25, 95%, 55%);
background: rgba(200, 80, 0, 0.1);
border: 1px solid rgba(200, 80, 0, 0.15);
}
.light.theme-pumpkin code {
color: hsl(25, 90%, 40%);
background: rgba(200, 80, 0, 0.04);
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); }
/* HR */
.dark.theme-pumpkin hr { border-color: rgba(200, 80, 0, 0.12); }
.light.theme-pumpkin hr { border-color: rgba(200, 80, 0, 0.08); }
/* Tables */
.dark.theme-pumpkin table th { color: hsl(25, 95%, 55%); }
.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); }
/* Selection */
.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%); }
/* Placeholders */
.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%); }
/* Page header / stat card / badge */
.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%); }
/* ============================================================
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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,
.light.theme-pumpkin .growth-word {
-webkit-text-fill-color: #1BB0CE !important;
color: #1BB0CE !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;
}
+64
View File
@@ -0,0 +1,64 @@
{
"name": "Rage",
"description": "Fiery red-orange rage theme with intense dark background and burning accents",
"dark": {
"colors": {
"background": "0 30% 4%",
"foreground": "20 50% 95%",
"primary": "10 100% 50%",
"secondary": "30 100% 50%",
"accent": "10 100% 50%",
"muted": "0 12% 15%",
"border": "10 30% 18%",
"ring": "10 100% 50%",
"card": "0 25% 8%",
"popover": "0 25% 8%"
},
"textColors": {
"heading": "linear-gradient(135deg, hsl(10,100%,50%), hsl(30,100%,50%))",
"link": "hsl(10, 100%, 55%)",
"link-hover": "hsl(30, 100%, 55%)",
"code": "hsl(10, 100%, 55%)",
"blockquote": "hsl(20, 15%, 70%)",
"table-header": "hsl(10, 100%, 55%)",
"stat-card-value": "hsl(10, 100%, 55%)",
"badge": "hsl(10, 100%, 55%)",
"page-header-title": "hsl(10, 100%, 55%)",
"text-glow": "0 0 10px hsl(10,100%,50%), 0 0 30px hsl(10,100%,50%), 0 0 60px rgba(255,0,0,0.5)"
},
"backgrounds": {
"body": "Deep crimson-black with red and orange radial nebula gradients",
"glow-colors": ["rgba(200, 0, 0, 0.2)", "rgba(255, 100, 0, 0.15)", "rgba(150, 0, 0, 0.1)"]
}
},
"light": {
"colors": {
"background": "20 30% 96%",
"foreground": "10 50% 10%",
"primary": "10 90% 45%",
"secondary": "30 90% 45%",
"accent": "10 90% 45%",
"muted": "20 15% 88%",
"border": "20 15% 82%",
"ring": "10 90% 45%",
"card": "0 0% 100%",
"popover": "0 0% 100%"
},
"textColors": {
"heading": "linear-gradient(135deg, hsl(10,90%,40%), hsl(30,90%,40%))",
"link": "hsl(10, 90%, 40%)",
"link-hover": "hsl(30, 90%, 45%)",
"code": "hsl(10, 90%, 40%)",
"blockquote": "hsl(10, 10%, 45%)",
"table-header": "hsl(10, 90%, 40%)",
"stat-card-value": "hsl(10, 90%, 40%)",
"badge": "hsl(10, 90%, 40%)",
"page-header-title": "hsl(10, 90%, 40%)",
"text-glow": "0 0 8px hsl(10,90%,45%), 0 0 20px hsl(10,90%,45%), 0 0 40px rgba(200,50,0,0.3)"
},
"backgrounds": {
"body": "Warm off-white with subtle red-orange gradient washes",
"glow-colors": ["rgba(200, 50, 0, 0.04)", "rgba(255, 100, 0, 0.03)"]
}
}
}
+79 -7
View File
@@ -1,5 +1,8 @@
@import url("./cosmic-theme.css");
@import url("./cyber-theme.css");
@import url("./cyberpunk-theme.css");
@import url("./cyber2-theme.css");
@import url("./pumpkin-theme.css");
@import url("./bw-theme.css");
.dark.theme-spidey {
--background: 222.2 84% 4.9%;
@@ -8,8 +11,6 @@
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 0 100% 53%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
@@ -20,16 +21,12 @@
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 0 100% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.light.theme-spidey {
@@ -179,3 +176,78 @@
.light.theme-spidey .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(217, 91%, 50%), hsl(217, 91%, 60%), hsl(217, 91%, 50%)) !important;
}
.dark.theme-spidey .growth-word,
.light.theme-spidey .growth-word {
-webkit-text-fill-color: #1BB0CE !important;
color: #1BB0CE !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);
}
+113
View File
@@ -0,0 +1,113 @@
{
"theme": {
"id": "cyberpunk",
"name": "Cyberpunk Theme",
"description": "Complete cyberpunk transformation: semi-transparent metric cards with 2px glowing hot-pink borders, fixed cyberpunk cityscape background, neon-pink avatar badges, and vibrant chart legends with hot-pink/cyan neon swatches.",
"type": "dual",
"default": false
},
"modes": {
"dark": {
"colors": {
"background": "hsl(240, 18%, 4%)",
"foreground": "hsl(210, 40%, 93%)",
"card": "hsl(240, 15%, 7%)",
"cardForeground": "hsl(210, 40%, 93%)",
"popover": "hsl(240, 15%, 7%)",
"popoverForeground": "hsl(210, 40%, 93%)",
"primary": "hsl(330, 100%, 50%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(180, 100%, 50%)",
"secondaryForeground": "hsl(0, 0%, 0%)",
"muted": "hsl(240, 12%, 12%)",
"mutedForeground": "hsl(240, 8%, 55%)",
"accent": "hsl(260, 80%, 60%)",
"accentForeground": "hsl(0, 0%, 100%)",
"destructive": "hsl(0, 84%, 55%)",
"destructiveForeground": "hsl(0, 0%, 100%)",
"border": "hsl(270, 30%, 15%)",
"input": "hsl(240, 12%, 12%)",
"ring": "hsl(330, 100%, 50%)",
"sidebar": "hsl(240, 15%, 3%)",
"sidebarForeground": "hsl(210, 40%, 93%)",
"sidebarPrimary": "hsl(330, 100%, 50%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(240, 10%, 10%)",
"sidebarAccentForeground": "hsl(210, 40%, 93%)",
"sidebarBorder": "hsl(270, 25%, 12%)",
"sidebarRing": "hsl(330, 100%, 50%)"
}
},
"light": {
"colors": {
"background": "hsl(0, 0%, 98%)",
"foreground": "hsl(240, 10%, 15%)",
"card": "hsl(0, 0%, 100%)",
"cardForeground": "hsl(240, 10%, 15%)",
"popover": "hsl(0, 0%, 100%)",
"popoverForeground": "hsl(240, 10%, 15%)",
"primary": "hsl(330, 100%, 50%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(180, 100%, 50%)",
"secondaryForeground": "hsl(0, 0%, 0%)",
"muted": "hsl(240, 5%, 94%)",
"mutedForeground": "hsl(240, 4%, 48%)",
"accent": "hsl(260, 80%, 60%)",
"accentForeground": "hsl(0, 0%, 100%)",
"destructive": "hsl(0, 84%, 55%)",
"destructiveForeground": "hsl(0, 0%, 100%)",
"border": "hsl(270, 10%, 88%)",
"input": "hsl(240, 5%, 92%)",
"ring": "hsl(330, 100%, 50%)",
"sidebar": "hsl(0, 0%, 100%)",
"sidebarForeground": "hsl(240, 10%, 15%)",
"sidebarPrimary": "hsl(330, 100%, 50%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(240, 5%, 94%)",
"sidebarAccentForeground": "hsl(240, 10%, 15%)",
"sidebarBorder": "hsl(270, 10%, 88%)",
"sidebarRing": "hsl(330, 100%, 50%)"
}
}
},
"borderRadius": "0.5rem",
"typography": {
"fontFamily": "Inter, sans-serif",
"headingFont": "Inter, sans-serif"
},
"keyColors": {
"primary": "#ff007f",
"background": "#0a0a10 (dark) / #f0f2f8 (light)",
"card": "#0f0f18 (dark) / #ffffff (light)",
"sidebar": "#07070d (dark) / #ffffff (light)",
"border": "#1c1430 (dark) / #d4d0e0 (light)",
"text": "#e8e8f0 (dark) / #1a1a2e (light)",
"mutedText": "#7a7a9a (dark) / #6b7280 (light)"
},
"neon": {
"pink": "#ff007f",
"cyan": "#00f0ff",
"violet": "#8b5cf6",
"yellow": "#ffd700",
"acidGreen": "#39ff14",
"orange": "#ff8c00"
},
"chartSegments": {
"open": { "label": "Open", "color": "#BD00FF", "description": "Neon Purple — 35%" },
"contacted": { "label": "Contacted", "color": "#00E5FF", "description": "Cyberpunk Cyan — 25%" },
"pending": { "label": "Pending", "color": "#FFD700", "description": "Electric Yellow/Orange — 17%" },
"closed": { "label": "Closed", "color": "#39FF14", "description": "Glowing Lime Green — 23%" },
"ignored": { "label": "Ignored", "color": "#FF0055", "description": "Deep Neon Crimson — 0%" }
},
"barChart": {
"newLeads": { "color": "#FF1493", "glow": "rgba(255, 0, 127, 0.5)" },
"closed": { "color": "#00F0FF", "glow": "rgba(0, 229, 255, 0.4)" }
},
"statusBadges": {
"open": { "color": "#00E5FF", "label": "Cyan Neon" },
"contacted": { "color": "#BD00FF", "label": "Purple Magenta" },
"pending": { "color": "#FFAA00", "label": "Orange Yellow" },
"closed": { "color": "#C81E3C", "label": "Crimson Red" },
"ignored": { "color": "#666666", "label": "Muted Grey" }
}
}
+55 -20
View File
@@ -202,7 +202,8 @@ Provide concise, actionable sales advice. When asked about a specific job catego
{ role: "user", content: userMessage },
],
stream: false,
options: { temperature: 0.7, num_predict: 1024 },
keep_alive: "30m",
options: { temperature: 0.7, num_predict: 2048, num_thread: 12 },
}),
})
@@ -268,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) {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
return { pathname: url.pathname, searchParams: url.searchParams }
@@ -308,27 +316,23 @@ const server = http.createServer(async (req, res) => {
}
// GET /status — combined health of all services
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
// Polls each service internally to avoid cross-origin CORS issues.
// Used by the splash page to check if AI and Frontend are ready.
// Scraper is no longer part of dev:start so it's always reported ready.
if (req.method === "GET" && pathname === "/status") {
const { default: http } = await import("http")
const results = { ai: true }
// Check scraper (port 3008)
const results = { ai: true, scraper: true }
// Check frontend (port 3006) — try localhost, fallback to 127.0.0.1
try {
await new Promise((resolve, reject) => {
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)
})
await checkPort(http, "localhost", 3006, 2000)
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)
}
@@ -366,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 facebookLoggedIn = false
let selectedBrowser = process.env.SELECTED_BROWSER || ""
let scraperAvailable = false
try {
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()
for (const [b, p] of Object.entries(profiles)) {
if (p) browsers[b] = { path: p }
@@ -395,8 +401,8 @@ const server = http.createServer(async (req, res) => {
} catch {}
const anyDetected = Object.values(browsers).some(v => v.path)
// first_run = any setup step is incomplete
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
// first_run = env or Ollama incomplete, or scraper is available but detection incomplete
const firstRun = !envExists || !ollamaRunning || (scraperAvailable && (!anyDetected || !facebookLoggedIn))
return sendJSON(res, 200, {
first_run: firstRun,
@@ -576,11 +582,40 @@ async function processRequest(req, res, body, startTime) {
}
})
// ── Warm up model on startup ────────────────────────────────────
// Pre-loads the model into Ollama's memory so the first user
// request doesn't pay the cold-start penalty.
async function warmModel() {
console.log(`Warming up model ${MODEL}...`)
try {
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(300000),
body: JSON.stringify({
model: MODEL,
prompt: "Hello",
keep_alive: "30m",
options: { num_predict: 1, num_thread: 12 },
}),
})
if (res.ok) {
console.log(`Model ${MODEL} loaded and kept alive for 30 minutes`)
} else {
console.warn(`Model warm-up returned ${res.status}`)
}
} catch (err) {
console.warn(`Model warm-up failed (will load on first request): ${err.message}`)
}
}
// ── Start ───────────────────────────────────────────────────────
server.listen(PORT, HOST, () => {
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
console.log(` Model: ${MODEL}`)
console.log(` Ollama: ${OLLAMA_URL}`)
// Kick off warm-up in background (don't block server start)
warmModel()
})
initPg()
View File
+445 -84
View File
@@ -341,6 +341,7 @@ OFFER_PATTERNS = [
r"\bfree\s+domain\b",
r"\bweb\s+hosting\b",
r"\bfree\s+website\b",
r"\bfree\s+\w+\s+website\b",
r"\bprofessional\s+website",
r"\baffordable\s+web\b",
r"\bget\s+a\s+free\b",
@@ -365,6 +366,88 @@ OFFER_PATTERNS = [
r"\bsponsored\b",
r"\bpromoted\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 = [
@@ -549,6 +632,48 @@ FB_SEARCHES = [
"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 = [
{'width': 1280, 'height': 800},
{'width': 1366, 'height': 768},
@@ -663,16 +788,31 @@ def _clean_fb_text(text: str) -> str:
cleaned_lines.append(stripped)
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 ──────────────────────────────────────────────────
# Two strategies for extracting posts from page content:
# 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
# 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]:
posts = []
seen_texts = set()
now = datetime.utcnow()
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', '')
if len(raw_text) < 40:
continue
@@ -686,13 +826,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
if dekey in seen_texts:
continue
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
post_url = el.get('url') or base_url
# Prefer JS-extracted date, fall back to text parsing
js_date = (el.get('date') or '').strip()
if js_date:
# Try ISO datetime from <time datetime>
try:
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
date_str = dt.strftime('%Y-%m-%d')
@@ -720,6 +870,25 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
posts = []
seen_texts = set()
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:
words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) < 2:
@@ -735,7 +904,7 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": '',
"author": _find_author(cur[-3:]),
"url": url,
"source": "facebook",
"date": _parse_fb_date(cur + [l]),
@@ -759,7 +928,61 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
cur.append(l)
if len(cur) > 10:
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 ──────────────────────────────────
# 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('''() => {
const results = [];
const seenTexts = new Set();
const selectors = [
'div[role="article"]',
'div[role="feed"] > div',
'div.x1yztbdb',
'div[data-pagelet]',
];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
for (const el of els) {
const text = (el.innerText || '').trim();
if (text.length < 40) continue;
const key = text.substring(0, 80);
if (seenTexts.has(key)) continue;
seenTexts.add(key);
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"];
const now = Date.now();
const DAY_MS = 86400000;
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
for (const a of anchors) {
const h = a.getAttribute('href') || '';
if (!h) continue;
const cell = a.closest('div[style]') || a.parentElement;
if (!cell) continue;
const txt = (cell.innerText || '').trim();
if (txt.length < 40) continue;
const lower = txt.toLowerCase();
let matched = false;
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 ---
let author = '';
const nameEl = el.querySelector('h4, strong, a[role="link"]');
if (nameEl) {
author = (nameEl.innerText || '').trim();
}
if (!author) {
const links = el.querySelectorAll('a');
for (const a of links) {
const t = (a.innerText || '').trim();
if (t && t.length > 1 && t.length < 60 && /^[A-Za-zÀ-ÿ]/.test(t) && !t.includes('facebook') && !t.includes('/')) {
author = t;
break;
}
}
}
// --- 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 });
const isGroup = h.includes('/groups/');
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.length < 2) author = '';
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
let date = '';
let ts = 0;
const timeEl = cell.querySelector('time');
if (timeEl) {
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
const dtVal = timeEl.getAttribute('datetime');
if (dtVal) { ts = new Date(dtVal).getTime(); }
}
if (results.length > 0) break;
results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
}
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)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
try:
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 page.wait_for_timeout(random.randint(5000, 10000))
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.wait_for_timeout(random.randint(1000, 3000))
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)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
if not posts:
raw = await page.evaluate('document.body.innerText')
posts = _extract_posts_from_text(raw, url)
raw = await page.evaluate('document.body.innerText')
text_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:
logger.warning("Facebook search '%s' failed: %s", query, e)
return page, []
@@ -971,7 +1265,7 @@ def cleanup_chrome():
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
# - 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."""
effective_path = profile_path or ""
browser_type = ""
@@ -1013,14 +1307,14 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
# Firefox path
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"):
return result
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
# 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"):
return result
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
# 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)."""
if not 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}
all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, query in enumerate(searches):
page, posts = await search_facebook(page, context, query)
if query:
query_pool = _search_list_for_query(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)
if not posts:
continue
@@ -1142,6 +1440,12 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
pass
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ─────────────────────────────────────────────────
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
# 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.
# 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)."""
if not 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}
all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, query in enumerate(searches):
page, posts = await search_facebook(page, context, query)
if query:
query_pool = _search_list_for_query(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)
if not posts:
continue
@@ -1387,6 +1695,8 @@ NOT LEAD:
- Recruiting employees, hiring staff, looking for business partners
- Selling products, promoting services, affiliate offers
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
- 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):
{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",
]
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:
t = r['title'].lower()
has_web = any(kw in t for kw in web_terms)
has_request = any(kw in t for kw in request_terms)
if not has_web or not has_request:
continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting',
'i do web design', 'i develop websites']):
if any(kw in t for kw in offer_reject):
continue
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:
seen_titles.add(key)
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)
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:
continue
t = r['title'].lower()
if any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms):
seen_titles.add(key)
merged.append(r)
if len(merged) >= 5:
break
if not (any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms)):
continue
if any(kw in t for kw in offer_reject):
continue
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))
return merged[:10]
@@ -1601,8 +1962,8 @@ async def agent_run(task: str = Body(..., embed=True)):
return {"success": False, "error": str(e)}
@app.post("/scrape/facebook")
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
result = await scrape_facebook(profile_path, force)
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, query)
return result
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":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
{"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"}
{"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":"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"}
+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;
+104
View File
@@ -0,0 +1,104 @@
-- ============================================================================
-- Project Management — Post-sale project tracking with milestones
-- ============================================================================
CREATE TABLE IF NOT EXISTS project_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL UNIQUE,
color VARCHAR(7),
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
owner_id UUID NOT NULL REFERENCES users(id),
name VARCHAR(255) NOT NULL,
description TEXT,
status_id UUID NOT NULL REFERENCES project_statuses(id),
start_date DATE,
target_end_date DATE,
actual_end_date DATE,
budget DECIMAL(15,2),
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_projects_customer ON projects(customer_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_owner ON projects(owner_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_status ON projects(status_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_created ON projects(created_at) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS project_milestones (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
sort_order INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
start_date DATE,
due_date DATE,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_milestone_status CHECK (status IN ('pending','in_progress','completed','cancelled'))
);
CREATE INDEX idx_milestones_project ON project_milestones(project_id);
CREATE INDEX idx_milestones_status ON project_milestones(status);
CREATE INDEX idx_milestones_due ON project_milestones(due_date);
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS milestone_id UUID REFERENCES project_milestones(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id) WHERE project_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_milestone ON tasks(milestone_id) WHERE milestone_id IS NOT NULL AND deleted_at IS NULL;
-- Seed project statuses
INSERT INTO project_statuses (name, color, sort_order) VALUES
('Planning', '#6366f1', 1),
('In Progress', '#22c55e', 2),
('Review', '#eab308', 3),
('Completed', '#22d3ee', 4),
('On Hold', '#f97316', 5),
('Cancelled', '#ef4444', 6)
ON CONFLICT (name) DO NOTHING;
-- Seed a sample project from the existing TechCorp opportunity
DO $$
DECLARE
v_customer_id UUID;
v_opportunity_id UUID;
v_owner_id UUID;
v_planning_id UUID;
v_progress_id UUID;
v_project_id UUID;
BEGIN
SELECT id INTO v_customer_id FROM customers LIMIT 1;
SELECT id INTO v_opportunity_id FROM opportunities LIMIT 1;
SELECT u.id INTO v_owner_id FROM users u JOIN user_roles ur ON ur.user_id = u.id JOIN roles r ON r.id = ur.role_id WHERE r.name = 'SALES_USER' LIMIT 1;
IF v_owner_id IS NULL THEN SELECT id INTO v_owner_id FROM users LIMIT 1; END IF;
SELECT id INTO v_planning_id FROM project_statuses WHERE name = 'Planning';
SELECT id INTO v_progress_id FROM project_statuses WHERE name = 'In Progress';
IF v_customer_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE name = 'TechCorp Website Redesign') THEN
INSERT INTO projects (customer_id, opportunity_id, owner_id, name, description, status_id, start_date, target_end_date, budget, created_by)
VALUES (v_customer_id, v_opportunity_id, v_owner_id, 'TechCorp Website Redesign',
'Full website redesign including homepage, services pages, and contact forms',
v_planning_id, CURRENT_DATE, CURRENT_DATE + INTERVAL '60 days', 25000.00, v_owner_id)
RETURNING id INTO v_project_id;
INSERT INTO project_milestones (project_id, name, description, sort_order, status, start_date, due_date) VALUES
(v_project_id, 'Requirements & Wireframes', 'Gather requirements, create wireframes for approval', 1, 'in_progress', CURRENT_DATE, CURRENT_DATE + INTERVAL '10 days'),
(v_project_id, 'Design Mockups', 'Create visual design mockups for all pages', 2, 'pending', CURRENT_DATE + INTERVAL '10 days', CURRENT_DATE + INTERVAL '20 days'),
(v_project_id, 'Frontend Development', 'Build responsive frontend components', 3, 'pending', CURRENT_DATE + INTERVAL '20 days', CURRENT_DATE + INTERVAL '35 days'),
(v_project_id, 'Backend Integration', 'Integrate CMS, forms, and email functionality', 4, 'pending', CURRENT_DATE + INTERVAL '35 days', CURRENT_DATE + INTERVAL '45 days'),
(v_project_id, 'Testing & QA', 'Cross-browser testing, performance optimization', 5, 'pending', CURRENT_DATE + INTERVAL '45 days', CURRENT_DATE + INTERVAL '55 days'),
(v_project_id, 'Launch', 'Deploy to production, final sign-off', 6, 'pending', CURRENT_DATE + INTERVAL '55 days', CURRENT_DATE + INTERVAL '60 days');
END IF;
END $$;
@@ -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);
+119
View File
@@ -0,0 +1,119 @@
-- ============================================================================
-- Proposal/Quote Builder + Time Tracking
-- ============================================================================
-- ── Proposals / Quotes ──────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS proposal_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL UNIQUE,
color VARCHAR(7),
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO proposal_statuses (name, color, sort_order) VALUES
('Draft', '#6366f1', 1),
('Sent', '#eab308', 2),
('Viewed', '#22d3ee', 3),
('Accepted', '#22c55e', 4),
('Declined', '#ef4444', 5),
('Expired', '#a1a1aa', 6)
ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS proposals (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lead_id UUID REFERENCES leads(id),
customer_id UUID REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
title VARCHAR(255) NOT NULL,
proposal_number VARCHAR(50) NOT NULL,
status_id UUID NOT NULL REFERENCES proposal_statuses(id),
content JSONB,
subtotal DECIMAL(15,2) NOT NULL DEFAULT 0,
tax_percent DECIMAL(5,2) DEFAULT 15,
tax_amount DECIMAL(15,2) DEFAULT 0,
total_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
valid_until DATE,
notes TEXT,
terms TEXT DEFAULT 'Payment due within 30 days. 50% deposit required to commence work.',
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_proposal_number ON proposals(proposal_number) WHERE deleted_at IS NULL;
CREATE INDEX idx_proposals_customer ON proposals(customer_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_proposals_lead ON proposals(lead_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_proposals_status ON proposals(status_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_proposals_created ON proposals(created_at) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS proposal_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
description TEXT NOT NULL,
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price DECIMAL(15,2) NOT NULL CHECK (unit_price >= 0),
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100),
total_price DECIMAL(15,2) NOT NULL CHECK (total_price >= 0),
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_proposal_items_proposal ON proposal_items(proposal_id);
CREATE TABLE IF NOT EXISTS proposal_signatures (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
signatory_name VARCHAR(255) NOT NULL,
signatory_email VARCHAR(255) NOT NULL,
signature_data TEXT,
ip_address INET,
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_valid BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_proposal_signatures_proposal ON proposal_signatures(proposal_id);
-- ── Time Tracking ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS time_entries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id),
project_id UUID REFERENCES projects(id),
milestone_id UUID REFERENCES project_milestones(id),
task_id UUID REFERENCES tasks(id),
description TEXT,
date DATE NOT NULL DEFAULT CURRENT_DATE,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
duration_minutes INT NOT NULL CHECK (duration_minutes > 0),
billable BOOLEAN NOT NULL DEFAULT TRUE,
hourly_rate DECIMAL(15,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_time_entries_user ON time_entries(user_id);
CREATE INDEX idx_time_entries_project ON time_entries(project_id);
CREATE INDEX idx_time_entries_date ON time_entries(date);
CREATE INDEX idx_time_entries_billable ON time_entries(billable) WHERE billable = TRUE;
-- Seed sample time entry
DO $$
DECLARE
v_user_id UUID;
v_project_id UUID;
BEGIN
SELECT id INTO v_user_id FROM users LIMIT 1;
SELECT id INTO v_project_id FROM projects LIMIT 1;
IF v_user_id IS NOT NULL AND v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM time_entries) THEN
INSERT INTO time_entries (user_id, project_id, description, date, duration_minutes, billable, hourly_rate)
VALUES
(v_user_id, v_project_id, 'Initial planning and requirements gathering', CURRENT_DATE - 2, 240, TRUE, 1500),
(v_user_id, v_project_id, 'Wireframe design', CURRENT_DATE - 1, 180, TRUE, 1500),
(v_user_id, v_project_id, 'Team standup and sprint planning', CURRENT_DATE, 60, FALSE, NULL);
END IF;
END $$;
+43
View File
@@ -0,0 +1,43 @@
-- ============================================================================
-- Custom Fields — Adaptable fields per entity type (leads, customers, etc.)
-- ============================================================================
CREATE TABLE IF NOT EXISTS custom_field_definitions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
entity_type VARCHAR(50) NOT NULL,
field_key VARCHAR(100) NOT NULL,
field_label VARCHAR(255) NOT NULL,
field_type VARCHAR(50) NOT NULL DEFAULT 'text',
options JSONB,
placeholder TEXT,
default_value TEXT,
section VARCHAR(100),
is_required BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_custom_field_entity_key UNIQUE (entity_type, field_key),
CONSTRAINT chk_custom_field_type CHECK (field_type IN ('text','number','date','select','multi_select','boolean','url','email','phone'))
);
CREATE INDEX idx_custom_fields_entity ON custom_field_definitions(entity_type, is_active, sort_order);
ALTER TABLE leads ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}';
ALTER TABLE customers ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}';
ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}';
-- Seed sample fields
INSERT INTO custom_field_definitions (entity_type, field_key, field_label, field_type, options, section, sort_order) VALUES
('lead', 'service_type', 'Service Type', 'select', '["Web Development","Mobile App","SEO","Consulting","Retainer","Maintenance"]', 'Details', 1),
('lead', 'budget_confirmed', 'Budget Confirmed', 'boolean', NULL, 'Details', 2),
('lead', 'referral_source', 'Referral Source', 'text', NULL, 'Details', 3),
('customer', 'industry_vertical', 'Industry Vertical', 'select', '["Fintech","Healthcare","E-commerce","Education","Real Estate","Hospitality","Other"]', 'Business', 1),
('customer', 'vat_number', 'VAT Number', 'text', NULL, 'Business', 2),
('project', 'project_type', 'Project Type', 'select', '["New Build","Redesign","Maintenance","Consulting","Retainer"]', 'Details', 1),
('project', 'hosting_provider', 'Hosting Provider', 'text', NULL, 'Technical', 2),
('project', 'tech_stack', 'Tech Stack', 'multi_select', '["React","Next.js","Node.js","Python","PHP","WordPress","Shopify","Laravel","Vue","Angular"]', 'Technical', 3),
('opportunity', 'competitor', 'Competitor', 'text', NULL, 'Details', 1),
('opportunity', 'decision_timeline', 'Decision Timeline', 'select', '["This Week","This Month","This Quarter","Next Quarter","Not Sure"]', 'Details', 2)
ON CONFLICT (entity_type, field_key) DO NOTHING;
@@ -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;
+22
View File
@@ -75,5 +75,27 @@ BEGIN;
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
\i 019_allow_null_start_time.sql
\echo '=== Running 020_projects.sql (Project Management) ==='
\i 020_projects.sql
\echo '=== Running 021_proposals_time.sql (Proposals + Time Tracking) ==='
\i 021_proposals_time.sql
\echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ==='
\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 ==='
COMMIT;
+4 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: false,
ignoreDuringBuilds: true,
},
images: {
remotePatterns: [
@@ -12,6 +12,9 @@ const nextConfig: NextConfig = {
},
],
},
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion", "date-fns", "@radix-ui/react-icons", "recharts"],
},
}
export default nextConfig
+390 -7
View File
@@ -8,6 +8,9 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.9.1",
@@ -30,6 +33,7 @@
"@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3",
"build": "^0.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"devenv": "^1.0.1",
@@ -100,6 +104,14 @@
"node": ">=6.9.0"
}
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
"integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -210,6 +222,66 @@
"node": ">=18"
}
},
"node_modules/@dabh/diagnostics": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
"integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
"dependencies": {
"@so-ric/colorspace": "^1.1.6",
"enabled": "2.0.x",
"kuler": "^2.0.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -1992,6 +2064,57 @@
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
"dev": true
},
"node_modules/@so-ric/colorspace": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
"integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
"dependencies": {
"color": "^5.0.2",
"text-hex": "1.0.x"
}
},
"node_modules/@so-ric/colorspace/node_modules/color": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
"dependencies": {
"color-convert": "^3.1.3",
"color-string": "^2.1.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@so-ric/colorspace/node_modules/color-convert": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=14.6"
}
},
"node_modules/@so-ric/colorspace/node_modules/color-name": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
"engines": {
"node": ">=12.20"
}
},
"node_modules/@so-ric/colorspace/node_modules/color-string": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -2284,6 +2407,11 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/triple-beam": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -3175,8 +3303,7 @@
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"dev": true
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
},
"node_modules/async-function": {
"version": "1.0.0",
@@ -3411,6 +3538,26 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/build": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/build/-/build-0.1.4.tgz",
"integrity": "sha512-KwbDJ/zrsU8KZRRMfoURG14cKIAStUlS8D5jBDvtrZbwO5FEkYqc3oB8HIhRiyD64A48w1lc+sOmQ+mmBw5U/Q==",
"dependencies": {
"cssmin": "0.3.x",
"jsmin": "1.x",
"jxLoader": "*",
"moo-server": "*",
"promised-io": "*",
"timespan": "2.x",
"uglify-js": "1.x",
"walker": "1.x",
"winston": "*",
"wrench": "1.3.x"
},
"engines": {
"node": ">v0.4.12"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -3846,6 +3993,14 @@
"node": ">=4"
}
},
"node_modules/cssmin": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/cssmin/-/cssmin-0.3.2.tgz",
"integrity": "sha512-bynxGIAJ8ybrnFobjsQotIjA8HFDDgPwbeUWNXXXfR+B4f9kkxdcUyagJoQCSUOfMV+ZZ6bMn8bvbozlCzUGwQ==",
"bin": {
"cssmin": "bin/cssmin"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -4255,6 +4410,11 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
"node_modules/enabled": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -5041,6 +5201,11 @@
"reusify": "^1.0.4"
}
},
"node_modules/fecha": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5133,6 +5298,11 @@
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true
},
"node_modules/fn.name": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -5635,8 +5805,7 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/internal-slot": {
"version": "1.1.0",
@@ -5999,6 +6168,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-string": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
@@ -6204,6 +6384,17 @@
}
}
},
"node_modules/jsmin": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/jsmin/-/jsmin-1.0.1.tgz",
"integrity": "sha512-OPuL5X/bFKgVdMvEIX3hnpx3jbVpFCrEM8pKPXjFkZUqg521r41ijdyTz7vACOhW6o1neVlcLyd+wkbK5fNHRg==",
"bin": {
"jsmin": "bin/jsmin"
},
"engines": {
"node": ">=0.1.93"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -6249,6 +6440,28 @@
"node": ">=4.0"
}
},
"node_modules/jxLoader": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jxLoader/-/jxLoader-0.1.1.tgz",
"integrity": "sha512-ClEvAj3K68y8uKhub3RgTmcRPo5DfIWvtxqrKQdDPyZ1UVHIIKvVvjrAsJFSVL5wjv0rt5iH9SMCZ0XRKNzeUA==",
"dependencies": {
"js-yaml": "0.3.x",
"moo-server": "1.3.x",
"promised-io": "*",
"walker": "1.x"
},
"engines": {
"node": ">v0.4.10"
}
},
"node_modules/jxLoader/node_modules/js-yaml": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-0.3.7.tgz",
"integrity": "sha512-/7PsVDNP2tVe2Z1cF9kTEkjamIwz4aooDpRKmN1+g/9eePCgcxsv4QDvEbxO0EH+gdDD7MLyDoR6BASo3hH51g==",
"engines": {
"node": "> 0.4.11"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6258,6 +6471,11 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
},
"node_modules/language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
@@ -6333,6 +6551,22 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/logform": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
"integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
"dependencies": {
"@colors/colors": "1.6.0",
"@types/triple-beam": "^1.3.2",
"fecha": "^4.2.0",
"ms": "^2.1.1",
"safe-stable-stringify": "^2.3.1",
"triple-beam": "^1.3.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -6405,6 +6639,14 @@
"node": ">=6.0.0"
}
},
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
"integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
"dependencies": {
"tmpl": "1.0.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6524,6 +6766,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/moo-server": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/moo-server/-/moo-server-1.3.0.tgz",
"integrity": "sha512-9A8/eor2DXwpv1+a4pZAAydqLFVrWoKoO1fzdzqLUhYVXAO1Kgd1FR2gFZi7YdHzF0s4W8cDNwCfKJQrvLqxDw==",
"engines": {
"node": ">v0.4.10"
}
},
"node_modules/motion-dom": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
@@ -6892,6 +7142,14 @@
"node": ">= 0.8"
}
},
"node_modules/one-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
"integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
"dependencies": {
"fn.name": "1.x.x"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -7363,6 +7621,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/promised-io": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/promised-io/-/promised-io-0.3.6.tgz",
"integrity": "sha512-bNwZusuNIW4m0SPR8jooSyndD35ggirHlxVl/UhIaZD/F0OBv9ebfc6tNmbpZts3QXHggkjIBH8lvtnzhtcz0A=="
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7641,6 +7904,19 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -7850,7 +8126,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -7899,6 +8174,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -8306,6 +8589,14 @@
"integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
"dev": true
},
"node_modules/stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
"engines": {
"node": "*"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -8336,6 +8627,14 @@
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -8675,6 +8974,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -8696,6 +9000,14 @@
"node": ">=0.8"
}
},
"node_modules/timespan": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/timespan/-/timespan-2.3.0.tgz",
"integrity": "sha512-0Jq9+58T2wbOyLth0EU+AUb6JMGCLaTWIykJFa7hyAybjVH9gpVMTfUAwo5fWAvtFt2Tjh/Elg8JtgNpnMnM8g==",
"engines": {
"node": ">= 0.2.0"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -8746,6 +9058,11 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -8804,6 +9121,14 @@
"tree-kill": "cli.js"
}
},
"node_modules/triple-beam": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
"integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -8951,6 +9276,14 @@
"node": ">=14.17"
}
},
"node_modules/uglify-js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-1.3.5.tgz",
"integrity": "sha512-YPX1DjKtom8l9XslmPFQnqWzTBkvI4N0pbkzLuPZZ4QTyig0uQqvZz9NgUdfEV+qccJzi7fVcGWdESvRIjWptQ==",
"bin": {
"uglifyjs": "bin/uglifyjs"
}
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -9121,8 +9454,7 @@
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/utils-merge": {
"version": "1.0.1",
@@ -9205,6 +9537,14 @@
"node": ">=18"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
"integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
"dependencies": {
"makeerror": "1.0.12"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -9367,6 +9707,40 @@
"integrity": "sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==",
"dev": true
},
"node_modules/winston": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
"async": "^3.2.3",
"is-stream": "^2.0.0",
"logform": "^2.7.0",
"one-time": "^1.0.0",
"readable-stream": "^3.4.0",
"safe-stable-stringify": "^2.3.1",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.9.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
"integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
"dependencies": {
"logform": "^2.7.0",
"readable-stream": "^3.6.2",
"triple-beam": "^1.3.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -9407,6 +9781,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrench": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/wrench/-/wrench-1.3.9.tgz",
"integrity": "sha512-srTJQmLTP5YtW+F5zDuqjMEZqLLr/eJOZfDI5ibfPfRMeDh3oBUefAscuH0q5wBKE339ptH/S/0D18ZkfOfmKQ==",
"deprecated": "wrench.js is deprecated! You should check out fs-extra (https://github.com/jprichardson/node-fs-extra) for any operations you were using wrench for. Thanks for all the usage over the years.",
"engines": {
"node": ">=0.1.97"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+7 -3
View File
@@ -7,9 +7,9 @@
"dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs",
"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:precheck": "node scripts/precheck.mjs",
"dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs",
"dev:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
@@ -20,10 +20,13 @@
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
"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"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.9.1",
@@ -46,6 +49,7 @@
"@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3",
"build": "^0.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"devenv": "^1.0.1",
+14 -6
View File
@@ -3,6 +3,13 @@ const { chromium } = require('playwright');
const app = express();
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;
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.waitForTimeout(2000);
await page.waitForTimeout(3000);
const items = await page.evaluate(() => {
const cards = document.querySelectorAll('.job_seen_beacon');
return Array.from(cards).slice(0, 8).map(card => {
const titleEl = card.querySelector('.jcs-JobTitle');
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
const snippetEl = card.querySelector('.job-snippet');
const cards = document.querySelectorAll('.job_seen_beacon, [class*="card"], [class*="result"], li');
return Array.from(cards).slice(0, 10).map(card => {
const titleEl = card.querySelector('.jcs-JobTitle, a[class*="title"], a[class*="job"], h2 a');
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"], [class*="company"], [class*="comp"]');
const snippetEl = card.querySelector('.job-snippet, [class*="snippet"], [class*="summary"]');
return {
title: titleEl?.textContent?.trim() || '',
url: titleEl?.href || '',
company: companyEl?.textContent?.trim() || '',
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
};
});
}).filter(j => j.title);
});
for (const item of items) {
+7 -9
View File
@@ -558,12 +558,11 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dependencies": {
"playwright-core": "1.61.0"
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
@@ -576,10 +575,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"bin": {
"playwright-core": "cli.js"
},
+17 -16
View File
@@ -23831,6 +23831,7 @@ var init_harTracer = __esm({
this._pageEntries = [];
this._eventListeners = [];
this._started = false;
this._omitWebSocketFrames = true;
this._context = context2;
this._page = page;
this._delegate = delegate;
@@ -23847,6 +23848,9 @@ var init_harTracer = __esm({
this._pageEntrySymbol = Symbol("pageHarEntry");
this._baseURL = context2 instanceof APIRequestContext ? context2._defaultOptions().baseURL : context2._options.baseURL;
}
setOmitWebSocketFrames(omitWebSocketFrames) {
this._omitWebSocketFrames = omitWebSocketFrames;
}
start(options) {
if (this._started)
return;
@@ -24157,7 +24161,7 @@ var init_harTracer = __esm({
harEntry._resourceType = "websocket";
let sha1 = void 0;
const recordMessage = (type3, opcode, data, wallTimeMs) => {
if (this._options.omitWebSocketFrames)
if (this._omitWebSocketFrames)
return;
const message = { type: type3, time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data };
if (this._options.content === "embed") {
@@ -24441,9 +24445,9 @@ var init_harRecorder = __esm({
includeTraceInfo: false,
recordRequestOverrides: true,
waitForContentOnStop: true,
urlFilter: urlFilterRe ?? options.urlGlob,
omitWebSocketFrames: !!process.env.PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES
urlFilter: urlFilterRe ?? options.urlGlob
});
this._tracer.setOmitWebSocketFrames(!!process.env.PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES);
this._tracer.start({ omitScripts: false });
}
onEntryStarted(entry) {
@@ -24652,8 +24656,7 @@ var init_tracing = __esm({
content: "attach",
includeTraceInfo: true,
recordRequestOverrides: false,
waitForContentOnStop: false,
omitWebSocketFrames: !!process.env.PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES
waitForContentOnStop: false
});
const testIdAttributeName2 = "selectors" in context2 ? context2.selectors().testIdAttributeName() : void 0;
this._contextCreatedEvent = {
@@ -24747,6 +24750,7 @@ var init_tracing = __esm({
);
if (this._state.options.screenshots)
this._startScreencast();
this._harTracer.setOmitWebSocketFrames(!!process.env.PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES);
if (this._state.options.snapshots)
await this._snapshotter?.start(progress2);
return { traceName: this._state.traceName };
@@ -24896,6 +24900,7 @@ var init_tracing = __esm({
eventsHelper.removeEventListeners(this._eventListeners);
if (this._state.options.screenshots)
this._stopScreencast();
this._harTracer.setOmitWebSocketFrames(true);
if (this._state.options.snapshots)
this._snapshotter?.stop();
this.flushHarEntries();
@@ -35989,7 +35994,7 @@ var init_crNetworkManager = __esm({
}
_onWebSocketWillSendHandshakeRequest(event) {
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);
}
_onWebSocketClosed(event) {
@@ -35997,7 +36002,7 @@ var init_crNetworkManager = __esm({
this._page.frameManager.webSocketClosed(event.requestId);
}
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
}
_maybeUpdateRequestSession(sessionInfo, request2) {
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);
}
_onNavigationCommitted(params2) {
if (!params2.navigationId) {
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
return;
}
for (const [workerId, worker] of this._workers) {
if (worker.frameId === params2.frameId)
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) {
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
@@ -46941,7 +46942,7 @@ var init_wkPage = __esm({
}
_onWebSocketWillSendHandshakeRequest(event) {
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);
}
_onWebSocketClosed(event) {
@@ -46949,7 +46950,7 @@ var init_wkPage = __esm({
this._page.frameManager.webSocketClosed(event.requestId);
}
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
}
async _grantPermissions(origin, permissions) {
const webPermissionToProtocol = /* @__PURE__ */ new Map([
@@ -49287,7 +49288,7 @@ var init_wvPage = __esm({
}
_onWebSocketWillSendHandshakeRequest(event) {
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);
}
_onWebSocketClosed(event) {
@@ -49295,7 +49296,7 @@ var init_wvPage = __esm({
this._page.frameManager.webSocketClosed(event.requestId);
}
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
}
shouldToggleStyleSheetToSyncAnimations() {
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="manifest" href="./manifest.webmanifest">
<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/defaultSettingsView-BNmKHKpQ.js">
<link rel="stylesheet" crossorigin href="./defaultSettingsView.CjdS-WJx.css">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "playwright-core",
"version": "1.61.0",
"version": "1.61.1",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",
+3 -2
View File
@@ -869,7 +869,7 @@ function resolve(specifier, context, nextResolve) {
const filename = import_url.default.fileURLToPath(context.parentURL);
const resolved = resolveHook(filename, specifier);
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);
@@ -894,7 +894,8 @@ function load(moduleUrl, context, nextLoad) {
const filename = import_url.default.fileURLToPath(moduleUrl);
if (!shouldTransform(filename))
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 transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
return {
+1 -1
View File
@@ -16,7 +16,7 @@
1.2 KB packages/playwright/src/common/validators.ts
1.3 KB packages/playwright/src/isomorphic/teleReceiver.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/portTransport.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")
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)) {
if (name in allBuiltinMatchers)
continue;
info.userMatchers[name] = matcher;
const { positive, inverse } = buildCustomAsymmetricMatcher(name, matcher);
expectFn[name] = positive;
notAsymmetric[name] = inverse;
+1 -1
View File
@@ -1,5 +1,5 @@
# packages/playwright/lib/matchers/expect.js
# total: 513.8 KB
# total: 513.9 KB
## Inlined (64)
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 resolved = resolveHook(filename, specifier);
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);
@@ -5654,7 +5654,8 @@ function load(moduleUrl, context, nextLoad) {
const filename = import_url.default.fileURLToPath(moduleUrl);
if (!shouldTransform(filename))
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 transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
return {
+2 -2
View File
@@ -1,5 +1,5 @@
# packages/playwright/lib/transform/esmLoader.js
# total: 249.0 KB
# total: 249.1 KB
## Inlined (47)
1.5 KB node_modules/balanced-match/index.js
@@ -40,7 +40,7 @@
0.2 KB packages/isomorphic/stringUtils.ts
6.3 KB packages/playwright/src/transform/compilationCache.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/portTransport.ts
11.7 KB packages/playwright/src/transform/transform.ts
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "playwright",
"version": "1.61.0",
"version": "1.61.1",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",
@@ -53,7 +53,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
"playwright-core": "1.61.1"
},
"optionalDependencies": {
"fsevents": "2.3.2"
+8 -10
View File
@@ -9,7 +9,7 @@
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.48.0"
"playwright": "^1.61.1"
}
},
"node_modules/accepts": {
@@ -580,12 +580,11 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dependencies": {
"playwright-core": "1.61.0"
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
@@ -598,10 +597,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"bin": {
"playwright-core": "cli.js"
},
+1 -1
View File
@@ -8,6 +8,6 @@
},
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.48.0"
"playwright": "^1.61.1"
}
}
+5 -1
View File
@@ -50,7 +50,11 @@ if (!isRunning()) {
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
}
// Give it a moment to start listening
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
if (platform() === "win32") {
execSync("powershell -Command \"Start-Sleep -Seconds 3\"", { stdio: "ignore", timeout: 10000 })
} else {
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
}
} else {
console.log("Ollama already running")
}
+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 ──")
}
// 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) {
console.log("\n── Self-Healing Build Check ──")
const { generateModule } = loadRegistry()
@@ -252,7 +261,7 @@ if (doSelfHeal) {
}
}
// 6. Final summary
// 7. Final summary
console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
+190 -38
View File
@@ -1,9 +1,9 @@
"use client"
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 { Bot, ChevronRight } from "lucide-react"
import { Bot, ChevronRight, Wifi, WifiOff } from "lucide-react"
const tips = [
"Ask for cold email templates for a specific job",
@@ -16,12 +16,150 @@ const tips = [
export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
const aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null)
const [aiOnline, setAiOnline] = useState<boolean | 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) => {
setSelectedJob(job)
}, [])
useEffect(() => {
let cancelled = false
const check = async () => {
try {
const res = await fetch("/api/ai/jobs")
if (!cancelled) setAiOnline(res.ok)
} catch {
if (!cancelled) setAiOnline(false)
}
}
check()
const id = setInterval(check, 15000)
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) => {
aiChatRef.current?.fillInput(tip)
}, [])
@@ -35,51 +173,65 @@ export default function AIAssistantPage() {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
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 (
<div className="flex h-[calc(100vh-3.5rem)] bg-[#0f1117]">
<div className="flex-1 flex flex-col min-w-0">
<div className="bg-[#1a1d2e]/80 backdrop-blur-md border-b border-[#ffffff08] px-6 py-4">
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
<div className="flex-1 flex flex-col min-w-0 border border-foreground transition-colors overflow-hidden">
<div className="bg-card/80 backdrop-blur-md border-b border-border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary flex items-center justify-center" style={{boxShadow: "0 0 40px hsl(var(--primary) / 0.3)"}}>
<Bot className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-white font-bold text-lg">AI Sales Assistant</h1>
<p className="text-[#6b7280] text-xs mt-0.5">Powered by local AI</p>
<h1 className="text-foreground font-bold text-lg">AI Sales Assistant</h1>
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
<span className="text-[#22c55e] text-xs font-medium">Online</span>
<div className="flex items-center gap-2 text-xs">
<span className={aiOnline === null ? "text-[#6a6a75]" : aiOnline ? "text-green-400" : "text-red-400"}>
{aiOnline === null ? "Checking..." : aiOnline ? "Connected" : "Disconnected"}
</span>
{aiOnline === null ? (
<span className="h-2 w-2 rounded-full bg-[#6a6a75]" />
) : aiOnline ? (
<Wifi className="h-3.5 w-3.5 text-green-400" />
) : (
<WifiOff className="h-3.5 w-3.5 text-red-400" />
)}
</div>
<div className="w-px h-5 bg-[#ffffff08]" />
<div className="bg-[#ffffff08] rounded-lg px-3 py-1.5">
<span className="text-[#9ca3af] text-xs">Local AI Model</span>
<div className="w-px h-5 bg-border" />
<div className="bg-muted/50 rounded-lg px-3 py-1.5">
<span className="text-muted-foreground text-xs">Local AI Model</span>
</div>
</div>
</div>
</div>
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
</div>
<div className="w-[300px] flex-none bg-[#13151f] border-l border-[#ffffff08] p-5 overflow-y-auto h-full flex flex-col">
<div className="w-[300px] flex-none bg-sidebar border border-foreground transition-colors p-5 overflow-y-auto h-full flex flex-col">
<div>
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
<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>
</div>
<JobSelector onSelect={handleJobSelect} />
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
{selectedJob && (
<div className="bg-[#1a1d2e]/50 border border-[#ffffff08] rounded-xl p-3.5 mt-3 space-y-2">
<h4 className="text-sm font-semibold text-[#e5e7eb]">{selectedJob.job_title}</h4>
<span className="text-xs px-2 py-0.5 rounded-md bg-[#f97316]/10 text-[#f97316] font-medium inline-block">{selectedJob.industry}</span>
<p className="text-xs text-[#6b7280] leading-relaxed">{selectedJob.description}</p>
<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>
<span className="text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium inline-block">{selectedJob.industry}</span>
<p className="text-xs text-muted-foreground leading-relaxed">{selectedJob.description}</p>
<div className="flex flex-wrap gap-1.5">
{selectedJob.keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-[#ffffff08] text-[#4b5563]">{kw}</span>
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-muted/50 text-muted-foreground">{kw}</span>
))}
</div>
</div>
@@ -87,48 +239,48 @@ export default function AIAssistantPage() {
</div>
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
</div>
{tips.map((tip, i) => (
<div
key={i}
onClick={() => handleTipClick(tip)}
className="flex items-start gap-2.5 bg-[#1a1d2e]/50 hover:bg-[#1a1d2e] border border-[#ffffff08] hover:border-[#f97316]/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
className="flex items-start gap-2.5 bg-card/50 hover:bg-card border border-border hover:border-primary/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
>
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-[#374151] group-hover:text-[#f97316] transition-colors duration-200 flex-shrink-0" />
<span className="text-[#9ca3af] text-xs leading-5 group-hover:text-[#e5e7eb] transition-colors duration-200">{tip}</span>
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors duration-200 flex-shrink-0" />
<span className="text-muted-foreground text-xs leading-5 group-hover:text-foreground transition-colors duration-200">{tip}</span>
</div>
))}
</div>
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Recent Prompts</span>
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Recent Prompts</span>
</div>
{recentPrompts.length > 0 ? (
recentPrompts.map((prompt, i) => (
<div
key={i}
onClick={() => handleRecentPromptClick(prompt)}
className="bg-[#1a1d2e]/50 rounded-xl p-3 mb-2 border border-[#ffffff08] text-[#6b7280] text-xs truncate hover:text-[#9ca3af] cursor-pointer transition-colors duration-200"
className="bg-card/50 rounded-xl p-3 mb-2 border border-border text-muted-foreground text-xs truncate hover:text-foreground cursor-pointer transition-colors duration-200"
>
{prompt}
</div>
))
) : (
<div className="text-[#374151] text-xs text-center py-4">Your recent prompts will appear here</div>
<div className="text-muted-foreground text-xs text-center py-4">Your recent prompts will appear here</div>
)}
</div>
<div className="mt-auto border-t border-[#ffffff08] pt-4">
<div className="mt-auto border-t border-border pt-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Messages today</div>
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">24</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">{msgCount}</div>
</div>
<div className="flex-1">
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Tokens used</div>
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">12.4k</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">{tokenEstimate}</div>
</div>
</div>
</div>
+5 -5
View File
@@ -126,11 +126,11 @@ export default function CalendarPage() {
fetch("/api/event-users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
.catch((err) => console.error("Failed to fetch event users:", err))
fetch("/api/leads?limit=200")
.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 }))))
.catch(() => {})
.catch((err) => console.error("Failed to fetch leads:", err))
}, [user, router])
useEffect(() => {
@@ -139,13 +139,13 @@ export default function CalendarPage() {
}, [fetchMonthEvents])
useEffect(() => {
fetchUpcomingEvents().catch(() => {})
fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
}, [fetchUpcomingEvents])
useEffect(() => {
const interval = setInterval(() => {
fetchMonthEvents().catch(() => {})
fetchUpcomingEvents().catch(() => {})
fetchMonthEvents().catch((err) => console.error("Failed to fetch month events:", err))
fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
}, 15000)
return () => clearInterval(interval)
}, [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,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
Pin, PinOff,
} from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
import {
@@ -211,8 +212,15 @@ const formatPreviewContent = (content: string) => {
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => {
if (!searchQuery.trim()) return conversations
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
let list = conversations
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])
// Fetch conversations from API
@@ -227,8 +235,8 @@ const formatPreviewContent = (content: string) => {
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
if (convs.length > 0) setActiveChat(convs[0].id)
}
} catch {
console.warn("Failed to fetch conversations in chats page")
} catch (err) {
console.error("Failed to fetch conversations:", err)
} finally {
setLoadingChats(false)
}
@@ -289,11 +297,11 @@ const formatPreviewContent = (content: string) => {
return next
})
}
} catch {
// ignore
} catch (err) {
console.error("Failed to fetch messages:", err)
}
// 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 })
}
fetchMsgs()
@@ -312,7 +320,7 @@ const formatPreviewContent = (content: string) => {
const existingIds = new Set(conversations.map((c) => otherParticipant(c).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)
}, 300)
return () => clearTimeout(timer)
@@ -502,10 +510,13 @@ const formatPreviewContent = (content: string) => {
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
: conv
)
const idx = updated.findIndex((c) => c.id === activeChat)
if (idx > 0) {
const [item] = updated.splice(idx, 1)
updated.unshift(item)
const conv = updated.find((c) => c.id === activeChat)
if (conv && !conv.pinned) {
const idx = updated.findIndex((c) => c.id === activeChat)
if (idx > 0) {
const [item] = updated.splice(idx, 1)
updated.unshift(item)
}
}
return updated
})
@@ -776,6 +787,23 @@ const formatPreviewContent = (content: string) => {
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) => {
try {
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 })
setConversations((prev) => {
const idx = prev.findIndex((c) => c.id === conv.id)
if (idx > 0) {
if (idx > 0 && !conv.pinned) {
const u = [...prev]
const [item] = u.splice(idx, 1)
u.unshift(item)
@@ -827,7 +855,7 @@ const formatPreviewContent = (content: string) => {
})
}}
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"
)}
>
@@ -837,8 +865,21 @@ const formatPreviewContent = (content: string) => {
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{person.name}</span>
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
<div className="flex items-center gap-1.5 min-w-0">
{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>
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
</div>
@@ -871,7 +912,7 @@ const formatPreviewContent = (content: string) => {
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"
>
+2 -2
View File
@@ -39,8 +39,8 @@ export default function DashboardPage() {
const data = await res.json()
setStats(data)
}
} catch {
console.warn("Failed to fetch dashboard stats")
} catch (err) {
console.error("Failed to fetch dashboard stats:", err)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default function EmailsPage() {
useEffect(() => {
if (!user) { router.push("/login"); 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])
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(),
])
if (leadRes.ok) setLead(await leadRes.json())
} catch {
console.warn("Failed to fetch lead details")
} catch (err) {
console.error("Failed to fetch lead details:", err)
}
setLoading(false)
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useRouter } from "next/navigation"
import { PageHeader } from "@/components/shared/page-header"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card } from "@/components/ui/card"
import { GripVertical, Plus, User, Building2, Mail, Phone, Hash } from "lucide-react"
import { toast } from "sonner"
interface KanbanLead {
id: string
company_name: string
contact_name: string
email: string
phone: string
score: number
notes: string
assigned_to: string | null
created_at: string
}
interface KanbanColumn {
id: string
name: string
status: string
sortOrder: number
probability: number
leads: KanbanLead[]
}
const statusColors: Record<string, { header: string; border: string; dot: string }> = {
open: { header: "bg-blue-500/10 border-blue-500/20", border: "border-blue-500/20", dot: "bg-blue-500" },
contacted: { header: "bg-amber-500/10 border-amber-500/20", border: "border-amber-500/20", dot: "bg-amber-500" },
pending: { header: "bg-purple-500/10 border-purple-500/20", border: "border-purple-500/20", dot: "bg-purple-500" },
closed: { header: "bg-emerald-500/10 border-emerald-500/20", border: "border-emerald-500/20", dot: "bg-emerald-500" },
ignored: { header: "bg-zinc-500/10 border-zinc-500/20", border: "border-zinc-500/20", dot: "bg-zinc-500" },
}
const columnGradient: Record<string, string> = {
open: "from-blue-500/5",
contacted: "from-amber-500/5",
pending: "from-purple-500/5",
closed: "from-emerald-500/5",
ignored: "from-zinc-500/5",
}
export default function KanbanPage() {
const router = useRouter()
const [columns, setColumns] = useState<KanbanColumn[]>([])
const [loading, setLoading] = useState(true)
const [draggedLead, setDraggedLead] = useState<{ leadId: string; fromStatus: string } | null>(null)
const fetchKanban = useCallback(async () => {
try {
const res = await fetch("/api/leads/kanban")
const data = await res.json()
setColumns(data)
} catch (e) {
toast.error("Failed to load kanban data")
console.error("Failed to load kanban", e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchKanban() }, [fetchKanban])
const handleDragStart = (leadId: string, status: string) => {
setDraggedLead({ leadId, fromStatus: status })
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
const handleDrop = async (toStatus: string) => {
if (!draggedLead || draggedLead.fromStatus === toStatus) {
setDraggedLead(null)
return
}
setColumns((prev) => {
const next = prev.map((col) => ({ ...col, leads: [...col.leads] }))
const fromCol = next.find((c) => c.status === draggedLead.fromStatus)
const toCol = next.find((c) => c.status === toStatus)
if (!fromCol || !toCol) return prev
const leadIdx = fromCol.leads.findIndex((l) => l.id === draggedLead.leadId)
if (leadIdx === -1) return prev
const [moved] = fromCol.leads.splice(leadIdx, 1)
toCol.leads.unshift(moved)
return next
})
try {
await fetch(`/api/leads/${draggedLead.leadId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: toStatus }),
})
} catch (e) {
toast.error("Failed to update lead status")
console.error("Failed to update lead status", e)
fetchKanban()
}
setDraggedLead(null)
}
if (loading) {
return (
<div className="space-y-4">
<PageHeader title="Kanban Board" description="Drag and drop leads to update their stage" />
<div className="flex gap-4 overflow-x-auto pb-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex h-96 w-72 shrink-0 animate-pulse rounded-lg bg-muted" />
))}
</div>
</div>
)
}
return (
<div className="space-y-4">
<PageHeader
title="Kanban Board"
description="Drag and drop leads between stages to update their status"
>
<Button variant="outline" size="sm" onClick={() => router.push("/leads")}>
Table View
</Button>
<Button size="sm" onClick={() => router.push("/leads/new")}>
<Plus className="mr-1.5 h-4 w-4" /> New Lead
</Button>
</PageHeader>
<div className="flex gap-4 overflow-x-auto pb-4" style={{ minHeight: "calc(100vh - 12rem)" }}>
{columns.map((col) => {
const colors = statusColors[col.status] || statusColors.open
return (
<div
key={col.id}
className="flex w-72 shrink-0 flex-col rounded-lg border bg-card"
onDragOver={handleDragOver}
onDrop={() => handleDrop(col.status)}
>
<div className={`flex items-center justify-between rounded-t-lg border-b px-3 py-2.5 ${colors.header}`}>
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${colors.dot}`} />
<span className="text-sm font-semibold">{col.name}</span>
<span className="text-xs text-muted-foreground">({col.leads.length})</span>
</div>
{col.probability > 0 && (
<span className="text-xs text-muted-foreground">{col.probability}%</span>
)}
</div>
<div className="flex-1 space-y-2 overflow-y-auto p-2">
{col.leads.length === 0 && (
<div className="flex h-20 items-center justify-center">
<p className="text-xs text-muted-foreground">No leads</p>
</div>
)}
{col.leads.map((lead) => (
<LeadCard
key={lead.id}
lead={lead}
status={col.status}
onDragStart={handleDragStart}
onClick={() => router.push(`/leads/${lead.id}`)}
/>
))}
</div>
</div>
)
})}
</div>
</div>
)
}
function LeadCard({
lead,
status,
onDragStart,
onClick,
}: {
lead: KanbanLead
status: string
onDragStart: (id: string, status: string) => void
onClick: () => void
}) {
const initials = lead.contact_name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
return (
<Card
draggable
onDragStart={() => onDragStart(lead.id, status)}
onClick={onClick}
className="cursor-grab active:cursor-grabbing p-3 space-y-2 transition-shadow hover:shadow-md"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Avatar className="h-7 w-7 shrink-0">
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{lead.contact_name}</p>
{lead.company_name && (
<p className="flex items-center gap-1 text-xs text-muted-foreground truncate">
<Building2 className="h-3 w-3 shrink-0" />
{lead.company_name}
</p>
)}
</div>
</div>
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground/40" />
</div>
<div className="space-y-1">
{lead.email && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
<Mail className="h-3 w-3 shrink-0" />
{lead.email}
</p>
)}
{lead.phone && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
<Phone className="h-3 w-3 shrink-0" />
{lead.phone}
</p>
)}
</div>
{lead.score > 0 && (
<div className="flex items-center gap-1.5">
<Hash className="h-3 w-3 text-muted-foreground/60" />
<div className="flex h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="rounded-full transition-all"
style={{
width: `${lead.score}%`,
backgroundColor:
lead.score >= 70 ? "#22c55e" : lead.score >= 40 ? "#eab308" : "#ef4444",
}}
/>
</div>
<span className="text-[11px] text-muted-foreground">{lead.score}</span>
</div>
)}
</Card>
)
}
+3 -1
View File
@@ -29,6 +29,7 @@ import { ArrowLeft } from "lucide-react"
import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { toast } from "sonner"
const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -53,7 +54,7 @@ export default function CreateLeadPage() {
fetch("/api/users")
.then((r) => r.json())
.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>({
@@ -87,6 +88,7 @@ export default function CreateLeadPage() {
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads")
} catch (e) {
toast.error("Failed to create lead")
console.error("Create lead error:", e)
} finally {
setSaving(false)
+9 -2
View File
@@ -5,6 +5,9 @@ import { useRouter } from "next/navigation"
import { PageHeader } from "@/components/shared/page-header"
import { LeadsTable } from "@/components/leads/leads-table"
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
import { Button } from "@/components/ui/button"
import { Columns3 } from "lucide-react"
import { toast } from "sonner"
import { Lead } from "@/types"
export default function LeadsPage() {
@@ -28,7 +31,7 @@ export default function LeadsPage() {
setLeadsData(data)
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])
return (
@@ -36,7 +39,11 @@ export default function LeadsPage() {
<PageHeader
title="Leads"
description="Manage and track your sales leads"
/>
>
<Button variant="outline" size="sm" onClick={() => router.push("/leads/kanban")}>
<Columns3 className="mr-1.5 h-4 w-4" /> Kanban
</Button>
</PageHeader>
<div className="rounded-lg border bg-card">
<div className="p-4">
+398
View File
@@ -0,0 +1,398 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useParams, useRouter } from "next/navigation"
import { PageHeader } from "@/components/shared/page-header"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { CustomFieldsSection } from "@/components/shared/custom-fields-section"
import {
ArrowLeft, Calendar, User, Building2, DollarSign, Plus, CheckCircle2,
Circle, Clock, AlertCircle, ChevronDown, ChevronRight, GripVertical,
} from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { toast } from "sonner"
interface Milestone {
id: string
name: string
description: string
sort_order: number
status: string
start_date: string
due_date: string
completed_at: string
created_at: string
tasks: Task[]
}
interface Task {
id: string
title: string
description: string
status: string
due_date: string
completed_at: string
assigned_to: string
created_at: string
}
const milestoneStatusConfig: Record<string, { icon: any; class: string; label: string }> = {
pending: { icon: Circle, class: "text-zinc-400", label: "Pending" },
in_progress: { icon: Clock, class: "text-blue-500", label: "In Progress" },
completed: { icon: CheckCircle2, class: "text-emerald-500", label: "Completed" },
cancelled: { icon: AlertCircle, class: "text-red-500", label: "Cancelled" },
}
const taskStatusColors: Record<string, string> = {
pending: "bg-zinc-500",
in_progress: "bg-blue-500",
completed: "bg-emerald-500",
cancelled: "bg-red-500",
}
export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [project, setProject] = useState<any>(null)
const [milestones, setMilestones] = useState<Milestone[]>([])
const [statuses, setStatuses] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [expandedMilestones, setExpandedMilestones] = useState<Set<string>>(new Set())
const [newMilestoneName, setNewMilestoneName] = useState("")
const [addingMilestone, setAddingMilestone] = useState(false)
const [newTaskInputs, setNewTaskInputs] = useState<Record<string, string>>({})
const fetchProject = useCallback(async () => {
try {
const [pRes, mRes, sRes] = await Promise.all([
fetch(`/api/projects/${id}`),
fetch(`/api/projects/${id}/milestones`),
fetch("/api/project-statuses"),
])
setProject(await pRes.json())
setMilestones(await mRes.json())
setStatuses(await sRes.json())
} catch (e) {
toast.error("Failed to load project")
console.error("Failed to load project", e)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { fetchProject() }, [fetchProject])
const updateProjectStatus = async (statusId: string) => {
try {
await fetch(`/api/projects/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ statusId }),
})
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) => {
try {
await fetch(`/api/milestones/${milestoneId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
})
fetchProject()
} catch (e) {
console.error("Failed to update milestone status", e)
toast.error("Failed to update milestone status")
}
}
const addMilestone = async () => {
if (!newMilestoneName.trim()) return
setAddingMilestone(true)
try {
await fetch(`/api/projects/${id}/milestones`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newMilestoneName.trim() }),
})
setNewMilestoneName("")
fetchProject()
} catch (e) {
console.error("Failed to add milestone", e)
toast.error("Failed to add milestone")
} finally {
setAddingMilestone(false)
}
}
const addTask = async (milestoneId: string) => {
const title = newTaskInputs[milestoneId]?.trim()
if (!title) return
try {
await fetch(`/api/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
projectId: id,
milestoneId,
}),
})
setNewTaskInputs((prev) => ({ ...prev, [milestoneId]: "" }))
fetchProject()
} catch (e) {
toast.error("Failed to add task")
console.error("Failed to add task", e)
}
}
const toggleMilestone = (msId: string) => {
setExpandedMilestones((prev) => {
const next = new Set(prev)
if (next.has(msId)) next.delete(msId)
else next.add(msId)
return next
})
}
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—"
const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString()}` : ""
if (loading) {
return (
<div className="space-y-4">
<div className="h-10 w-48 animate-pulse rounded bg-muted" />
<div className="grid gap-4 md:grid-cols-3">
{[1, 2, 3].map((i) => <div key={i} className="h-24 animate-pulse rounded-lg bg-muted" />)}
</div>
</div>
)
}
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>
const now = new Date()
const start = project.start_date ? new Date(project.start_date) : now
const end = project.target_end_date ? new Date(project.target_end_date) : new Date(start.getTime() + 60 * 86400000)
const totalDays = Math.max(1, (end.getTime() - start.getTime()) / 86400000)
const completedMilestones = milestones.filter((m) => m.status === "completed").length
const progress = milestones.length ? Math.round((completedMilestones / milestones.length) * 100) : 0
return (
<div className="space-y-6">
<PageHeader title={project.name} description={`Project · ${project.company_name}`}>
<Button variant="outline" size="sm" onClick={() => router.push("/projects")}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Button>
</PageHeader>
{/* Overview Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Customer</p>
<p className="flex items-center gap-1.5 text-sm font-medium"><Building2 className="h-4 w-4 text-muted-foreground" />{project.company_name}</p>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Owner</p>
<p className="flex items-center gap-1.5 text-sm font-medium"><User className="h-4 w-4 text-muted-foreground" />{project.owner_name}</p>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Timeline</p>
<p className="flex items-center gap-1.5 text-sm font-medium"><Calendar className="h-4 w-4 text-muted-foreground" />{formatDate(project.start_date)} {formatDate(project.target_end_date)}</p>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Budget</p>
<p className="flex items-center gap-1.5 text-sm font-medium"><DollarSign className="h-4 w-4 text-muted-foreground" />{project.budget ? formatCurrency(project.budget) : "—"}</p>
</Card>
</div>
{/* Status & Progress */}
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<p className="text-sm font-medium">Status</p>
<Select value={project.status_id} onValueChange={updateProjectStatus}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statuses.map((s) => (
<SelectItem key={s.id} value={s.id}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color }} />
{s.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">{progress}% complete</p>
<div className="h-2 w-32 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
</Card>
{/* Gantt Chart */}
<Card className="p-4">
<p className="text-sm font-medium mb-3">Timeline</p>
<div className="relative overflow-x-auto">
<div className="min-w-[500px] space-y-2">
{milestones.map((ms) => {
const msStart = ms.start_date ? new Date(ms.start_date) : start
const msEnd = ms.due_date ? new Date(ms.due_date) : end
const left = Math.max(0, ((msStart.getTime() - start.getTime()) / 86400000) / totalDays * 100)
const width = Math.max(3, ((msEnd.getTime() - msStart.getTime()) / 86400000) / totalDays * 100)
const config = milestoneStatusConfig[ms.status] || milestoneStatusConfig.pending
return (
<div key={ms.id} className="flex items-center gap-3">
<span className="w-48 text-xs truncate shrink-0">{ms.name}</span>
<div className="relative flex-1 h-6">
<div className="absolute inset-0 rounded bg-muted/30" />
<div
className={`absolute top-1 h-4 rounded transition-all ${config.class.replace("text-", "bg-").replace("500", "500/80")}`}
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: ms.status === "completed" ? "#22c55e" : ms.status === "in_progress" ? "#3b82f6" : ms.status === "cancelled" ? "#ef4444" : "#a1a1aa" }}
>
<span className="px-2 text-[10px] leading-4 text-white block truncate opacity-80">{ms.name}</span>
</div>
</div>
</div>
)
})}
</div>
</div>
</Card>
{/* Custom Fields */}
{project.custom_fields && Object.keys(project.custom_fields).length > 0 && (
<CustomFieldsSection
entityType="project"
customFields={project.custom_fields}
entityId={project.id}
apiEndpoint={`/api/projects/${project.id}`}
title="Custom Fields"
/>
)}
{/* Milestones */}
<Card className="divide-y">
<div className="flex items-center justify-between px-4 py-3">
<p className="text-sm font-medium">Milestones ({milestones.length})</p>
<div className="flex items-center gap-2">
<Input
placeholder="New milestone name..."
value={newMilestoneName}
onChange={(e) => setNewMilestoneName(e.target.value)}
className="h-8 w-56 text-sm"
onKeyDown={(e) => e.key === "Enter" && addMilestone()}
/>
<Button size="sm" variant="outline" className="h-8" onClick={addMilestone} disabled={addingMilestone || !newMilestoneName.trim()}>
<Plus className="h-3.5 w-3.5" /> Add
</Button>
</div>
</div>
{milestones.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">No milestones yet</div>
)}
{milestones.map((ms) => {
const config = milestoneStatusConfig[ms.status] || milestoneStatusConfig.pending
const isExpanded = expandedMilestones.has(ms.id)
return (
<div key={ms.id}>
<div
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => toggleMilestone(ms.id)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{isExpanded ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<config.icon className={`h-4 w-4 ${config.class}`} />
<span className="text-sm font-medium truncate">{ms.name}</span>
<Badge variant="outline" className="text-[10px] h-5">{config.label}</Badge>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
{ms.start_date && <span>{formatDate(ms.start_date)}</span>}
{ms.start_date && ms.due_date && <span></span>}
{ms.due_date && <span>{formatDate(ms.due_date)}</span>}
</div>
<Select
value={ms.status}
onValueChange={(v) => updateMilestoneStatus(ms.id, v)}
>
<SelectTrigger className="h-7 w-[110px] text-xs" onPointerDown={(e) => e.stopPropagation()}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{isExpanded && (
<div className="border-t bg-muted/20 px-4 py-3 space-y-2">
{ms.description && (
<p className="text-xs text-muted-foreground">{ms.description}</p>
)}
{ms.tasks.length === 0 && (
<p className="text-xs text-muted-foreground">No tasks yet</p>
)}
{ms.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 pl-2">
<span className={`h-2 w-2 rounded-full shrink-0 ${taskStatusColors[task.status] || "bg-zinc-400"}`} />
<span className="text-xs flex-1">{task.title}</span>
<span className="text-[10px] text-muted-foreground capitalize">{task.status.replace("_", " ")}</span>
</div>
))}
<div className="flex items-center gap-2 pt-1">
<Input
placeholder="Add task..."
value={newTaskInputs[ms.id] || ""}
onChange={(e) => setNewTaskInputs((prev) => ({ ...prev, [ms.id]: e.target.value }))}
className="h-7 text-xs flex-1"
onKeyDown={(e) => e.key === "Enter" && addTask(ms.id)}
/>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => addTask(ms.id)}
disabled={!newTaskInputs[ms.id]?.trim()}
>
<Plus className="h-3 w-3 mr-1" /> Task
</Button>
</div>
</div>
)}
</div>
)
})}
</Card>
</div>
)
}
+280
View File
@@ -0,0 +1,280 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { PageHeader } from "@/components/shared/page-header"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Search, Plus, Calendar, User, Building2, ArrowRight, DollarSign } from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { toast } from "sonner"
interface Project {
id: string
name: string
description: string
start_date: string
target_end_date: string
actual_end_date: string
budget: number
created_at: string
status_name: string
status_color: string
customer_id: string
company_name: string
owner_id: string
owner_name: string
owner_avatar: string
}
interface ProjectStatus {
id: string
name: string
color: string
sort_order: number
}
export default function ProjectsPage() {
const router = useRouter()
const [projects, setProjects] = useState<Project[]>([])
const [statuses, setStatuses] = useState<ProjectStatus[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const fetchProjects = async () => {
try {
const params = new URLSearchParams()
if (search) params.set("search", search)
if (statusFilter !== "all") params.set("status", statusFilter)
const res = await fetch(`/api/projects?${params}`)
const data = res.ok ? await res.json() : []
setProjects(Array.isArray(data) ? data : [])
} catch (e) {
toast.error("Failed to load projects")
console.error("Failed to load projects", e)
setProjects([])
} finally {
setLoading(false)
}
}
useEffect(() => { fetchProjects() }, [search, statusFilter])
useEffect(() => {
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 formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString()}` : ""
return (
<div className="space-y-4">
<PageHeader title="Projects" description="Track post-sale project delivery">
<NewProjectDialog statuses={statuses} onCreated={() => fetchProjects()} />
</PageHeader>
<Card>
<div className="p-4">
<div className="flex flex-1 flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search projects or companies..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 pl-9 w-full sm:max-w-sm"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-9 w-[160px]">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{statuses.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="divide-y">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : projects.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No projects yet</div>
) : (
projects.map((p) => (
<div
key={p.id}
className="flex items-center gap-4 px-6 py-4 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => router.push(`/projects/${p.id}`)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{p.name}</span>
<Badge
variant="outline"
className="shrink-0"
style={{ borderColor: p.status_color + "40", color: p.status_color }}
>
{p.status_name}
</Badge>
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1"><Building2 className="h-3.5 w-3.5" />{p.company_name}</span>
<span className="flex items-center gap-1"><User className="h-3.5 w-3.5" />{p.owner_name}</span>
<span className="flex items-center gap-1"><Calendar className="h-3.5 w-3.5" />{formatDate(p.start_date)} {formatDate(p.target_end_date)}</span>
{p.budget > 0 && <span className="flex items-center gap-1"><DollarSign className="h-3.5 w-3.5" />{formatCurrency(p.budget)}</span>}
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
))
)}
</div>
</Card>
</div>
)
}
function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[]; onCreated: () => void }) {
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [customerId, setCustomerId] = useState("")
const [ownerId, setOwnerId] = useState("")
const [startDate, setStartDate] = useState("")
const [targetEndDate, setTargetEndDate] = useState("")
const [budget, setBudget] = useState("")
const [customers, setCustomers] = useState<any[]>([])
const [users, setUsers] = useState<any[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
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((e) => { console.error("Failed to load users", e); toast.error("Failed to load users") })
}, [open])
const handleSubmit = async () => {
if (!name || !customerId) return
setSaving(true)
try {
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
description,
customerId,
ownerId: ownerId || undefined,
startDate: startDate || null,
targetEndDate: targetEndDate || null,
budget: budget ? parseFloat(budget) : null,
}),
})
if (res.ok) {
setOpen(false)
setName(""); setDescription(""); setCustomerId(""); setOwnerId(""); setStartDate(""); setTargetEndDate(""); setBudget("")
onCreated()
}
} catch (e) {
console.error("Failed to create project", e)
toast.error("Failed to create project")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-1.5 h-4 w-4" />New Project</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Project Name *</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Website Redesign" />
</div>
<div>
<label className="text-sm font-medium">Customer *</label>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder="Select customer..." />
</SelectTrigger>
<SelectContent>
{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>
<label className="text-sm font-medium">Project Owner</label>
<Select value={ownerId} onValueChange={setOwnerId}>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder="Select owner..." />
</SelectTrigger>
<SelectContent>
{users.map((u: any) => (
<SelectItem key={u.id} value={u.id}>{u.first_name} {u.last_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Start Date</label>
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
<div>
<label className="text-sm font-medium">Target End Date</label>
<Input type="date" value={targetEndDate} onChange={(e) => setTargetEndDate(e.target.value)} />
</div>
</div>
<div>
<label className="text-sm font-medium">Budget (ZAR)</label>
<Input type="number" value={budget} onChange={(e) => setBudget(e.target.value)} placeholder="e.g. 25000" />
</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="Project scope and notes..."
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving || !name || !customerId}>
{saving ? "Creating..." : "Create Project"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+213
View File
@@ -0,0 +1,213 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useParams, useRouter } from "next/navigation"
import { PageHeader } from "@/components/shared/page-header"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ArrowLeft, FileText, CheckCircle2, Clock, User, Mail, Building2, Calendar } from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { toast } from "sonner"
export default function ProposalDetailPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [proposal, setProposal] = useState<any>(null)
const [statuses, setStatuses] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [signName, setSignName] = useState("")
const [signEmail, setSignEmail] = useState("")
const [signing, setSigning] = useState(false)
const fetchProposal = useCallback(async () => {
try {
const [pRes, sRes] = await Promise.all([
fetch(`/api/proposals/${id}`),
fetch("/api/proposal-statuses"),
])
setProposal(await pRes.json())
setStatuses(await sRes.json())
} catch (e) {
toast.error("Failed to load proposal")
console.error("Failed to load proposal", e)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { fetchProposal() }, [fetchProposal])
const updateStatus = async (statusId: string) => {
try {
await fetch(`/api/proposals/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ statusId }),
})
fetchProposal()
} catch (e) {
console.error("Failed to update proposal status", e)
toast.error("Failed to update proposal status")
}
}
const handleSign = async () => {
if (!signName || !signEmail) return
setSigning(true)
try {
const res = await fetch(`/api/proposals/${id}/sign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: signName, email: signEmail }),
})
if (res.ok) fetchProposal()
} catch (e) {
console.error("Failed to sign proposal", e)
toast.error("Failed to sign proposal")
} finally {
setSigning(false)
}
}
const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString(undefined, { minimumFractionDigits: 2 })}` : "R0.00"
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—"
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>
if (!proposal) return <div className="p-8 text-center text-muted-foreground">Proposal not found</div>
const canSign = proposal.status_name === "Sent" || proposal.status_name === "Viewed"
const isAccepted = proposal.status_name === "Accepted"
return (
<div className="space-y-6 max-w-4xl">
<PageHeader
title={proposal.title}
description={`${proposal.proposal_number} · Created by ${proposal.created_by_name}`}
>
<Button variant="outline" size="sm" onClick={() => router.push("/proposals")}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Button>
</PageHeader>
{/* Status bar */}
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Status</span>
<Select value={proposal.status_id} onValueChange={updateStatus}>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statuses.map((s: any) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline" style={{ borderColor: proposal.status_color + "40", color: proposal.status_color }}>
{proposal.status_name}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span><Calendar className="h-3.5 w-3.5 inline mr-1" />Created {formatDate(proposal.created_at)}</span>
{proposal.valid_until && <span>Valid until {formatDate(proposal.valid_until)}</span>}
</div>
</div>
</Card>
{/* Client info */}
{(proposal.lead_company || proposal.company_name) && (
<Card className="p-4">
<p className="text-sm text-muted-foreground mb-1">Client</p>
<p className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
{proposal.lead_company || proposal.company_name}
</p>
{proposal.lead_contact && <p className="text-sm text-muted-foreground ml-6">{proposal.lead_contact}</p>}
</Card>
)}
{/* Line Items */}
<Card className="divide-y">
<div className="px-4 py-3 font-medium text-sm">Services</div>
{proposal.items?.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">No items</div>
)}
{proposal.items?.map((item: any) => (
<div key={item.id} className="flex items-center px-4 py-3 text-sm">
<div className="flex-1">
<p>{item.description}</p>
<p className="text-xs text-muted-foreground">
{item.quantity} × {formatCurrency(item.unit_price)}
{item.discount_percent > 0 && ` (${item.discount_percent}% off)`}
</p>
</div>
<p className="font-medium">{formatCurrency(item.total_price)}</p>
</div>
))}
</Card>
{/* Totals */}
<Card className="p-4">
<div className="ml-auto w-64 space-y-1 text-sm">
<div className="flex justify-between"><span>Subtotal</span><span>{formatCurrency(proposal.subtotal)}</span></div>
<div className="flex justify-between"><span>VAT ({proposal.tax_percent}%)</span><span>{formatCurrency(proposal.tax_amount)}</span></div>
<div className="flex justify-between text-lg font-bold border-t pt-2"><span>Total</span><span>{formatCurrency(proposal.total_amount)}</span></div>
</div>
</Card>
{/* Notes & Terms */}
{proposal.notes && (
<Card className="p-4">
<p className="text-sm font-medium mb-1">Notes</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{proposal.notes}</p>
</Card>
)}
{proposal.terms && (
<Card className="p-4">
<p className="text-sm font-medium mb-1">Terms & Conditions</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{proposal.terms}</p>
</Card>
)}
{/* E-Signature */}
<Card className="p-6">
<p className="text-sm font-medium mb-1">Digital Signature</p>
<p className="text-sm text-muted-foreground mb-4">
{isAccepted ? "This proposal has been signed and accepted." : "Sign below to accept this proposal."}
</p>
{proposal.signatures?.length > 0 && (
<div className="mb-4 space-y-2">
{proposal.signatures.map((sig: any) => (
<div key={sig.id} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<span className="font-medium">{sig.signatory_name}</span>
<span className="text-muted-foreground">({sig.signatory_email})</span>
<span className="text-muted-foreground text-xs"> {new Date(sig.signed_at).toLocaleString()}</span>
</div>
))}
</div>
)}
{canSign && (
<div className="space-y-3 max-w-sm">
<Input placeholder="Full Name" value={signName} onChange={(e) => setSignName(e.target.value)} />
<Input placeholder="Email Address" type="email" value={signEmail} onChange={(e) => setSignEmail(e.target.value)} />
<Button onClick={handleSign} disabled={signing || !signName || !signEmail}>
{signing ? "Signing..." : <><CheckCircle2 className="mr-1.5 h-4 w-4" /> Accept & Sign</>}
</Button>
</div>
)}
</Card>
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
"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 { Input } from "@/components/ui/input"
import { Card } from "@/components/ui/card"
import { Plus, Trash2, ArrowLeft, FileText } from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { toast } from "sonner"
interface LineItem {
id: string
description: string
quantity: number
unitPrice: number
discountPercent: number
totalPrice: number
}
export default function NewProposalPage() {
const router = useRouter()
const [title, setTitle] = useState("")
const [leadId, setLeadId] = useState("")
const [customerId, setCustomerId] = useState("")
const [validUntil, setValidUntil] = useState("")
const [taxPercent, setTaxPercent] = useState(15)
const [notes, setNotes] = useState("")
const [terms, setTerms] = useState("Payment due within 30 days. 50% deposit required to commence work.")
const [items, setItems] = useState<LineItem[]>([])
const [leads, setLeads] = useState<any[]>([])
const [customers, setCustomers] = useState<any[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
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((e) => { console.error("Failed to load customers", e); toast.error("Failed to load customers") })
}, [])
const addItem = () => {
setItems((prev) => [
...prev,
{ id: crypto.randomUUID(), description: "", quantity: 1, unitPrice: 0, discountPercent: 0, totalPrice: 0 },
])
}
const updateItem = (id: string, field: keyof LineItem, value: string | number) => {
setItems((prev) =>
prev.map((item) => {
if (item.id !== id) return item
const updated = { ...item, [field]: value }
updated.totalPrice = updated.quantity * updated.unitPrice * (1 - updated.discountPercent / 100)
return updated
})
)
}
const removeItem = (id: string) => {
setItems((prev) => prev.filter((i) => i.id !== id))
}
const subtotal = items.reduce((sum, i) => sum + i.totalPrice, 0)
const taxAmount = subtotal * (taxPercent / 100)
const total = subtotal + taxAmount
const handleSubmit = async () => {
if (!title) return
setSaving(true)
try {
const res = await fetch("/api/proposals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
leadId: leadId || null,
customerId: customerId || null,
subtotal,
taxPercent,
taxAmount,
totalAmount: total,
validUntil: validUntil || null,
notes: notes || null,
terms: terms || null,
}),
})
const data = await res.json()
if (!data.id) throw new Error("No id returned")
for (const item of items) {
await fetch(`/api/proposals/${data.id}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
discountPercent: item.discountPercent,
}),
})
}
router.push(`/proposals/${data.id}`)
} catch (e) {
toast.error("Failed to create proposal")
console.error("Failed to create proposal", e)
} finally {
setSaving(false)
}
}
const formatCurrency = (n: number) => `R${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
return (
<div className="space-y-6 max-w-4xl mx-auto">
<PageHeader title="New Proposal" description="Create a professional quote for your client">
<Button variant="outline" size="sm" onClick={() => router.push("/proposals")}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Button>
</PageHeader>
<Card className="p-6 space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium">Proposal Title *</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Website Redesign Quote" />
</div>
<div>
<label className="text-sm font-medium">Valid Until</label>
<Input type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
</div>
<div>
<label className="text-sm font-medium">Lead (optional)</label>
<Select value={leadId} onValueChange={setLeadId}>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder="Select lead..." />
</SelectTrigger>
<SelectContent>
{leads.map((l: any) => (
<SelectItem key={l.id} value={l.id}>{l.contact_name || l.company_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium">Customer (optional)</label>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder="Select customer..." />
</SelectTrigger>
<SelectContent>
{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>
<label className="text-sm font-medium">VAT %</label>
<Input type="number" value={taxPercent} onChange={(e) => setTaxPercent(parseFloat(e.target.value) || 0)} />
</div>
</div>
{/* Line Items */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium">Line Items</label>
<Button size="sm" variant="outline" onClick={addItem}><Plus className="h-3.5 w-3.5 mr-1" /> Add Item</Button>
</div>
{items.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No items yet. Click "Add Item" to add services or products.</p>
)}
{items.map((item) => (
<div key={item.id} className="flex items-start gap-3 mb-2 p-3 rounded-lg border bg-muted/20">
<div className="flex-1 space-y-2">
<Input
value={item.description}
onChange={(e) => updateItem(item.id, "description", e.target.value)}
placeholder="Service description..."
className="h-8 text-sm"
/>
<div className="flex gap-2">
<div className="w-20">
<label className="text-[10px] text-muted-foreground">Qty</label>
<Input type="number" min="1" value={item.quantity} onChange={(e) => updateItem(item.id, "quantity", parseInt(e.target.value) || 1)} className="h-7 text-xs" />
</div>
<div className="flex-1">
<label className="text-[10px] text-muted-foreground">Unit Price (ZAR)</label>
<Input type="number" min="0" value={item.unitPrice} onChange={(e) => updateItem(item.id, "unitPrice", parseFloat(e.target.value) || 0)} className="h-7 text-xs" />
</div>
<div className="w-20">
<label className="text-[10px] text-muted-foreground">Discount %</label>
<Input type="number" min="0" max="100" value={item.discountPercent} onChange={(e) => updateItem(item.id, "discountPercent", parseFloat(e.target.value) || 0)} className="h-7 text-xs" />
</div>
<div className="w-28 text-right">
<label className="text-[10px] text-muted-foreground">Total</label>
<p className="text-sm font-medium pt-1">{formatCurrency(item.totalPrice)}</p>
</div>
</div>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 mt-5 shrink-0" onClick={() => removeItem(item.id)}>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</div>
))}
</div>
{/* Totals */}
<div className="border-t pt-4">
<div className="ml-auto w-64 space-y-1 text-sm">
<div className="flex justify-between"><span>Subtotal</span><span>{formatCurrency(subtotal)}</span></div>
<div className="flex justify-between"><span>VAT ({taxPercent}%)</span><span>{formatCurrency(taxAmount)}</span></div>
<div className="flex justify-between text-base font-bold border-t pt-1"><span>Total</span><span>{formatCurrency(total)}</span></div>
</div>
</div>
{/* Notes & Terms */}
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium">Notes</label>
<textarea value={notes} onChange={(e) => setNotes(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="Additional notes for the client..." />
</div>
<div>
<label className="text-sm font-medium">Terms & Conditions</label>
<textarea value={terms} onChange={(e) => setTerms(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" />
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => router.push("/proposals")}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving || !title}>
{saving ? "Creating..." : <><FileText className="mr-1.5 h-4 w-4" /> Create Proposal</>}
</Button>
</div>
</Card>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { PageHeader } from "@/components/shared/page-header"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Search, Plus, ArrowRight, FileText, Calendar, User, Trash2 } from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { toast } from "sonner"
export default function ProposalsPage() {
const router = useRouter()
const [proposals, setProposals] = useState<any[]>([])
const [statuses, setStatuses] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
useEffect(() => {
const params = new URLSearchParams()
if (search) params.set("search", search)
if (statusFilter !== "all") params.set("status", statusFilter)
fetch(`/api/proposals?${params}`)
.then((r) => r.json())
.then(setProposals)
.catch((e) => { console.error("Failed to load proposals", e); toast.error("Failed to load proposals") })
.finally(() => setLoading(false))
}, [search, statusFilter])
useEffect(() => {
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 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 (
<div className="space-y-4">
<PageHeader title="Proposals & Quotes" description="Create and send professional quotes to clients">
<Button size="sm" onClick={() => router.push("/proposals/new")}>
<Plus className="mr-1.5 h-4 w-4" /> New Proposal
</Button>
</PageHeader>
<Card>
<div className="p-4">
<div className="flex flex-1 flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search proposals..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 pl-9 w-full sm:max-w-sm"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-9 w-[160px]">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{statuses.map((s: any) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="divide-y">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : proposals.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No proposals yet</div>
) : (
proposals.map((p: any) => (
<div
key={p.id}
className="flex items-center gap-4 px-6 py-4 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => router.push(`/proposals/${p.id}`)}
>
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{p.title}</span>
<Badge variant="outline" className="shrink-0 text-xs" style={{ borderColor: p.status_color + "40", color: p.status_color }}>
{p.status_name}
</Badge>
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1"><User className="h-3.5 w-3.5" />{p.created_by_name}</span>
<span>{p.proposal_number}</span>
<span className="font-medium">{formatCurrency(p.total_amount)}</span>
{p.valid_until && (
<span className="flex items-center gap-1"><Calendar className="h-3.5 w-3.5" />Valid until {new Date(p.valid_until).toLocaleDateString()}</span>
)}
</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" />
</div>
))
)}
</div>
</Card>
</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>
)
}
+5 -1
View File
@@ -6,7 +6,9 @@ import { CompanySettingsForm } from "@/components/settings/company-settings-form
import { UserPreferencesForm } from "@/components/settings/user-preferences-form"
import { ThemeSettings } from "@/components/settings/theme-settings"
import { NotificationSettings } from "@/components/settings/notification-settings"
import { Building2, User, Palette, Bell } from "lucide-react"
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager"
import { LeadScoringSettings } from "@/components/settings/lead-scoring-settings"
import { Building2, User, Palette, Bell, ListChecks, Gauge } from "lucide-react"
export default function SettingsPage() {
const tabs = [
@@ -14,6 +16,8 @@ export default function SettingsPage() {
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
{ value: "lead-scoring", label: "Lead Scoring", icon: Gauge, component: LeadScoringSettings },
]
return (
+327
View File
@@ -0,0 +1,327 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
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 { 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 { toast } from "sonner"
interface TimeEntry {
id: string
description: string
date: string
duration_minutes: number
billable: boolean
hourly_rate: number
user_name: string
project_name: string
created_at: string
}
export default function TimeTrackingPage() {
const [entries, setEntries] = useState<TimeEntry[]>([])
const [summary, setSummary] = useState<any>(null)
const [projects, setProjects] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [showManual, setShowManual] = useState(false)
// Manual entry form
const [manualProject, setManualProject] = useState("none")
const [manualDesc, setManualDesc] = useState("")
const [manualDate, setManualDate] = useState(new Date().toISOString().split("T")[0])
const [manualHours, setManualHours] = useState("")
const [manualMinutes, setManualMinutes] = useState("")
const [manualBillable, setManualBillable] = useState(true)
const [manualRate, setManualRate] = useState("")
const [saving, setSaving] = useState(false)
// Timer
const [timerRunning, setTimerRunning] = useState(false)
const [timerSeconds, setTimerSeconds] = useState(0)
const [timerProject, setTimerProject] = useState("none")
const [timerDesc, setTimerDesc] = useState("")
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const startTimeRef = useRef<Date | null>(null)
const fetchData = useCallback(async () => {
try {
const [eRes, sRes, pRes] = await Promise.all([
fetch("/api/time-entries"),
fetch("/api/time-entries/summary"),
fetch("/api/projects"),
])
const eData = eRes.ok ? await eRes.json() : []
const sData = sRes.ok ? await sRes.json() : null
const pData = pRes.ok ? await pRes.json() : []
setEntries(Array.isArray(eData) ? eData : [])
setSummary(sData?.totals ? sData : null)
setProjects(Array.isArray(pData) ? pData : [])
} catch (e) {
toast.error("Failed to load time data")
console.error("Failed to load time data", e)
setEntries([])
setProjects([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const startTimer = () => {
if (!timerDesc.trim()) return
setTimerRunning(true)
setTimerSeconds(0)
startTimeRef.current = new Date()
timerRef.current = setInterval(() => {
setTimerSeconds((prev) => prev + 1)
}, 1000)
}
const stopTimer = async () => {
if (timerRef.current) clearInterval(timerRef.current)
setTimerRunning(false)
if (timerSeconds < 60) return
try {
await fetch("/api/time-entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: timerProject === "none" ? null : timerProject,
description: timerDesc,
date: new Date().toISOString().split("T")[0],
startTime: startTimeRef.current?.toISOString(),
endTime: new Date().toISOString(),
durationMinutes: Math.round(timerSeconds / 60),
billable: true,
hourlyRate: null,
}),
})
setTimerDesc("")
setTimerProject("none")
setTimerSeconds(0)
fetchData()
} catch (e) {
toast.error("Failed to save time entry")
console.error("Failed to save time entry", e)
}
}
const addManualEntry = async () => {
const hours = parseInt(manualHours) || 0
const mins = parseInt(manualMinutes) || 0
const totalMins = hours * 60 + mins
if (totalMins <= 0 || !manualDesc.trim()) return
setSaving(true)
try {
await fetch("/api/time-entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: manualProject === "none" ? null : manualProject,
description: manualDesc,
date: manualDate,
durationMinutes: totalMins,
billable: manualBillable,
hourlyRate: manualRate ? parseFloat(manualRate) : null,
}),
})
setManualDesc("")
setManualHours("")
setManualMinutes("")
setManualRate("")
setShowManual(false)
fetchData()
} catch (e) {
console.error("Failed to save time entry", e)
toast.error("Failed to save time entry")
} finally {
setSaving(false)
}
}
const deleteEntry = async (id: string) => {
try {
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 h = Math.floor(mins / 60)
const m = mins % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
const formatTimer = (secs: number) => {
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = secs % 60
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
const formatCurrency = (n: number) => `R${Number(n).toLocaleString(undefined, { minimumFractionDigits: 2 })}`
if (loading) {
return (
<div className="space-y-4">
<PageHeader title="Time Tracking" description="Track billable hours" />
<div className="grid gap-4 md:grid-cols-4">{[1, 2, 3, 4].map((i) => <div key={i} className="h-24 animate-pulse rounded-lg bg-muted" />)}</div>
</div>
)
}
const t = summary?.totals || { total_minutes: 0, billable_minutes: 0, total_revenue: 0, entry_count: 0 }
return (
<div className="space-y-6">
<PageHeader title="Time Tracking" description="Track billable hours per project" />
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Total Hours</p>
<p className="text-2xl font-bold">{formatDuration(parseInt(t.total_minutes))}</p>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Billable Hours</p>
<p className="text-2xl font-bold">{formatDuration(parseInt(t.billable_minutes))}</p>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Revenue</p>
<p className="text-2xl font-bold">{formatCurrency(parseFloat(t.total_revenue))}</p>
</Card>
<Card className="p-4 space-y-1">
<p className="text-xs text-muted-foreground">Entries</p>
<p className="text-2xl font-bold">{t.entry_count}</p>
</Card>
</div>
{/* Timer */}
<Card className="p-6">
<p className="text-sm font-medium mb-3 flex items-center gap-2"><Clock className="h-4 w-4" /> Stopwatch</p>
<div className="flex items-end gap-3">
<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} />
<Select value={timerProject} onValueChange={setTimerProject} disabled={timerRunning}>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder="No project" />
</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 className="text-3xl font-mono font-bold tabular-nums px-4 py-1">{formatTimer(timerSeconds)}</div>
{!timerRunning ? (
<Button onClick={startTimer} disabled={!timerDesc.trim()}><Play className="mr-1.5 h-4 w-4" /> Start</Button>
) : (
<Button variant="destructive" onClick={stopTimer}><Square className="mr-1.5 h-4 w-4" /> Stop</Button>
)}
</div>
</Card>
{/* Manual Entry */}
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium">Manual Entry</p>
<Button size="sm" variant="outline" onClick={() => setShowManual(!showManual)}>
<Plus className="h-3.5 w-3.5 mr-1" /> {showManual ? "Cancel" : "Add Entry"}
</Button>
</div>
{showManual && (
<div className="space-y-3 p-3 rounded-lg border bg-muted/20">
<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" />
<Select value={manualProject} onValueChange={setManualProject}>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="No project" />
</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 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="number" placeholder="Hours" value={manualHours} onChange={(e) => setManualHours(e.target.value)} className="h-8 w-24 text-sm" min="0" />
<Input type="number" placeholder="Minutes" value={manualMinutes} onChange={(e) => setManualMinutes(e.target.value)} className="h-8 w-24 text-sm" min="0" />
<Input type="number" placeholder="Rate (ZAR/hr)" value={manualRate} onChange={(e) => setManualRate(e.target.value)} className="h-8 w-32 text-sm" min="0" />
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={manualBillable} onChange={(e) => setManualBillable(e.target.checked)} className="rounded" />
Billable
</label>
</div>
<Button size="sm" onClick={addManualEntry} disabled={saving || !manualDesc.trim()}>
{saving ? "Saving..." : "Save Entry"}
</Button>
</div>
)}
</Card>
{/* Per-project summary */}
{summary?.perProject?.length > 0 && (
<Card className="p-4">
<p className="text-sm font-medium mb-3 flex items-center gap-2"><Briefcase className="h-4 w-4" /> Hours by Project</p>
<div className="space-y-2">
{summary.perProject.map((p: any) => (
<div key={p.id} className="flex items-center gap-3 text-sm">
<span className="w-48 truncate">{p.name}</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-primary" style={{ width: `${Math.min(100, (parseInt(p.total_minutes) / parseInt(t.total_minutes)) * 100)}%` }} />
</div>
<span className="w-20 text-right">{formatDuration(parseInt(p.total_minutes))}</span>
<span className="w-24 text-right text-muted-foreground">{formatCurrency(parseFloat(p.revenue))}</span>
</div>
))}
</div>
</Card>
)}
{/* Recent Entries */}
<Card className="divide-y">
<div className="flex items-center justify-between px-4 py-3">
<p className="text-sm font-medium">Recent Entries</p>
</div>
{entries.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">No entries yet</div>
)}
{entries.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-4 py-3 text-sm hover:bg-muted/30">
<span className={`h-2 w-2 rounded-full shrink-0 ${entry.billable ? "bg-emerald-500" : "bg-zinc-400"}`} />
<div className="flex-1 min-w-0">
<p className="truncate">{entry.description || "No description"}</p>
<p className="text-xs text-muted-foreground">
{entry.project_name && <>{entry.project_name} · </>}
{entry.user_name} · {new Date(entry.date).toLocaleDateString()}
</p>
</div>
<span className="font-medium">{formatDuration(entry.duration_minutes)}</span>
{entry.hourly_rate > 0 && <span className="text-muted-foreground w-20 text-right">{formatCurrency(entry.duration_minutes * entry.hourly_rate / 60)}</span>}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => deleteEntry(entry.id)}>
<Trash2 className="h-3.5 w-3.5 text-red-400" />
</Button>
</div>
))}
</Card>
</div>
)
}
+2 -2
View File
@@ -24,8 +24,8 @@ export default function UsersPage() {
const data = await res.json()
setUsers(data.users || [])
}
} catch {
console.warn("Failed to fetch users in users page")
} catch (err) {
console.error("Failed to fetch users:", err)
} finally {
setLoading(false)
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (!GIPHY_API_KEY) {
return NextResponse.json({ error: "GIPHY API key not configured" }, { status: 500 })
}
const { searchParams } = new URL(request.url)
const type = searchParams.get("type") || "trending"
const query = searchParams.get("q") || ""
const offset = parseInt(searchParams.get("offset") || "0", 10)
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
let url: string
if (type === "search" && query) {
url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g`
} else {
url = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${offset}&rating=g`
}
const res = await fetch(url)
if (!res.ok) {
const errBody = await res.text()
console.error("GIPHY API error:", res.status, errBody)
return NextResponse.json({ error: `GIPHY API error (${res.status}): ${errBody}` }, { status: res.status })
}
const data = await res.json()
const gifs = (data.data || []).map((gif: any) => ({
id: gif.id,
title: gif.title,
url: gif.images?.original?.url || "",
previewUrl: gif.images?.fixed_width?.url || "",
previewHeight: gif.images?.fixed_width?.height || 150,
width: gif.images?.original?.width || 0,
height: gif.images?.original?.height || 0,
}))
return NextResponse.json({
gifs,
pagination: data.pagination || { total_count: 0, count: gifs.length, offset },
})
} catch (error: any) {
console.error("GIPHY proxy error:", error)
return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 })
}
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server"
import { checkAiServiceStatus } from "@/lib/ai"
export async function GET() {
const ok = await checkAiServiceStatus()
return NextResponse.json({ status: ok ? "ok" : "unavailable" }, { status: ok ? 200 : 503 })
}
+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 })
}
}
+34 -22
View File
@@ -1,4 +1,4 @@
import { NextRequest } from "next/server"
import { NextRequest, NextResponse } from "next/server"
import {
comparePassword,
getUserByEmail,
@@ -12,16 +12,31 @@ import {
setSessionContext,
SESSION_COOKIE,
} from "@/lib/auth"
import { checkRateLimit } from "@/lib/rate-limit"
function jsonResponse(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
})
function jsonResponse(data: unknown, status: number, headers?: Record<string, string>) {
return NextResponse.json(data, { status, headers })
}
export async function POST(request: NextRequest) {
try {
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
const rateCheck = checkRateLimit(`login:${ipAddress}`, 10, 60000)
if (!rateCheck.allowed) {
return jsonResponse(
{ error: "Too many login attempts. Try again in 60 seconds." },
429,
{
"Retry-After": String(Math.ceil((rateCheck.resetAt - Date.now()) / 1000)),
"X-RateLimit-Remaining": "0",
},
)
}
const { email, username, password } = await request.json()
const credential = email || username
@@ -40,11 +55,6 @@ export async function POST(request: NextRequest) {
)
}
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
const userAgent = request.headers.get("user-agent") || null
// Try to find user by email first, then by username
@@ -119,20 +129,22 @@ export async function POST(request: NextRequest) {
const user = mapDbUserToSessionUser(dbUser)
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
return new Response(JSON.stringify({ user }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": cookieStr,
},
const isHttps = request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https"
const response = NextResponse.json({ user })
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "strict",
path: "/",
maxAge: 86400,
secure: isHttps,
})
return response
} catch (error) {
console.error("Login error:", error)
return new Response(
JSON.stringify({ error: "Authentication service unavailable." }),
{ status: 503, headers: { "Content-Type": "application/json" } }
const message = error instanceof Error ? error.message : "Authentication service unavailable."
return NextResponse.json(
{ error: message },
{ status: 503 }
)
}
}
+2 -1
View File
@@ -11,8 +11,9 @@ export async function POST() {
})
} catch (error) {
console.error("Logout error:", error)
const message = error instanceof Error ? error.message : "Logout failed."
return new Response(
JSON.stringify({ error: "Logout failed." }),
JSON.stringify({ error: message }),
{ 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 })
} catch (error) {
console.error("Auth me error:", error)
const message = error instanceof Error ? error.message : "Authentication service unavailable."
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ error: message },
{ status: 503 }
)
}
+2 -1
View File
@@ -59,6 +59,7 @@ export async function POST(request: NextRequest) {
}, { status: 200 })
} catch (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 })
} catch (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 })
} catch (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 })
} catch (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) {
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 })
} catch (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) {
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 })
} catch (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 })
} catch (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 })
} catch (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.updated_at,
cp_me.last_read_at,
cp_me.pinned_at,
u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email,
@@ -28,7 +29,7 @@ export async function GET() {
WHERE c.id IN (
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`,
[user.id],
)
@@ -36,6 +37,7 @@ export async function GET() {
const conversations = result.rows.map((row: any) => ({
id: row.id,
updatedAt: row.updated_at,
pinned: row.pinned_at !== null,
otherUser: {
id: row.other_user_id,
name: row.other_user_name,
@@ -50,7 +52,8 @@ export async function GET() {
return NextResponse.json({ conversations })
} catch (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) {
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 })
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const entityType = searchParams.get("entityType")
let sql = `SELECT id, entity_type, field_key, field_label, field_type, options, placeholder, default_value, section, is_required, sort_order, is_active, created_at
FROM custom_field_definitions WHERE is_active = true`
const params: any[] = []
if (entityType) {
sql += ` AND entity_type = $1`
params.push(entityType)
}
sql += ` ORDER BY entity_type, sort_order`
const result = await query(sql, params)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Custom fields API error:", error)
const message = error instanceof Error ? error.message : "Failed to load custom fields"
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()
if (!body.entityType || !body.fieldKey || !body.fieldLabel) {
return NextResponse.json({ error: "entityType, fieldKey, fieldLabel are required" }, { status: 400 })
}
const result = await query(
`INSERT INTO custom_field_definitions (entity_type, field_key, field_label, field_type, options, placeholder, default_value, section, is_required, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[
body.entityType,
body.fieldKey,
body.fieldLabel,
body.fieldType || "text",
body.options ? JSON.stringify(body.options) : null,
body.placeholder || null,
body.defaultValue || null,
body.section || null,
body.isRequired || false,
body.sortOrder || 0,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error: any) {
if (error?.constraint === "uq_custom_field_entity_key") {
return NextResponse.json({ error: "A field with this key already exists for this entity" }, { status: 409 })
}
console.error("Custom fields POST error:", error)
const message = error instanceof Error ? error.message : "Failed to create custom field"
return NextResponse.json({ error: message }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
if (!body.id) return NextResponse.json({ error: "id required" }, { status: 400 })
const fields: string[] = []
const values: any[] = []
let idx = 1
const fieldMap: Record<string, string> = {
fieldLabel: "field_label",
fieldType: "field_type",
options: "options",
placeholder: "placeholder",
defaultValue: "default_value",
section: "section",
isRequired: "is_required",
sortOrder: "sort_order",
isActive: "is_active",
}
for (const [key, col] of Object.entries(fieldMap)) {
if (body[key] !== undefined) {
fields.push(`${col} = $${idx++}`)
values.push(key === "options" && body[key] ? JSON.stringify(body[key]) : body[key])
}
}
if (fields.length === 0) return NextResponse.json({ error: "No fields to update" }, { status: 400 })
fields.push(`updated_at = NOW()`)
values.push(body.id)
await query(`UPDATE custom_field_definitions SET ${fields.join(", ")} WHERE id = $${idx}`, values)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Custom fields PATCH error:", error)
const message = error instanceof Error ? error.message : "Failed to update custom field"
return NextResponse.json({ error: message }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
await getSessionUser()
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 custom_field_definitions WHERE id = $1", [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Custom fields DELETE error:", error)
const message = error instanceof Error ? error.message : "Failed to delete custom field"
return NextResponse.json({ error: message }, { status: 500 })
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const result = await query(
`SELECT c.id, c.customer_type,
ic.first_name, ic.last_name,
cc.company_name
FROM customers c
LEFT JOIN individual_customers ic ON ic.customer_id = c.id
LEFT JOIN company_customers cc ON cc.customer_id = c.id
WHERE c.deleted_at IS NULL
ORDER BY COALESCE(cc.company_name, ic.last_name || ', ' || ic.first_name)
LIMIT $1`,
[limit]
)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Customers API error:", error)
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)
} catch (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 })
}
}

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