Compare commits

...

109 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
JCBSComputer 1d080761fb Themes/ Ram Increased to 16GB 2026-06-29 16:38:40 +02:00
caitlin 7a722a7d7e Merge remote-tracking branch 'origin/main' into master 2026-06-29 15:38:18 +02:00
caitlin 61e9fac352 Fixed Light Mode 2026-06-29 15:26:44 +02:00
Ace 878627a6ba Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 15:08:09 +02:00
Ace 2c3a8e5333 Added qwen to serve as an repair agent. 2026-06-29 15:06:03 +02:00
Hannah_Bagga d870fb3ac7 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 14:52:13 +02:00
Hannah_Bagga f6d1d91fc6 AI Chat page looks better. Caitlin is my witness 2026-06-29 14:51:59 +02:00
TroodonEnjoyer bc3e345a27 Fix login session persistence + add admin accounts
- Fix JWT secret regenerating on every HMR cycle (globalThis cache)
- Replace unreliable response.cookies.set() with manual Set-Cookie header
- Remove jwt-secret.ts abstraction — JWT_SECRET back inline in auth.ts
- Sidebar logo links directly to /dashboard instead of server-redirect /
- Add 3 admin users to seed: ewan/caitlin/dillen (ADMIN role)
- Fix bcrypt hash corruption from PowerShell $ interpolation
- Remove plaintext passwords from codebase
- Fix audit_password_change trigger UUID type error
2026-06-29 14:42:16 +02:00
JCBSComputer 54b19123ca Merge branch 'main' of https://git.coastit.co.za/CRM_ENVIROMENT/Newbie_CRM 2026-06-29 13:50:54 +02:00
caitlin 3c0e7d76ca Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 10:42:58 +02:00
TroodonEnjoyer 39fb39db12 Fixed the login again? 2026-06-26 22:30:54 +02:00
Ace cd37d5a987 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 21:36:17 +02:00
Ace d138c60203 Fixing calander 2026-06-26 21:36:09 +02:00
caitlin 4d7a59c278 Changes in lightmode and dark mode header 2026-06-26 21:31:20 +02:00
TroodonEnjoyer aac9817ee7 Fix double login: set cookie on response object, invalidate sessions on dev restart
- Move cookie setting from cookies().set() to response.cookies.set() in
  login/logout routes (Next.js 15 ambient cookie merge is unreliable)
- Generate random JWT_SECRET at module scope in auth.ts for dev mode so
  every server start invalidates all prior sessions
- Replace blank page on auth failure with redirect to /login in dashboard layout
- Preserve ?redirect= param from middleware in login form
- Wrap login page in Suspense boundary for useSearchParams()

Fixes: double-login bug, session persistence across restarts, blank page on
session expiry
2026-06-26 21:29:36 +02:00
Hannah_Bagga fa97abb5b6 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 20:59:21 +02:00
Hannah_Bagga 3998285fd5 Fixed the gif picker just need to add in gifs 2026-06-26 20:58:57 +02:00
Hannah_Bagga 4c1db42873 Chat page fits whole screen now 2026-06-26 20:46:20 +02:00
Hannah_Bagga c428435f2f Removed video call, fixed logo, CRM changes with color theme 2026-06-26 20:30:46 +02:00
caitlin 886a7efe91 Ficed the leads per month in dev account 2026-06-26 19:52:02 +02:00
TroodonEnjoyer 1b5f244f28 Login possible fix and something else I can't remember 2026-06-26 19:39:45 +02:00
Hannah_Bagga 46a3216386 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:37 +02:00
Ace 8aba5c9ef4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:05 +02:00
Ace 06181625c3 AI back please test out and tell me added, filter words for AI! 2026-06-26 19:31:07 +02:00
caitlin 08eba574f8 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:27:22 +02:00
Hannah_Bagga a876f2d769 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:23:02 +02:00
caitlin fbb37d6777 Fixed sidebar colur and Cpu and Ram 2026-06-26 19:22:57 +02:00
Hannah_Bagga 214c4a1852 Spiderman Theme Fixed 2026-06-26 19:22:54 +02:00
Ace c669ec0d04 added browser use for AI 2026-06-26 19:18:01 +02:00
Ace bf6fbe3ee4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 18:47:54 +02:00
TroodonEnjoyer bccba1434b ATTEMPTED to fix my databse stuff, security etc, and a Issue Cait had 2026-06-26 18:37:28 +02:00
246 changed files with 20252 additions and 1388 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Build & Auto-Repair
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Playwright
run: npx playwright install chromium firefox
- name: Build check
id: build
continue-on-error: true
run: npx tsc --noEmit
- name: Run self-healing setup
if: steps.build.outcome == 'failure'
run: node scripts/setup.mjs --self-heal
- name: Run code repair agent
if: steps.build.outcome == 'failure'
env:
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
run: node scripts/code-repair-agent.mjs --ci
- name: Re-check build after repair
id: rebuild
continue-on-error: true
run: npx tsc --noEmit
- name: Commit fixes
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
run: |
git config user.name "CRM Repair Bot"
git config user.email "bot@coastit.co.za"
git add -A
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
git push
- name: Build failed — report
if: steps.rebuild.outcome == 'failure'
run: |
echo "::error::Build still failing after auto-repair. Manual intervention required."
exit 1
+42
View File
@@ -0,0 +1,42 @@
{
"version": 2,
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
"templates": {
"data/stickers": {
"template": "stickers.ts",
"description": "Emoji-based sticker packs for media picker"
},
"data/constants": {
"template": "constants.ts",
"description": "App-wide constants (lead statuses, roles, event types)"
},
"lib/utils": {
"template": "utils.ts",
"description": "Utility functions (cn, formatters, helpers)"
},
"types/index": {
"template": "types-index.ts",
"description": "Core type definitions (User, Lead, DashboardStats)"
},
"hooks/use-media": {
"template": "hooks/use-media.ts",
"description": "Media query hook"
},
"hooks/use-debounce": {
"template": "hooks/use-debounce.ts",
"description": "Debounce hook"
},
"hooks/use-local-storage": {
"template": "hooks/use-local-storage.ts",
"description": "LocalStorage hook with SSR safety"
}
},
"fallbackTemplates": {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts"
}
}
@@ -0,0 +1,7 @@
// ── Generic Component Template ────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/components/* imports. Customize as needed.
export default function GenericComponent() {
return <div />;
}
+54
View File
@@ -0,0 +1,54 @@
// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
export const EVENT_STATUSES = [
"scheduled",
"completed",
"cancelled",
"rescheduled",
] as const;
export const AI_MODELS = {
chat: "llama3.2:3b",
scraper: "dolphin-llama3:8b",
coder: "qwen2.5-coder:1.5b-base",
} as const;
export const PAGINATION = {
defaultLimit: 50,
maxLimit: 200,
} as const;
export const DATE_FORMATS = {
short: "MMM d, yyyy",
long: "MMMM d, yyyy",
time: "h:mm a",
datetime: "MMM d, yyyy h:mm a",
} as const;
@@ -0,0 +1,17 @@
// ── Generic Data Module Template ─────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/data/* imports. Customize as needed.
export interface DataItem {
id: string;
name: string;
[key: string]: unknown;
}
export function getDataItems(): DataItem[] {
return [];
}
export function getDataItem(id: string): DataItem | undefined {
return undefined;
}
@@ -0,0 +1,10 @@
// ── Generic Hook Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/hooks/* imports. Customize as needed.
import { useState, useEffect } from "react";
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
const [value, setValue] = useState(initialValue);
return [value, setValue];
}
@@ -0,0 +1,15 @@
// ── useDebounce Hook ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
@@ -0,0 +1,29 @@
// ── useLocalStorage Hook ──────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
if (item) setStoredValue(JSON.parse(item));
} catch {
// corrupted data — use initial value
}
}, [key]);
const setValue = (value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch {
// storage full or disabled
}
};
return [storedValue, setValue];
}
@@ -0,0 +1,18 @@
// ── useMedia Hook ─────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useMedia(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
@@ -0,0 +1,7 @@
// ── Generic Lib Module Template ──────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/lib/* imports. Customize as needed.
export function libFunction(): void {
// Placeholder
}
+95
View File
@@ -0,0 +1,95 @@
// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
const reactionStickers: Sticker[] = [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
];
const animalStickers: Sticker[] = [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
];
const foodStickers: Sticker[] = [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🍰" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
];
const activityStickers: Sticker[] = [
{ id: "football", name: "Football", emoji: "⚽" },
{ id: "basketball", name: "Basketball", emoji: "🏀" },
{ id: "tennis", name: "Tennis", emoji: "🎾" },
{ id: "running", name: "Running", emoji: "🏃" },
{ id: "swimming", name: "Swimming", emoji: "🏊" },
{ id: "cycling", name: "Cycling", emoji: "🚴" },
{ id: "gym", name: "Gym", emoji: "🏋️" },
{ id: "yoga", name: "Yoga", emoji: "🧘" },
{ id: "music", name: "Music", emoji: "🎵" },
{ id: "party", name: "Party", emoji: "🎉" },
{ id: "gift", name: "Gift", emoji: "🎁" },
{ id: "trophy", name: "Trophy", emoji: "🏆" },
];
const stickerPacks: StickerPack[] = [
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
{ id: "animals", name: "Animals", stickers: animalStickers },
{ id: "food", name: "Food & Drink", stickers: foodStickers },
{ id: "activities", name: "Activities", stickers: activityStickers },
];
export function getStickerPacks(): StickerPack[] {
return stickerPacks;
}
export function getStickerPack(id: string): StickerPack | undefined {
return stickerPacks.find((p) => p.id === id);
}
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
const pack = getStickerPack(packId);
return pack?.stickers.find((s) => s.id === stickerId);
}
@@ -0,0 +1,10 @@
// ── Generic Types Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/types/* imports. Customize as needed.
export type GenericType = Record<string, unknown>;
export interface GenericInterface {
id: string;
[key: string]: unknown;
}
+141
View File
@@ -0,0 +1,141 @@
// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus =
| "new"
| "contacted"
| "qualified"
| "proposal"
| "won"
| "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
monthlyBreakdown: {
label: string;
total: number;
new: number;
contacted: number;
qualified: number;
proposal: number;
won: number;
lost: number;
}[];
leadsPerMonth: { label: string; leads: number; won: number }[];
recentLeads: Lead[];
statusDistribution: { name: string; value: number; color: string }[];
periodLabel: string;
trends: Record<string, { pct: number; up: boolean }>;
}
export interface Note {
id: string;
leadId: string;
userId: string;
authorName: string;
authorAvatar: string;
authorRole: UserRole;
note: string;
createdAt: string;
updatedAt: string;
}
export type NotificationType =
| "lead_created"
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added"
| "event_scheduled";
export interface Notification {
id: string;
type: NotificationType;
title: string;
description: string;
timestamp: string;
read: boolean;
link?: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string;
content: string;
timestamp: string;
}
export interface Conversation {
id: string;
participants: { id: string; name: string; avatar: string; role: string }[];
lastMessage: string;
lastMessageTime: string;
unread: number;
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "email" | "task" | "note";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
developerId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
description: string | null;
participantNotes: string | null;
eventType: EventType;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string;
}
@@ -0,0 +1,7 @@
// ── Generic Utils Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/utils/* imports. Customize as needed.
export function utilsFunction(): void {
// Placeholder
}
+76
View File
@@ -0,0 +1,76 @@
// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
return fn().catch((err) =>
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
);
}
+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)"]
}
}
}
+518
View File
@@ -0,0 +1,518 @@
.dark.theme-cosmic {
--background: 260 30% 8%;
--foreground: 210 40% 98%;
--card: 260 25% 12%;
--card-foreground: 210 40% 98%;
--popover: 260 25% 12%;
--popover-foreground: 210 40% 98%;
--primary: 280 60% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 260 20% 20%;
--secondary-foreground: 210 40% 98%;
--muted: 260 20% 18%;
--muted-foreground: 260 10% 60%;
--accent: 280 60% 55%;
--accent-foreground: 210 40% 98%;
--destructive: 0 70% 50%;
--destructive-foreground: 210 40% 98%;
--border: 260 20% 22%;
--input: 260 20% 22%;
--ring: 280 60% 55%;
--radius: 0.5rem;
--sidebar: 260 30% 8%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 280 60% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 280 60% 55%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 260 20% 20%;
--sidebar-ring: 280 60% 55%;
}
.light.theme-cosmic {
--background: 0 0% 98%;
--foreground: 260 30% 12%;
--card: 0 0% 100%;
--card-foreground: 260 30% 12%;
--popover: 0 0% 100%;
--popover-foreground: 260 30% 12%;
--primary: 280 50% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 260 20% 92%;
--secondary-foreground: 260 30% 20%;
--muted: 260 15% 92%;
--muted-foreground: 260 10% 45%;
--accent: 280 50% 50%;
--accent-foreground: 0 0% 100%;
--destructive: 0 80% 50%;
--destructive-foreground: 0 0% 100%;
--border: 260 15% 85%;
--input: 260 15% 85%;
--ring: 280 50% 50%;
--radius: 0.5rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 260 30% 12%;
--sidebar-primary: 280 50% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 280 50% 50%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 260 15% 85%;
--sidebar-ring: 280 50% 50%;
}
.dark.theme-cosmic body {
background: transparent
radial-gradient(ellipse 120% 70% at 20% 30%, rgba(100, 60, 200, 0.25) 0%, transparent 60%),
radial-gradient(ellipse 80% 60% at 80% 70%, rgba(0, 200, 100, 0.15) 0%, transparent 50%),
radial-gradient(ellipse 60% 50% at 60% 20%, rgba(200, 50, 180, 0.2) 0%, transparent 50%),
radial-gradient(ellipse 100% 80% at 40% 80%, rgba(50, 100, 220, 0.15) 0%, transparent 50%);
background-size: 200% 200%;
animation: cosmic-drift 90s linear infinite;
}
.dark.theme-cosmic body > div {
background: transparent !important;
}
.light.theme-cosmic body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 100% 60% at 15% 25%, rgba(0, 200, 80, 0.06) 0%, transparent 60%),
radial-gradient(ellipse 70% 50% at 80% 70%, rgba(100, 60, 200, 0.04) 0%, transparent 50%),
radial-gradient(ellipse 50% 40% at 60% 20%, rgba(0, 200, 80, 0.05) 0%, transparent 50%);
}
@keyframes cosmic-drift {
0% { background-position: 0% 0%; }
50% { background-position: 100% 100%; }
100% { background-position: 0% 0%; }
}
.dark.theme-cosmic .text-glow {
text-shadow: 0 0 10px hsl(120, 100%, 45%), 0 0 30px hsl(120, 100%, 45%), 0 0 60px rgba(0, 220, 100, 0.4);
}
.light.theme-cosmic .text-glow {
text-shadow: 0 0 8px hsl(280, 50%, 50%), 0 0 20px hsl(280, 50%, 50%), 0 0 40px rgba(200, 50, 200, 0.3);
}
.dark.theme-cosmic h1, .dark.theme-cosmic h2, .dark.theme-cosmic h3 {
background: linear-gradient(135deg, hsl(120, 100%, 45%), hsl(170, 80%, 50%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.light.theme-cosmic h1, .light.theme-cosmic h2, .light.theme-cosmic h3 {
background: linear-gradient(135deg, hsl(280, 50%, 45%), hsl(280, 60%, 55%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark.theme-cosmic a {
color: hsl(120, 90%, 50%);
text-decoration: none;
transition: color 0.2s;
}
.light.theme-cosmic a {
color: hsl(280, 50%, 45%);
text-decoration: none;
transition: color 0.2s;
}
.dark.theme-cosmic a:hover {
color: hsl(170, 80%, 50%);
text-shadow: 0 0 8px rgba(0, 220, 100, 0.4);
}
.light.theme-cosmic a:hover {
color: hsl(280, 60%, 55%);
}
.dark.theme-cosmic blockquote {
border-left: 3px solid hsl(280, 60%, 55%);
color: hsl(260, 15%, 70%);
font-style: italic;
}
.light.theme-cosmic blockquote {
border-left: 3px solid hsl(280, 50%, 50%);
color: hsl(260, 10%, 45%);
font-style: italic;
}
.dark.theme-cosmic code {
color: hsl(120, 90%, 50%);
background: rgba(100, 60, 200, 0.15);
border: 1px solid rgba(100, 60, 200, 0.2);
}
.light.theme-cosmic code {
color: hsl(280, 50%, 45%);
background: rgba(100, 60, 200, 0.06);
border: 1px solid rgba(100, 60, 200, 0.15);
}
.dark.theme-cosmic pre {
background: rgba(13, 8, 32, 0.8);
border: 1px solid rgba(100, 60, 200, 0.15);
}
.light.theme-cosmic pre {
background: rgba(0, 200, 80, 0.03);
border: 1px solid rgba(0, 200, 80, 0.1);
}
.dark.theme-cosmic hr {
border-color: rgba(100, 60, 200, 0.2);
}
.light.theme-cosmic hr {
border-color: rgba(0, 200, 80, 0.15);
}
.dark.theme-cosmic table th {
color: hsl(120, 90%, 50%);
}
.light.theme-cosmic table th {
color: hsl(280, 50%, 45%);
}
.dark.theme-cosmic table td {
border-color: rgba(100, 60, 200, 0.15);
}
.light.theme-cosmic table td {
border-color: rgba(0, 200, 80, 0.1);
}
.dark.theme-cosmic ::selection {
background: rgba(200, 50, 200, 0.25);
color: hsl(0, 0%, 100%);
}
.light.theme-cosmic ::selection {
background: rgba(200, 50, 200, 0.15);
color: hsl(0, 0%, 0%);
}
.dark.theme-cosmic input::placeholder,
.dark.theme-cosmic textarea::placeholder {
color: hsl(260, 10%, 40%);
}
.light.theme-cosmic input::placeholder,
.light.theme-cosmic textarea::placeholder {
color: hsl(260, 10%, 65%);
}
.dark.theme-cosmic .page-header-title {
color: hsl(120, 90%, 50%);
}
.light.theme-cosmic .page-header-title {
color: hsl(280, 50%, 45%);
}
.dark.theme-cosmic .stat-card-value {
color: hsl(120, 90%, 50%);
}
.light.theme-cosmic .stat-card-value {
color: hsl(280, 50%, 45%);
}
.dark.theme-cosmic .badge {
color: hsl(120, 90%, 50%);
}
.light.theme-cosmic .badge {
color: hsl(280, 50%, 45%);
}
.dark.theme-cosmic .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(280, 60%, 55%), hsl(280, 70%, 65%), hsl(280, 60%, 55%)) !important;
}
.dark.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, 100%, 45%), hsl(120, 100%, 55%), hsl(120, 100%, 45%)) !important;
}
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(280, 60%, 55%, 0.1) !important;
}
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(120, 100%, 45%, 0.1) !important;
}
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
color: hsl(280, 60%, 55%) !important;
}
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(120, 100%, 45%) !important;
}
.dark.theme-cosmic .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(280, 60%, 55%, 0.25), transparent) !important;
}
.dark.theme-cosmic .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(120, 100%, 45%, 0.25), transparent) !important;
}
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(280, 60%, 55%), hsl(120, 100%, 45%)) !important;
}
.light.theme-cosmic .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(280, 50%, 50%), hsl(280, 60%, 60%), hsl(280, 50%, 50%)) !important;
}
.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);
}
+117
View File
@@ -0,0 +1,117 @@
{
"theme": {
"id": "cosmic",
"name": "Cosmic",
"description": "Dark mode: green text with purple highlights + glowing NEBULA header; Light mode: purple text + purple NEBULA header",
"type": "dual",
"default": false
},
"modes": {
"dark": {
"colors": {
"background": "hsl(260, 30%, 8%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(260, 25%, 12%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(260, 25%, 12%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(280, 60%, 55%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(260, 20%, 20%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(260, 20%, 18%)",
"mutedForeground": "hsl(260, 10%, 60%)",
"accent": "hsl(280, 60%, 55%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 70%, 50%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(260, 20%, 22%)",
"input": "hsl(260, 20%, 22%)",
"sidebar": "hsl(260, 30%, 8%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(280, 60%, 55%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(280, 60%, 55%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(260, 20%, 22%)",
"sidebarRing": "hsl(280, 60%, 55%)"
},
"textColors": {
"heading": "linear-gradient(135deg, #00E060, #2dd4bf)",
"body": "#e8e8ef",
"muted": "#8a7faa",
"link": "#00E060",
"linkHover": "#2dd4bf",
"code": "#00E060",
"placeholder": "#554b70"
},
"backgroundImage": null,
"backgroundGradients": [
"rgba(100, 60, 200, 0.15) at 20% 30%",
"rgba(0, 220, 100, 0.08) at 80% 70%",
"rgba(200, 50, 180, 0.10) at 60% 20%",
"rgba(50, 100, 220, 0.08) at 40% 80%"
]
},
"light": {
"colors": {
"background": "hsl(0, 0%, 98%)",
"foreground": "hsl(260, 30%, 12%)",
"card": "hsl(0, 0%, 100%)",
"cardForeground": "hsl(260, 30%, 12%)",
"popover": "hsl(0, 0%, 100%)",
"popoverForeground": "hsl(260, 30%, 12%)",
"primary": "hsl(280, 50%, 50%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(260, 20%, 92%)",
"secondaryForeground": "hsl(260, 30%, 20%)",
"muted": "hsl(260, 15%, 92%)",
"mutedForeground": "hsl(260, 10%, 45%)",
"accent": "hsl(280, 50%, 50%)",
"accentForeground": "hsl(0, 0%, 100%)",
"destructive": "hsl(0, 80%, 50%)",
"destructiveForeground": "hsl(0, 0%, 100%)",
"border": "hsl(260, 15%, 85%)",
"input": "hsl(260, 15%, 85%)",
"sidebar": "hsl(0, 0%, 100%)",
"sidebarForeground": "hsl(260, 30%, 12%)",
"sidebarPrimary": "hsl(280, 50%, 50%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(280, 50%, 50%)",
"sidebarAccentForeground": "hsl(0, 0%, 100%)",
"sidebarBorder": "hsl(260, 15%, 85%)",
"sidebarRing": "hsl(280, 50%, 50%)"
},
"textColors": {
"heading": "linear-gradient(135deg, #7c3aed, #a855f7)",
"body": "#1a0a2e",
"muted": "#6b5b7a",
"link": "#7c3aed",
"linkHover": "#a855f7",
"code": "#7c3aed",
"placeholder": "#a8a0b0"
},
"backgroundImage": null,
"backgroundGradients": [
"rgba(0, 200, 80, 0.06) at 15% 25%",
"rgba(100, 60, 200, 0.04) at 80% 70%",
"rgba(0, 200, 80, 0.05) at 60% 20%"
]
}
},
"borderRadius": "0.5rem",
"typography": {
"fontFamily": "Inter, sans-serif",
"headingFont": "Inter, sans-serif"
},
"keyColors": {
"primary": "#a855f7 (dark) / #7c3aed (light)",
"background": "#0d0820 (dark) / #fafafa (light)",
"card": "#16102e (dark) / #ffffff (light)",
"sidebar": "#0d0820 (dark) / #ffffff (light)",
"border": "#2a2240 (dark) / #d4d0dc (light)",
"text": "#e8e8ef (dark) / #1a0a2e (light)",
"mutedText": "#8a7faa (dark) / #6b5b7a (light)",
"headingGradient": "#00E060 → #2dd4bf (dark) / #7c3aed → #a855f7 (light)"
}
}
+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" }
}
}
+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);
}
+69
View File
@@ -0,0 +1,69 @@
{
"name": "Cyberpunk",
"description": "Neon-cyberpunk theme with cyan grid, magenta accents, and futuristic glow",
"dark": {
"colors": {
"background": "240 30% 4%",
"foreground": "180 50% 95%",
"primary": "180 100% 50%",
"secondary": "280 80% 55%",
"accent": "280 80% 55%",
"muted": "240 15% 15%",
"border": "180 30% 20%",
"ring": "180 100% 50%",
"card": "240 25% 8%",
"popover": "240 25% 8%"
},
"textColors": {
"heading": "linear-gradient(135deg, hsl(180,100%,50%), hsl(280,80%,55%))",
"link": "hsl(180, 100%, 55%)",
"link-hover": "hsl(280, 80%, 60%)",
"code": "hsl(180, 100%, 55%)",
"blockquote": "hsl(180, 20%, 70%)",
"table-header": "hsl(180, 100%, 55%)",
"stat-card-value": "hsl(180, 100%, 55%)",
"badge": "hsl(180, 100%, 55%)",
"page-header-title": "hsl(180, 100%, 55%)",
"text-glow": "0 0 10px hsl(180,100%,50%), 0 0 30px hsl(180,100%,50%), 0 0 60px rgba(0,255,255,0.5)"
},
"backgrounds": {
"body": "Deep navy-black with cyan grid lines, cyan and magenta neon glow gradients, and glowing dot particles",
"grid-color": "rgba(0, 255, 255, 0.03)",
"grid-size": "40px",
"glow-colors": ["rgba(0, 255, 255, 0.08)", "rgba(255, 0, 255, 0.08)"]
},
"effects": {
"body-background-animation": "neon-drift 20s ease-in-out infinite alternate"
}
},
"light": {
"colors": {
"background": "180 20% 96%",
"foreground": "240 40% 10%",
"primary": "180 100% 40%",
"secondary": "280 60% 50%",
"accent": "280 60% 50%",
"muted": "180 15% 88%",
"border": "180 15% 82%",
"ring": "180 100% 40%",
"card": "0 0% 100%",
"popover": "0 0% 100%"
},
"textColors": {
"heading": "linear-gradient(135deg, hsl(180,100%,35%), hsl(280,60%,45%))",
"link": "hsl(180, 100%, 35%)",
"link-hover": "hsl(280, 60%, 50%)",
"code": "hsl(280, 60%, 45%)",
"blockquote": "hsl(240, 10%, 45%)",
"table-header": "hsl(180, 100%, 35%)",
"stat-card-value": "hsl(180, 100%, 35%)",
"badge": "hsl(180, 100%, 35%)",
"page-header-title": "hsl(180, 100%, 35%)",
"text-glow": "0 0 8px hsl(180,100%,40%), 0 0 20px hsl(180,100%,40%), 0 0 40px rgba(0,200,200,0.3)"
},
"backgrounds": {
"body": "Light with subtle cyan and magenta gradient washes and faint grid lines",
"glow-colors": ["rgba(0, 200, 200, 0.05)", "rgba(200, 0, 200, 0.04)"]
}
}
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
/* ============================================================================
Default Theme — Clean, Professional SaaS Design Tokens
This is the standard CRM appearance. No Spidey branding or assets.
============================================================================ */
.theme-default {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 80% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 215 25% 22%;
--input: 215 25% 22%;
--ring: 210 80% 52%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 210 80% 52%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 215 25% 22%;
--sidebar-ring: 210 80% 52%;
}
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)"]
}
}
}
+196 -12
View File
@@ -1,17 +1,16 @@
/* ============================================================================
Spidey Theme Baseline Design Tokens
This is the current website appearance captured as a reusable CSS theme.
============================================================================ */
@import url("./cosmic-theme.css");
@import url("./cyberpunk-theme.css");
@import url("./cyber2-theme.css");
@import url("./pumpkin-theme.css");
@import url("./bw-theme.css");
.theme-spidey {
.dark.theme-spidey {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 0 100% 53%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
@@ -22,19 +21,46 @@
--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%;
}
.theme-spidey body::before {
.light.theme-spidey {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 0 100% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 0 100% 45%;
--radius: 0.5rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 0 100% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 96%;
--sidebar-accent-foreground: 222.2 47.4% 11.2%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 0 100% 45%;
}
.dark.theme-spidey body::before {
content: "";
position: fixed;
inset: 0;
@@ -67,3 +93,161 @@
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
.light.theme-spidey body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
repeating-conic-gradient(
from 10deg,
transparent 0deg 10deg,
rgba(220, 38, 38, 0.03) 10deg 12deg
),
repeating-radial-gradient(
circle at 50% 50%,
transparent 0,
transparent 30px,
rgba(37, 99, 235, 0.02) 30px,
transparent 32px
),
radial-gradient(1.5px 1.5px at 10% 20%, rgba(220,38,38,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 30% 80%, rgba(37,99,235,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 50% 30%, rgba(220,38,38,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 70% 60%, rgba(37,99,235,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 85% 15%, rgba(220,38,38,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 20% 50%, rgba(37,99,235,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 65% 85%, rgba(220,38,38,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 40% 10%, rgba(37,99,235,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 90% 75%, rgba(220,38,38,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 5% 60%, rgba(37,99,235,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 55% 55%, rgba(220,38,38,0.1) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
@keyframes drift {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
.dark.theme-spidey .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, 100%, 53%), hsl(0, 100%, 65%), hsl(0, 100%, 53%)) !important;
}
.dark.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%, 60%), hsl(217, 91%, 70%), hsl(217, 91%, 60%)) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(0, 100%, 53%, 0.1) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(217, 91%, 60%, 0.1) !important;
}
.dark.theme-spidey .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, 100%, 53%) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(217, 91%, 60%) !important;
}
.dark.theme-spidey .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, 100%, 53%, 0.25), transparent) !important;
}
.dark.theme-spidey .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(217, 91%, 60%, 0.25), transparent) !important;
}
.dark.theme-spidey .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, 100%, 53%), hsl(217, 91%, 60%)) !important;
}
.light.theme-spidey .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, 100%, 45%), hsl(0, 100%, 55%), hsl(0, 100%, 45%)) !important;
}
.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);
}
+71 -36
View File
@@ -2,38 +2,73 @@
"theme": {
"id": "spidey",
"name": "Spidey",
"description": "Current website appearance — dark theme with red accents",
"type": "dark",
"description": "Dark mode with red accents and web patterns; light mode with clean red-blue accents",
"type": "dual",
"default": true
},
"colors": {
"background": "hsl(222.2, 84%, 4.9%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(222.2, 84%, 4.9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 4.9%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
"accent": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 62.8%, 30.6%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)",
"input": "hsl(217.2, 32.6%, 17.5%)",
"ring": "hsl(0, 100%, 53%)",
"sidebar": "hsl(222.2, 84%, 4.9%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(0, 100%, 53%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)"
"modes": {
"dark": {
"colors": {
"background": "hsl(222.2, 84%, 4.9%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(222.2, 84%, 4.9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 4.9%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
"accent": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 62.8%, 30.6%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)",
"input": "hsl(217.2, 32.6%, 17.5%)",
"ring": "hsl(0, 100%, 53%)",
"sidebar": "hsl(222.2, 84%, 4.9%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(0, 100%, 53%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)"
}
},
"light": {
"colors": {
"background": "hsl(0, 0%, 100%)",
"foreground": "hsl(222.2, 84%, 4.9%)",
"card": "hsl(0, 0%, 100%)",
"cardForeground": "hsl(222.2, 84%, 4.9%)",
"popover": "hsl(0, 0%, 100%)",
"popoverForeground": "hsl(222.2, 84%, 4.9%)",
"primary": "hsl(0, 100%, 45%)",
"primaryForeground": "hsl(0, 0%, 100%)",
"secondary": "hsl(210, 40%, 96%)",
"secondaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"muted": "hsl(210, 40%, 96%)",
"mutedForeground": "hsl(215.4, 16.3%, 46.9%)",
"accent": "hsl(210, 40%, 96%)",
"accentForeground": "hsl(222.2, 47.4%, 11.2%)",
"destructive": "hsl(0, 84.2%, 60.2%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(214.3, 31.8%, 91.4%)",
"input": "hsl(214.3, 31.8%, 91.4%)",
"ring": "hsl(0, 100%, 45%)",
"sidebar": "hsl(0, 0%, 100%)",
"sidebarForeground": "hsl(222.2, 84%, 4.9%)",
"sidebarPrimary": "hsl(0, 100%, 45%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(210, 40%, 96%)",
"sidebarAccentForeground": "hsl(222.2, 47.4%, 11.2%)",
"sidebarBorder": "hsl(214.3, 31.8%, 91.4%)",
"sidebarRing": "hsl(0, 100%, 45%)"
}
}
},
"borderRadius": "0.5rem",
"typography": {
@@ -42,11 +77,11 @@
},
"keyColors": {
"primary": "#FF0000",
"background": "#0a0a0f",
"card": "#141414",
"sidebar": "#0a0a0f",
"border": "#1a1a24",
"text": "#e8e8ef",
"mutedText": "#888888"
"background": "#0a0a0f (dark) / #ffffff (light)",
"card": "#141414 (dark) / #ffffff (light)",
"sidebar": "#0a0a0f (dark) / #ffffff (light)",
"border": "#1a1a24 (dark) / #e2e4ea (light)",
"text": "#e8e8ef (dark) / #0a0a1a (light)",
"mutedText": "#888888 (dark) / #6b7280 (light)"
}
}
-64
View File
@@ -1,64 +0,0 @@
/* ============================================================================
Starforce Theme Dark cosmic with crimson accents and neon green glow
Captures the current starfield + drift + glow effects used live.
============================================================================ */
.theme-starforce {
--background: 0 0% 10%;
--foreground: 210 40% 98%;
--card: 0 0% 9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 11%;
--popover-foreground: 210 40% 98%;
--primary: 0 100% 53%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--radius: 0.5rem;
--sidebar: 0 0% 11%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 145 65% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.theme-starforce body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(1px 1px at 10% 20%, rgba(100,100,120,0.4) 0%, transparent 100%),
radial-gradient(1px 1px at 20% 40%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 30% 10%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 40% 60%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 50% 80%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 60% 30%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 70% 50%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 80% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 90% 15%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 15% 70%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 25% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 35% 35%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 45% 15%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 55% 75%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 65% 55%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 75% 25%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 85% 65%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 95% 45%, rgba(80,80,100,0.5) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
-59
View File
@@ -1,59 +0,0 @@
{
"theme": {
"id": "starforce",
"name": "Starforce",
"description": "Dark cosmic theme with crimson accents, starfield background, and neon green glow effects",
"type": "dark",
"default": true
},
"colors": {
"background": "hsl(0, 0%, 10%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(0, 0%, 9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 11%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
"accent": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 62.8%, 30.6%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)",
"input": "hsl(217.2, 32.6%, 17.5%)",
"ring": "hsl(0, 100%, 53%)",
"sidebar": "hsl(0, 0%, 11%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(145, 65%, 50%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)"
},
"borderRadius": "0.5rem",
"typography": {
"fontFamily": "Inter, sans-serif",
"headingFont": "Inter, sans-serif"
},
"keyColors": {
"primary": "#FF0000",
"background": "#1a1a1a",
"card": "#171717",
"sidebar": "#1c1c1c",
"border": "#2a2a35",
"text": "#e8e8ef",
"mutedText": "#888888",
"glow": "#39ff14"
},
"effects": {
"starfield": true,
"starTwinkle": true,
"starDrift": true,
"textGlow": "#39ff14"
}
}
+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" }
}
}
+225 -74
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner"
import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote,
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone,
chat: Video,
follow_up: Clock,
demo: Calendar,
other: Calendar,
website_creation: Globe,
}
function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting")
const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
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(() => {})
}, [user, router])
useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr))
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth])
const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null)
setFormTitle("")
setFormDescription("")
setFormType("meeting")
setFormType("website_creation")
setFormDuration("30")
setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00")
@@ -184,9 +196,14 @@ export default function CalendarPage() {
setFormDescription(event.description || "")
setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true)
}
@@ -198,23 +215,35 @@ export default function CalendarPage() {
setFormSaving(true)
try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
}
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res
if (editingEvent) {
@@ -235,11 +264,13 @@ export default function CalendarPage() {
const data = await res.json()
if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated")
} else {
setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created")
}
@@ -260,7 +291,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
} catch {
@@ -446,6 +477,11 @@ export default function CalendarPage() {
>
<Icon className="h-2 w-2 shrink-0 opacity-50" />
<span className="truncate font-medium">{event.title}</span>
{event.lead && (
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
{event.lead.contactName}
</span>
)}
</div>
</button>
)
@@ -464,6 +500,15 @@ export default function CalendarPage() {
})}
</div>
</div>
{/* Unscheduled projects bar */}
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
</span>
</div>
)}
</div>
</div>
@@ -534,12 +579,19 @@ export default function CalendarPage() {
{event.status}
</span>
</div>
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
<Clock className="h-3 w-3" />
{formatDate(event.startTime)}
<span className="text-muted-foreground/20">·</span>
{formatTime(event.startTime)}
</div>
{event.startTime ? (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
<Clock className="h-3 w-3" />
{formatDate(event.startTime)}
<span className="text-muted-foreground/20">·</span>
{formatTime(event.startTime)}
</div>
) : (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
<Globe className="h-3 w-3" />
Website project no schedule
</div>
)}
{event.durationMinutes && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
<Zap className="h-2.5 w-2.5" />
@@ -547,21 +599,20 @@ export default function CalendarPage() {
</div>
)}
<div className="flex items-center gap-1 mt-1">
<span className={cn(
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
event.userId === user?.id
? "bg-primary/10 text-primary ring-primary/20"
: "bg-muted text-muted-foreground/60 ring-border",
)}>
{event.userId === user?.id
? (event.participant?.name.charAt(0) || "?")
: (event.creator?.name.charAt(0) || "?")}
</span>
<span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id
? (event.participant?.name || "No participant")
: (event.creator?.name || "Unknown")}
</span>
{event.developerId === user?.id ? (
<>
<span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
{event.creator?.name.charAt(0) || "?"}
</span>
<span className="text-[10px] text-muted-foreground/60 font-medium">
Assigned by {event.creator?.name || "Unknown"}
</span>
</>
) : event.lead ? (
<span className="text-[10px] text-muted-foreground/60 font-medium">
{event.lead.contactName}{event.lead.companyName ? `${event.lead.companyName}` : ""}
</span>
) : null}
</div>
</div>
</div>
@@ -579,7 +630,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className={cn(
@@ -608,11 +659,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div>
<div className="space-y-2">
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div>
</div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +689,16 @@ export default function CalendarPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10">
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -664,8 +729,40 @@ export default function CalendarPage() {
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." rows={3} className="resize-none" />
</div>
</div>
<DialogFooter className="flex items-center justify-between border-t pt-4">
@@ -690,7 +787,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px]">
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +808,29 @@ export default function CalendarPage() {
</DialogHeader>
<div className="space-y-4">
{/* Date / Time */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
{detailEvent.startTime ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
</div>
) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */}
<div className="grid grid-cols-2 gap-3">
@@ -751,11 +855,26 @@ export default function CalendarPage() {
{/* Description */}
{detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div>
)}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */}
{detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +921,32 @@ export default function CalendarPage() {
</div>
</div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5">
@@ -836,9 +981,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && (
<>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
{detailEvent.developerId === user?.id ? (
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button>
+6 -1
View File
@@ -1,10 +1,11 @@
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other"
export type EventType = "call" | "follow_up" | "website_creation"
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
export interface CalendarEvent {
id: string
userId: string
participantId: string | null
developerId: string | null
leadId: string | null
conversationId: string | null
title: string
@@ -17,6 +18,10 @@ export interface CalendarEvent {
status: EventStatus
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null
clientName: string | null
clientEmail: string | null
clientPhone: string | null
createdAt: string
}
+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
+542 -121
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 = [
@@ -513,28 +596,84 @@ def is_offer(text: str) -> bool:
# Each run picks 2-4 random queries to vary behavior and reduce detection.
FB_SEARCHES = [
"looking for web developer",
"need a website designed",
"need website built South Africa",
"need someone to build my website",
"need web designer",
"looking for someone to create website",
"need ecommerce website built",
"want to hire web developer",
"need wordpress website",
"I need a website for my business",
"need a site for my business",
"looking for website designer South Africa",
"need website for my small business",
"who can build me a website",
"want to create a website for my business",
"looking for affordable web design",
"need a web developer South Africa",
"recommend a web developer",
"I need a website for my",
"need a site for my business",
"looking for web designer",
"help me build a website",
"need someone to design my website",
"looking for web designer near me",
"need a website for my startup",
"anyone know a web developer",
"need a web developer for my project",
"need website for my startup",
"want a website for my company",
"need a new website for my",
"looking for web developer for my business",
"need ecommerce website for my",
"looking for affordable web design",
"need wordpress website",
"who can design a website for my",
"looking for someone to create a website",
"recommendations for a web designer",
"I need a website",
"I need a website built",
"need wordpress site",
"need a custom website",
"I need a new website",
"need an online store",
"need an online shop",
"need website help",
"who can help me with my website",
"looking for a website designer",
"need a shopify website",
"looking for website design",
"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},
@@ -649,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
@@ -672,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')
@@ -706,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:
@@ -721,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]),
@@ -745,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
@@ -797,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--ÿ]/.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;
}''')
@@ -882,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)")
@@ -905,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, []
@@ -957,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 = ""
@@ -999,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"))
@@ -1021,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"}
@@ -1075,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
@@ -1128,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:
@@ -1138,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"}
@@ -1200,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
@@ -1360,6 +1682,8 @@ async def ask_ollama(prompt: str) -> str:
async def classify_leads(results: list[dict]) -> list[dict]:
if not results:
return []
# ── 1. AI classification ─────────────────────────────────────────
briefs = [r["title"][:200] for r in results]
prompt = f"""Classify each post as LEAD or NOT.
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
@@ -1371,11 +1695,13 @@ 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))}
Return a JSON array like ["yes","no","yes"] matching the order above."""
ai_succeeded = False
ai_leads: list[dict] = []
try:
raw = await ask_ollama(prompt)
raw = raw.strip()
@@ -1388,43 +1714,138 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
raw = raw.strip()
answers = json.loads(raw)
if isinstance(answers, list) and len(answers) == len(results):
ai_succeeded = True
filtered = []
for i, ans in enumerate(answers):
if isinstance(ans, str) and ans.lower() == 'yes':
filtered.append(results[i])
if filtered:
return filtered[:10]
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
return []
ai_leads.append(results[i])
logger.info("AI classified %d/%d as LEAD", len(ai_leads), len(results))
except Exception as e:
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
logger.warning("AI classification failed: %s", e)
if not ai_succeeded:
strict_keywords = [
"website", "web design", "web develop", "web dev",
"build my website", "build a website", "create a website",
"need web", "looking for web", "new website",
"landing page", "wordpress",
"need a website", "my website", "business website",
"need a designer", "help with my website",
"redesign", "update my website",
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
]
filtered = []
# ── 2. Keyword fallback (always runs) ────────────────────────────
web_terms = [
"website", "web design", "web develop", "web dev",
"web designer", "web developer",
"build my website", "build a website", "create a website",
"landing page", "wordpress", "ecommerce",
"my website", "business website",
"site for my", "site for my business",
"new website", "redesign my website",
"help with my website", "update my website",
"make a website", "make my website",
"website for my",
"online store", "online shop",
"build my site", "build a site",
"set up a website", "set up my website",
"custom website",
"shopify",
"my site",
"webpage", "web page",
]
request_terms = [
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
"looking", "need", "want", "help",
"who can", "i need",
"recommend", "anyone know", "anyone recommend",
"know a", "know any", "recommendation",
"suggest", "looking for recommendations",
"can anyone", "does anyone",
"dm me", "pm me", "message me",
"quote for",
"can you help",
"how much",
"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 offer_reject):
continue
keyword_leads.append(r)
# ── 3. Merge: prefer AI leads, supplement with keywords to reach 5 ──
seen_titles: set[int] = set()
merged: list[dict] = []
for r in ai_leads + keyword_leads:
key = hash(r.get('title', ''))
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:
for r in results:
key = hash(r.get('title', ''))
if key in seen_titles:
continue
t = r['title'].lower()
if not any(kw in t for kw in strict_keywords):
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 ['i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting']):
if any(kw in t for kw in offer_reject):
continue
filtered.append(r)
return filtered[:10]
return []
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]
# ══════════════════════════════════════════════════════════════════════
# FastAPI Endpoints
@@ -1541,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"}
+18 -3
View File
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
-- admin_demo / AdminAccess@2026
-- sales_demo / SalesAccess@2026
-- dev_demo / DevTesting@2026
-- ewan (password change required on first login)
-- caitlin (password change required on first login)
-- dillen (password change required on first login)
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
'Sales', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
'Dev', 'User', TRUE, TRUE)
'Dev', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
'Ewan', 'Scheepers', TRUE, TRUE),
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
'Caitlin', 'Hermanus', TRUE, TRUE),
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
'Dillen', 'van der Merwe', TRUE, TRUE)
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
@@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
-- Set created_by for other users (SUPER_ADMIN created them)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
AND created_by IS NULL;
-- ============================================================================
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING;
-- ============================================================================
@@ -595,6 +595,11 @@ WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL.
-- They will set their own passwords on first login (password_change_required=TRUE).
-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint
-- after users have set their chosen passwords.
-- ============================================================================
-- FUTURE REQUIREMENT NOTE:
-- If this system is ever exposed publicly, remove reversible password storage
@@ -0,0 +1,4 @@
ALTER TABLE scheduled_events
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
@@ -0,0 +1,8 @@
ALTER TABLE scheduled_events
DROP CONSTRAINT IF EXISTS chk_event_type,
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
ALTER TABLE scheduled_events
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
@@ -0,0 +1,2 @@
ALTER TABLE scheduled_events
ALTER COLUMN start_time DROP NOT NULL;
+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;
+34
View File
@@ -43,6 +43,9 @@ BEGIN;
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
\i 011_calendar_events.sql
\echo '=== Running 012_invites.sql (Invites) ==='
\i 012_invites.sql
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
\i 012_sent_emails.sql
@@ -63,5 +66,36 @@ BEGIN;
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
\i 017_calendar_developer.sql
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
\i 018_website_creation_type.sql
\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",
+13 -3
View File
@@ -6,18 +6,27 @@
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs",
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
"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",
"setup": "node scripts/setup.mjs",
"setup:self-heal": "node scripts/setup.mjs --self-heal",
"repair": "node scripts/code-repair-agent.mjs",
"repair:watch": "node scripts/code-repair-agent.mjs --watch",
"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",
@@ -40,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"
}
}
+346
View File
@@ -0,0 +1,346 @@
// ── Code Repair Agent ─────────────────────────────────────────────
// Phase 2: Auto-fix TypeScript build errors using qwen2.5-coder:1.5b-base
//
// Usage:
// node scripts/code-repair-agent.mjs # one-shot: fix build errors
// node scripts/code-repair-agent.mjs --watch # watch mode: re-fix on file changes
// node scripts/code-repair-agent.mjs --ci # CI mode: fix + commit
//
// Requires: Ollama running at OLLAMA_HOST (default http://localhost:11434)
// Model: qwen2.5-coder:1.5b-base
//
// ── Architecture ──────────────────────────────────────────────────
// 1. Capture build output from `npx tsc --noEmit`
// 2. Parse error locations (file, line, column, message)
// 3. Read the failing source file + surrounding context (~20 lines)
// 4. Send to qwen2.5-coder via Ollama API for fix suggestion
// 5. Apply fix if confidence > threshold (0.7)
// 6. Re-run build to verify fix
// 7. If breaking change detected (wide-reaching import changes), escalate
// 8. In --ci mode: auto-commit with [bot] prefix
import { execSync } from "node:child_process"
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const SELF = dirname(fileURLToPath(import.meta.url))
const _require = createRequire(import.meta.url)
const ROOT = resolve(SELF, "..")
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
const MAX_ITERATIONS = 5
const CONFIDENCE_THRESHOLD = 0.7
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
// ── Helpers ────────────────────────────────────────────────────────
function log(msg, level = "info") {
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
console.log(` ${prefix} [repair] ${msg}`)
}
function runTsc() {
try {
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
return { ok: true, output: out.toString() }
} catch (e) {
return { ok: false, output: e.stdout?.toString() || e.message || "" }
}
}
function parseErrors(output) {
const errors = []
// Match: src/file.ts(line,col): error TSxxxx: message
const lineRegex = /(src\/[^:]+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g
let m
while ((m = lineRegex.exec(output)) !== null) {
errors.push({
file: resolve(ROOT, m[1]),
line: parseInt(m[2], 10),
column: parseInt(m[3], 10),
message: m[4].trim(),
})
}
return errors
}
function readContext(filePath, errorLine, contextLines = 20) {
try {
const lines = readFileSync(filePath, "utf-8").split("\n")
const start = Math.max(0, errorLine - 1 - contextLines)
const end = Math.min(lines.length, errorLine - 1 + contextLines)
return {
content: lines.slice(start, end).join("\n"),
contextLine: errorLine - 1 - start, // 0-indexed line of the error in the snippet
totalLines: lines.length,
}
} catch {
return null
}
}
// ── Ollama Integration ─────────────────────────────────────────────
async function queryOllama(prompt) {
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: MODEL,
prompt,
stream: false,
options: { temperature: 0.3, num_predict: 2048 },
}),
})
if (!response.ok) throw new Error(`Ollama returned ${response.status}`)
const data = await response.json()
return data.response || ""
}
function buildRepairPrompt(filePath, errorMessage, codeContext) {
return `You are a TypeScript repair agent. Fix the following error.
FILE: ${filePath}
ERROR: ${errorMessage}
CODE CONTEXT (line ${codeContext.contextLine + 1} is the error line):
\`\`\`typescript
${codeContext.content}
\`\`\`
Respond with ONLY the fix JSON in this exact format:
{"fix": "the exact replacement code for the error line(s)", "confidence": 0.0-1.0, "explanation": "brief explanation"}
Rules:
- Keep the fix minimal and precise
- Only change what's necessary to fix the error
- If the error is a missing import, suggest the import statement
- If the error is a type mismatch, suggest the corrected code
- confidence < 0.7 will not be auto-applied
- If you cannot determine a fix, set confidence to 0`
}
function parseFixResponse(text) {
try {
// Extract JSON from response (it might have markdown fences)
const jsonMatch = text.match(/\{[\s\S]*"fix"[\s\S]*"confidence"[\s\S]*\}/)
if (!jsonMatch) return null
return JSON.parse(jsonMatch[0])
} catch {
return null
}
}
function applyFix(filePath, fixSuggestion) {
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
try {
const original = readFileSync(filePath, "utf-8")
writeFileSync(filePath, fixSuggestion.fix, "utf-8")
return { applied: true, backup: original }
} catch (e) {
return { applied: false, reason: e.message }
}
}
// ── Breaking Change Escalation ─────────────────────────────────────
function isBreakingChange(errors, fixes) {
// Heuristic: if fix touches import structure or exports, it's breaking
for (const fix of fixes) {
if (!fix || !fix.fix) continue
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
return true
}
}
return false
}
async function escalateBreakingChange(errors, fixes) {
const logFile = resolve(ROOT, "repair-escalation.log")
const timestamp = new Date().toISOString()
const entry = [
`=== Breaking Change Escalation @ ${timestamp} ===`,
`Errors: ${errors.map(e => `${e.file}:${e.line}: ${e.message}`).join("\n ")}`,
`Fixes: ${fixes.map(f => f?.fix?.slice(0, 200)).join("\n ")}`,
`Auto-apply in ${ESCALATION_TIMEOUT_MS / 60000} minutes or cancel with Ctrl+C`,
"========================================\n",
].join("\n")
appendFileSync(logFile, entry)
log(`Breaking change escalated — see ${logFile}`, "warn")
log(`Auto-applying in ${ESCALATION_TIMEOUT_MS / 60000} min (Ctrl+C to cancel)...`, "warn")
// Wait for timeout or user intervention
await new Promise(resolve => setTimeout(resolve, ESCALATION_TIMEOUT_MS))
log("Escalation timeout reached — applying fixes")
return true
}
// ── Git Auto-Commit ────────────────────────────────────────────────
function gitCommit(message) {
try {
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
execSync(`git commit -m "[bot] ${message}"`, { stdio: "pipe", cwd: ROOT })
log(`Committed: [bot] ${message}`)
return true
} catch (e) {
log(`Git commit failed: ${e.message}`, "warn")
return false
}
}
// ── Main Repair Loop ───────────────────────────────────────────────
async function repairOnce(doCommit = false) {
log("Running build check...")
const build = runTsc()
if (build.ok) {
log("No build errors found")
return { fixed: false, errors: [] }
}
const errors = parseErrors(build.output)
if (errors.length === 0) {
log("Could not parse build errors from output", "warn")
log(build.output.slice(0, 1000))
return { fixed: false, errors: [] }
}
log(`Found ${errors.length} error(s):`)
for (const e of errors.slice(0, 10)) {
const shortFile = e.file.replace(ROOT, "").replace(/^\//, "")
log(`${shortFile}:${e.line}:${e.column}${e.message}`)
}
const fixes = []
for (const error of errors) {
const context = readContext(error.file, error.line)
if (!context) {
log(`Cannot read ${error.file}`, "error")
continue
}
log(`Asking ${MODEL} to fix ${error.file}:${error.line}...`)
try {
const prompt = buildRepairPrompt(error.file, error.message, context)
const response = await queryOllama(prompt)
const fix = parseFixResponse(response)
if (fix && fix.confidence >= CONFIDENCE_THRESHOLD) {
const result = applyFix(error.file, fix)
if (result.applied) {
log(`${fix.explanation || "Applied fix"} (confidence: ${fix.confidence})`)
fixes.push(fix)
} else {
log(`Failed to apply fix: ${result.reason}`, "error")
}
} else {
log(`Low confidence (${fix?.confidence || 0}) or invalid response — skipping`, "warn")
}
} catch (e) {
log(`Ollama error: ${e.message}`, "error")
}
}
if (fixes.length === 0) {
log("No fixes could be applied", "error")
return { fixed: false, errors }
}
// Verify by re-running build
log("Verifying fixes...")
const verify = runTsc()
if (verify.ok) {
log("All fixes verified — build passes!")
if (doCommit) gitCommit(`Auto-fix: ${fixes.length} error(s) resolved`)
return { fixed: true, errors: [], fixes }
}
const remainingErrors = parseErrors(verify.output)
log(`${remainingErrors.length} error(s) remaining after fix`, "warn")
// Check for breaking changes
if (isBreakingChange(remainingErrors, fixes)) {
log("Breaking change detected — escalating", "warn")
const approved = await escalateBreakingChange(remainingErrors, fixes)
if (approved) {
if (doCommit) gitCommit(`Breaking change applied after escalation`)
return { fixed: true, errors: remainingErrors, fixes, escalated: true }
}
}
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
}
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
for (let i = 0; i < maxIterations; i++) {
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
const result = await repairOnce(doCommit)
if (!result.fixed) {
if (result.errors?.length > 0) {
log(`${result.errors.length} error(s) remaining — run again for deeper fix`, "warn")
}
break
}
if (result.errors?.length === 0) break
}
log("Repair agent finished")
}
// ── Watch Mode ─────────────────────────────────────────────────────
function watchMode() {
log("Watch mode active — monitoring src/ for changes...", "info")
const chokidar = (() => {
try {
return _require("chokidar")
} catch {
return null
}
})()
if (!chokidar) {
log("chokidar not available — using simple polling (5s interval)", "warn")
setInterval(async () => {
const result = await repairOnce(false)
if (result.fixed) log("Auto-fix applied")
}, 5000)
return
}
const watcher = chokidar.watch("src/**/*.{ts,tsx}", { cwd: ROOT, ignoreInitial: true })
let debounceTimer = null
watcher.on("change", async (filePath) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
log(`Change detected: ${filePath}`)
await repairLoop(3, false)
}, 1000)
})
log("Watching for changes...")
}
// ── Entry ──────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const isWatch = args.includes("--watch") || args.includes("-w")
const isCI = args.includes("--ci") || args.includes("-c")
async function main() {
if (isWatch) {
watchMode()
} else {
await repairLoop(MAX_ITERATIONS, isCI)
}
}
main().catch((e) => {
console.error("Repair agent crashed:", e)
process.exit(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")
}
+396
View File
@@ -0,0 +1,396 @@
// ── Module Generator ─────────────────────────────────────────────────
// Generates missing internal modules from templates.
// Supports built-in templates and custom .setup-templates/ directory.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates");
const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
export interface TemplateRegistry {
[importPath: string]: {
template: string;
params?: Record<string, unknown>;
generator?: string; // optional custom generator function name
};
}
export interface GenerationResult {
success: boolean;
filePath: string;
internalPath: string;
message?: string;
error?: string;
}
export interface FallbackRegistry {
[pattern: string]: string;
}
/** Load the template registry from .setup-templates/registry.json */
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
const builtInTemplates: TemplateRegistry = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
};
const builtInFallbacks: FallbackRegistry = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
};
try {
if (fs.existsSync(REGISTRY_PATH)) {
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
const custom = JSON.parse(content);
return {
templates: { ...builtInTemplates, ...(custom.templates || {}) },
fallbacks: { ...builtInFallbacks, ...(custom.fallbackTemplates || {}) },
};
}
} catch {
// Use built-in
}
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
}
/** Match an internal path against fallback patterns */
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
for (const [pattern, template] of Object.entries(fallbacks)) {
// Convert glob pattern to regex
const regexStr = "^" + pattern
.replace(/\//g, "\\/")
.replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&")
+ "$";
const regex = new RegExp(regexStr);
if (regex.test(internalPath)) {
return template;
}
}
return undefined;
}
/** Render a template with parameters */
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
let result = templateContent;
for (const [key, value] of Object.entries(params)) {
const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g");
result = result.replace(regex, String(value));
}
return result;
}
/** Get template content - first from custom templates dir, then built-in */
function getTemplateContent(templateName: string): string {
// Check custom templates first
const customPath = path.join(TEMPLATES_DIR, templateName);
if (fs.existsSync(customPath)) {
return fs.readFileSync(customPath, "utf-8");
}
// Built-in templates (embedded)
const builtInTemplates: Record<string, string> = {
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
export function getStickerPacks(): StickerPack[] {
return [
{
id: "reactions",
name: "Reactions",
stickers: [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
],
},
{
id: "animals",
name: "Animals",
stickers: [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
],
},
{
id: "food",
name: "Food & Drink",
stickers: [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🎂" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
],
},
];
};
export function getStickerPacks(): StickerPack[] {
return [
// reactions, animals, food packs defined above
// (inline for brevity - full packs in template file)
];
}
`,
"constants.ts": `// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
`,
"utils.ts": `// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
`,
"types-index.ts": `// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus = "new" | "contacted" | "qualified" | "proposal" | "won" | "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
}
`,
};
return builtInTemplates[templateName] || "";
}
/** Generate a single missing module */
export function generateModule(
internalPath: string,
templates: TemplateRegistry,
fallbacks: FallbackRegistry
): GenerationResult {
let entry = templates[internalPath];
let templateFile = entry?.template;
// Try fallback patterns
if (!entry) {
const fallbackTemplate = matchFallback(internalPath, fallbacks);
if (fallbackTemplate) {
templateFile = fallbackTemplate;
entry = { template: fallbackTemplate };
}
}
if (!templateFile) {
return {
success: false,
filePath: "",
internalPath,
error: `No template registered for @/${internalPath}`,
};
}
const templateContent = getTemplateContent(templateFile);
if (!templateContent) {
return {
success: false,
filePath: "",
internalPath,
error: `Template not found: ${entry.template}`,
};
}
const rendered = renderTemplate(templateContent, entry.params || {});
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
// Ensure directory exists
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write file
try {
fs.writeFileSync(filePath, rendered, "utf-8");
return {
success: true,
filePath,
internalPath,
message: `Generated @/${internalPath}`,
};
} catch (e) {
return {
success: false,
filePath,
internalPath,
error: `Failed to write file: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
/** Generate all missing modules for a list of internal paths */
export function generateAll(internalPaths: string[]): GenerationResult[] {
const { templates, fallbacks } = loadRegistry();
const results: GenerationResult[] = [];
for (const internalPath of internalPaths) {
const result = generateModule(internalPath, templates, fallbacks);
results.push(result);
if (result.success) {
console.log(`${result.message}`);
} else {
console.error(` ✗ Failed @/${internalPath}: ${result.error}`);
}
}
return results;
}
+92
View File
@@ -0,0 +1,92 @@
// ── TypeScript Error Parser ──────────────────────────────────────────
// Parses `tsc --noEmit` or `npm run build` output to extract
// "Module not found: Can't resolve '@/path/to/module'" errors.
// Returns actionable missing module info for the generator.
export interface MissingModule {
/** The import path that failed, e.g. "@/data/stickers" */
importPath: string;
/** The file that contains the failing import */
importerFile: string;
/** Line number in importer file */
line: number;
/** Column number */
column: number;
/** Full error message for context */
message: string;
/** Whether this is an internal (@/...) module we can generate */
isInternal: boolean;
/** The normalized internal path, e.g. "data/stickers" */
internalPath?: string;
}
/** Parse TypeScript compiler output for missing module errors */
export function parseMissingModules(tscOutput: string): MissingModule[] {
const results: MissingModule[] = [];
// Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations.
// File: src/components/chats/media-picker.tsx:10:1
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g;
let match;
while ((match = errorRegex.exec(tscOutput)) !== null) {
const importPath = match[1];
const importerFile = match[2] || "";
const line = parseInt(match[3] || "0", 10);
const column = parseInt(match[4] || "0", 10);
// Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats'
const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g;
let altMatch;
let altImporter = importerFile;
while ((altMatch = altRegex.exec(tscOutput)) !== null) {
if (altMatch[1] === importPath) {
altImporter = altMatch[2];
break;
}
}
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/");
let internalPath: string | undefined;
if (isInternal) {
internalPath = importPath.replace(/^[@~]\//, "");
}
results.push({
importPath,
importerFile: altImporter,
line,
column,
message: `Cannot find module '${importPath}'`,
isInternal,
internalPath,
});
}
// Deduplicate by importPath + importerFile
const seen = new Set<string>();
return results.filter((m) => {
const key = `${m.importPath}|${m.importerFile}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** Filter to only internal (@/...) modules we can auto-generate */
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
return modules.filter((m) => m.isInternal && m.internalPath);
}
/** Group missing modules by internal path */
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
const groups = new Map<string, MissingModule[]>();
for (const m of modules) {
if (!m.internalPath) continue;
const existing = groups.get(m.internalPath) || [];
existing.push(m);
groups.set(m.internalPath, existing);
}
return groups;
}
+33 -3
View File
@@ -1,12 +1,42 @@
// ── Dependency Check ─────────────────────────────────────────────────
// Verifies all packages listed in package.json are installed.
// Auto-runs npm install if any are missing — zero manual setup steps.
import { readFileSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
import { execSync } from "node:child_process"
import { platform } from "node:os"
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, "..")
try {
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
const require = createRequire(import.meta.url)
const missing = Object.keys(allDeps).filter(name => {
try { require.resolve(name, { paths: [root] }); return false } catch { return true }
})
if (missing.length > 0) {
console.log(`\n${missing.length} package(s) missing: ${missing.join(", ")}`)
console.log("Installing...\n")
execSync("npm install --no-audit --no-fund", { cwd: root, stdio: "inherit", timeout: 120000 })
console.log("Dependencies installed\n")
}
} catch {
// If package.json read or resolution fails, skip silently
}
// ── Port Precheck ──────────────────────────────────────────────────
// Kills any existing processes on ports 3001, 3006, 3007, 3008.
// These are the AI server, Next.js frontend, Signaling server, and
// Python scraper respectively.
// Runs before anything else starts to avoid EADDRINUSE errors.
import { execSync } from "node:child_process"
import { platform } from "node:os"
const PORTS = [3001, 3006, 3007, 3008]
if (platform() === "win32") {
+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()
}
+204 -16
View File
@@ -5,17 +5,22 @@
// 2. pip install -r requirements.txt (Python dependencies)
// 3. playwright install firefox chromium (Playwright browsers)
// 4. Copies .env.example to .env.local if not exists
// 5. --self-heal flag: runs build check + auto-generates missing modules
//
// All steps are cross-platform (Windows, Mac, Linux).
// Uses execSync for simplicity since each step blocks the next.
import { execSync } from "node:child_process"
import { existsSync, copyFileSync } from "node:fs"
import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { platform } from "node:os"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const SEP = platform() === "win32" ? "&" : ";"
const SELF = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SELF, "..")
// ── Helpers ────────────────────────────────────────────────────────
// Auto-detect Python executable (python vs python3)
function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
@@ -28,7 +33,6 @@ function detectPython() {
process.exit(1)
}
// Auto-detect pip (pip vs pip3), fall back to python -m pip
function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) {
@@ -40,41 +44,225 @@ function detectPip(python) {
return `${python} -m pip`
}
const PY = detectPython()
const PIP = detectPip(PY)
function run(cmd, label) {
function run(cmd, label, opts = {}) {
console.log(`\n── ${label} ──`)
try {
execSync(cmd, { stdio: "inherit", timeout: 120000 })
execSync(cmd, { stdio: "inherit", timeout: opts.timeout || 120000, cwd: opts.cwd || ROOT })
} catch (e) {
if (opts.optional) {
console.log(` ~ Skipped (${e.message})`)
return false
}
console.error(` ✗ Failed: ${e.message}`)
process.exit(1)
}
return true
}
// ── Self-Healing Module Registry ───────────────────────────────────
function loadRegistry() {
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
const builtInTemplates = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts" },
"hooks/use-media": { template: "hooks/use-media.ts" },
"hooks/use-debounce": { template: "hooks/use-debounce.ts" },
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
}
const builtInFallbacks = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
}
let templates = { ...builtInTemplates }
let fallbacks = { ...builtInFallbacks }
try {
if (existsSync(registryPath)) {
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
templates = { ...templates, ...(custom.templates || {}) }
fallbacks = { ...fallbacks, ...(custom.fallbackTemplates || {}) }
}
} catch {}
const templateCache = {}
function getTemplate(templateName) {
if (templateCache[templateName]) return templateCache[templateName]
const candidatePaths = [
resolve(templatesDir, templateName),
resolve(templatesDir, "hooks", templateName.replace("hooks/", "")),
]
for (const p of candidatePaths) {
if (existsSync(p)) {
const content = readFileSync(p, "utf-8")
templateCache[templateName] = content
return content
}
}
return ""
}
function matchFallback(internalPath) {
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
const regexStr = "^" + pattern
.replace(/\//g, "\\/").replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&") + "$"
const regex = new RegExp(regexStr)
if (regex.test(internalPath)) return templateFile
}
return null
}
function generateModule(internalPath) {
let templateFile = templates[internalPath]?.template
if (!templateFile) templateFile = matchFallback(internalPath)
if (!templateFile) return { success: false, error: `No template for @/${internalPath}` }
const content = getTemplate(templateFile)
if (!content) return { success: false, error: `Template file not found: ${templateFile}` }
const ext = templateFile.endsWith(".tsx") ? ".tsx" : ".ts"
const filePath = resolve(ROOT, "src", internalPath + ext)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, content, "utf-8")
return { success: true, filePath, message: `Generated @/${internalPath}` }
}
return { generateModule }
}
function parseMissingModules(buildOutput) {
const results = []
const seen = new Set()
// Pattern: error TS2307: Cannot find module '@/path' or its corresponding type declarations.
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'/g
let match
while ((match = errorRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
// Also catch webpack-style: Module not found: Error: Can't resolve '@/path'
const webpackRegex = /Module not found: Error: Can't resolve\s+'([^']+)'/g
while ((match = webpackRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
return results
}
// ── Main ───────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
const PY = detectPython()
const PIP = detectPip(PY)
console.log("=== CoastIT CRM Setup ===\n")
// 1. Node dependencies
run("npm install", "Installing Node.js dependencies")
// 2. Python dependencies (run from browser-use-service directory)
// 2. Python dependencies
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
// 3. Playwright browsers (Firefox for primary scraping, Chromium for Chrome/Edge/Opera + Agent fallback)
// 3. Playwright browsers
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// 4. .env file — create from template if it doesn't exist
if (!existsSync(".env.local")) {
// 4. .env file
if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── Creating .env.local ──")
copyFileSync(".env.example", ".env.local")
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
console.log(" ✓ Created .env.local from .env.example — edit it with your settings")
} else {
console.log("\n── .env.local already exists, skipping ──")
}
// 5. Remaining manual steps
console.log("\n── Next steps ──")
// 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()
let lastGeneratedCount = -1
let iteration = 0
const MAX_ITERATIONS = 5
while (iteration < MAX_ITERATIONS) {
iteration++
console.log(` Build check pass ${iteration}/${MAX_ITERATIONS}...`)
let buildOutput = ""
try {
buildOutput = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }).toString()
} catch (e) {
buildOutput = e.stdout?.toString() || e.message || ""
}
const missing = parseMissingModules(buildOutput)
const internalMissing = missing
.filter(m => m.isInternal && m.internalPath)
.filter((m, i, arr) => arr.findIndex(x => x.internalPath === m.internalPath) === i) // dedup
if (internalMissing.length === 0) {
console.log(" ✓ No missing internal modules found!")
break
}
console.log(` Found ${internalMissing.length} missing internal module(s)`)
let generated = 0
for (const mod of internalMissing) {
const result = generateModule(mod.internalPath)
if (result.success) {
console.log(`${result.message}`)
generated++
} else {
console.log(`${result.error}`)
}
}
if (generated === 0 || generated === lastGeneratedCount) {
console.log(" No new modules could be generated. Stopping.")
break
}
lastGeneratedCount = generated
}
}
// 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")
console.log(" 3. Edit .env.local with your settings")
+265 -61
View File
@@ -1,82 +1,286 @@
"use client"
import { useState, useCallback } from "react"
import { AIChat } from "@/components/ai/ai-chat"
import { useState, useCallback, useRef, useEffect } from "react"
import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector"
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
import { Bot, ChevronRight, Wifi, WifiOff } from "lucide-react"
const tips = [
"Ask for cold email templates for a specific job",
"Request objection handling tips",
"Ask for outreach strategies per industry",
"Generate a follow up sequence",
"Get LinkedIn connection message templates",
]
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 [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)
}, [])
const handleRecentPromptClick = useCallback((prompt: string) => {
aiChatRef.current?.fillInput(prompt)
}, [])
const handleMessageSent = useCallback((msg: string) => {
setRecentPrompts((prev) => {
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)]">
<div className="flex-1 flex flex-col min-w-0">
<div className="border-b border-[#2a2a35] px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
<Bot className="h-5 w-5 text-[#1BB0CE]" />
<div 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-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-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>
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
<div className="flex items-center gap-4">
<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-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>
<div className="flex-1 flex">
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
<AIChat />
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
</div>
<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-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</div>
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
<div>
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Target className="h-3.5 w-3.5" />
Target Job
</h3>
<JobSelector onSelect={handleJobSelect} />
</div>
{selectedJob && (
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
<div className="flex items-center gap-1.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
</div>
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
<div className="flex flex-wrap gap-1">
{selectedJob.keywords.map((kw, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
{kw}
</span>
))}
</div>
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
{selectedJob && (
<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-muted/50 text-muted-foreground">{kw}</span>
))}
</div>
)}
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
<Lightbulb className="h-3.5 w-3.5" />
Tips
</h4>
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for cold email templates for a specific job
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Request objection handling tips
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for outreach strategies per industry
</li>
</ul>
</div>
)}
</div>
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<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-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-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-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-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-muted-foreground text-xs text-center py-4">Your recent prompts will appear here</div>
)}
</div>
<div className="mt-auto border-t border-border pt-4">
<div className="flex gap-4">
<div className="flex-1">
<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-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>
+193 -58
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner"
import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote,
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone,
chat: Video,
follow_up: Clock,
demo: Calendar,
other: Calendar,
website_creation: Globe,
}
function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting")
const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("")
@@ -123,7 +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((err) => console.error("Failed to fetch leads:", err))
}, [user, router])
useEffect(() => {
@@ -132,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])
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr))
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth])
const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null)
setFormTitle("")
setFormDescription("")
setFormType("meeting")
setFormType("website_creation")
setFormDuration("30")
setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00")
@@ -184,37 +196,58 @@ export default function CalendarPage() {
setFormDescription(event.description || "")
setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true)
}
const handleSave = async () => {
if (!formTitle.trim() || !formDate || !formTime) {
toast.error("Title, date, and time are required")
if (!formTitle.trim()) {
toast.error("Title is required")
return
}
if (formType !== "website_creation" && (!formDate || !formTime)) {
toast.error("Date and time are required for this event type")
return
}
setFormSaving(true)
try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
}
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res
if (editingEvent) {
@@ -235,11 +268,13 @@ export default function CalendarPage() {
const data = await res.json()
if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated")
} else {
setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created")
}
@@ -260,7 +295,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
} catch {
@@ -279,7 +314,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to save")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
}
@@ -579,7 +614,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className={cn(
@@ -608,11 +643,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div>
<div className="space-y-2">
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div>
</div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +673,16 @@ export default function CalendarPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10">
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -663,6 +712,38 @@ export default function CalendarPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client (Lead)</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
@@ -690,7 +771,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px]">
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +792,29 @@ export default function CalendarPage() {
</DialogHeader>
<div className="space-y-4">
{/* Date / Time */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
{detailEvent.startTime ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
</div>
) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */}
<div className="grid grid-cols-2 gap-3">
@@ -751,11 +839,26 @@ export default function CalendarPage() {
{/* Description */}
{detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div>
)}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */}
{detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +905,32 @@ export default function CalendarPage() {
</div>
</div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5">
@@ -836,9 +965,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && (
<>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
{detailEvent.developerId === user?.id ? (
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button>
+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>
)
}
+60 -22
View File
@@ -15,9 +15,10 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
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)
@@ -792,7 +820,7 @@ const formatPreviewContent = (content: string) => {
})
return (
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
<div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* Conversations list - left panel */}
<div
className="flex flex-col border-r shrink-0 overflow-hidden"
@@ -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"
>
@@ -923,9 +964,6 @@ const formatPreviewContent = (content: string) => {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Phone className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
<CalendarDays className="h-4 w-4" />
</Button>
+13 -24
View File
@@ -24,12 +24,10 @@ import {
SelectValue,
} from "@/components/ui/select"
import { DashboardStats } from "@/types"
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
import { StarField } from "@/components/dashboard/star-field"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
export default function DashboardPage() {
const { websiteTheme: theme } = useWebsiteTheme()
const { websiteTheme } = useWebsiteTheme()
const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null)
const pollingRef = useRef<NodeJS.Timeout | null>(null)
@@ -41,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)
}
}
@@ -67,22 +65,12 @@ export default function DashboardPage() {
return (
<div className="space-y-6 relative">
{theme === "starforce" && <StarField />}
<div className="relative z-[1] space-y-6">
<div>
<PageHeader
title={<span style={{fontFamily:"'Audiowide',sans-serif"}}>Dashboard</span>}
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
description="Overview of your sales pipeline"
>
{theme === "starforce" && (
<span
className="pointer-events-none select-none text-5xl text-gray-600 dark:text-[#b0f0c0] tracking-wider font-bold text-glow whitespace-nowrap"
style={{ fontFamily: "'Cormorant Garamond', serif" }}
>
Trust the Force, Find your Path
</span>
)}
{theme === "starforce" && <CrossedLightsabers />}
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[160px]">
<ListFilter className="mr-2 h-4 w-4" />
@@ -98,7 +86,7 @@ export default function DashboardPage() {
</PageHeader>
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats
@@ -109,7 +97,7 @@ export default function DashboardPage() {
}
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} />
@@ -118,12 +106,13 @@ export default function DashboardPage() {
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
{/* Daily Bugle watermark */}
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE
</span>
</div>
{websiteTheme === "spidey" && (
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE
</span>
</div>
)}
</div>
)
}
+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
+10 -3
View File
@@ -1,5 +1,7 @@
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
@@ -8,8 +10,15 @@ import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser()
const router = useRouter()
if (loading) {
useEffect(() => {
if (!user && !loading) {
router.push("/login")
}
}, [user, loading, router])
if (loading || !user) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -17,8 +26,6 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
)
}
if (!user) return null
return <AppShell>{children}</AppShell>
}
+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>
)
}

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