Compare commits

..

24 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
caitlin dd6767980a search button theme fix in ai assistant 2026-07-02 16:04:53 +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
Hannah_Bagga c0cb715ced ai assistant page fixed 2026-07-01 21:07:42 +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
Hannah_Bagga 370f935cbb Removed quick tips, recent prompts Messages today, Tokens used 2026-07-01 14:24:32 +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
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
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
caitlin e8d80c3a16 login logo fix that i broke 2026-07-01 12:04:05 +02:00
caitlin 77cccba8a1 Fixed Dark Mode 2026-07-01 11:43:21 +02:00
57 changed files with 1941 additions and 543 deletions
+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 |
+231 -198
View File
@@ -1,70 +1,71 @@
.dark.theme-pumpkin {
--background: 0 30% 4%;
--foreground: 20 50% 95%;
--card: 0 25% 8%;
--card-foreground: 20 50% 95%;
--popover: 0 25% 8%;
--popover-foreground: 20 50% 95%;
--primary: 10 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 30 100% 50%;
--secondary-foreground: 0 0% 0%;
--muted: 0 12% 15%;
--muted-foreground: 20 10% 55%;
--accent: 10 100% 50%;
--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: 210 40% 98%;
--border: 10 30% 18%;
--input: 10 30% 18%;
--ring: 10 100% 50%;
--destructive-foreground: 0 0% 100%;
--border: 25 20% 16%;
--input: 25 20% 16%;
--ring: 25 95% 50%;
--radius: 0.5rem;
--sidebar: 0 30% 4%;
--sidebar-foreground: 20 50% 95%;
--sidebar-primary: 10 100% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 10 100% 50%;
--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: 10 30% 15%;
--sidebar-ring: 10 100% 50%;
--sidebar-border: 25 18% 14%;
--sidebar-ring: 25 95% 50%;
}
.light.theme-pumpkin {
--background: 20 30% 96%;
--foreground: 10 50% 10%;
--background: 30 30% 94%;
--foreground: 25 40% 12%;
--card: 0 0% 100%;
--card-foreground: 10 50% 10%;
--card-foreground: 25 40% 12%;
--popover: 0 0% 100%;
--popover-foreground: 10 50% 10%;
--primary: 10 90% 45%;
--popover-foreground: 25 40% 12%;
--primary: 25 90% 42%;
--primary-foreground: 0 0% 100%;
--secondary: 30 90% 45%;
--secondary: 35 90% 42%;
--secondary-foreground: 0 0% 100%;
--muted: 20 15% 88%;
--muted-foreground: 10 10% 45%;
--accent: 10 90% 45%;
--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: 20 15% 82%;
--input: 20 15% 82%;
--ring: 10 90% 45%;
--border: 25 15% 80%;
--input: 25 15% 80%;
--ring: 25 90% 42%;
--radius: 0.5rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 10 50% 10%;
--sidebar-primary: 10 90% 45%;
--sidebar-foreground: 25 40% 12%;
--sidebar-primary: 25 90% 42%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 10 90% 45%;
--sidebar-accent: 15 90% 42%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 20 15% 82%;
--sidebar-ring: 10 90% 45%;
--sidebar-border: 25 15% 80%;
--sidebar-ring: 25 90% 42%;
}
/* Warm ambient glow */
.dark.theme-pumpkin body {
background: transparent
radial-gradient(ellipse 120% 60% at 20% 30%, rgba(200, 0, 0, 0.2) 0%, transparent 60%),
radial-gradient(ellipse 80% 50% at 80% 70%, rgba(255, 100, 0, 0.15) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 50% 50%, rgba(150, 0, 0, 0.1) 0%, transparent 50%);
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 {
@@ -78,195 +79,209 @@
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 80% 50% at 15% 20%, rgba(200, 50, 0, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 80% 50% at 85% 80%, rgba(255, 100, 0, 0.03) 0%, transparent 60%);
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(10, 100%, 50%), 0 0 30px hsl(10, 100%, 50%), 0 0 60px rgba(255, 0, 0, 0.5);
text-shadow: 0 0 10px hsl(25, 95%, 50%), 0 0 30px hsla(25, 95%, 50%, 0.6), 0 0 60px rgba(200, 80, 0, 0.3);
}
.light.theme-pumpkin .text-glow {
text-shadow: 0 0 8px hsl(10, 90%, 45%), 0 0 20px hsl(10, 90%, 45%), 0 0 40px rgba(200, 50, 0, 0.3);
text-shadow: 0 0 8px hsl(25, 90%, 42%), 0 0 20px hsla(25, 90%, 42%, 0.4), 0 0 40px rgba(200, 80, 0, 0.15);
}
.dark.theme-pumpkin h1, .dark.theme-pumpkin h2, .dark.theme-pumpkin h3 {
background: linear-gradient(135deg, hsl(10, 100%, 50%), hsl(30, 100%, 50%));
/* 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, .light.theme-pumpkin h2, .light.theme-pumpkin h3 {
background: linear-gradient(135deg, hsl(10, 90%, 40%), hsl(30, 90%, 40%));
.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;
}
.dark.theme-pumpkin a {
color: hsl(10, 100%, 55%);
}
.light.theme-pumpkin a {
color: hsl(10, 90%, 40%);
}
.dark.theme-pumpkin a:hover {
color: hsl(30, 100%, 55%);
text-shadow: 0 0 8px rgba(255, 100, 0, 0.4);
}
.light.theme-pumpkin a:hover {
color: hsl(30, 90%, 45%);
}
/* 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(10, 100%, 50%);
color: hsl(20, 15%, 70%);
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(10, 90%, 45%);
color: hsl(10, 10%, 45%);
border-left: 3px solid hsl(25, 90%, 42%);
color: hsl(25, 10%, 42%);
font-style: italic;
}
/* Code */
.dark.theme-pumpkin code {
color: hsl(10, 100%, 55%);
background: rgba(200, 0, 0, 0.12);
border: 1px solid rgba(200, 0, 0, 0.15);
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(10, 90%, 40%);
background: rgba(200, 50, 0, 0.05);
border: 1px solid rgba(200, 50, 0, 0.1);
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); }
.dark.theme-pumpkin pre {
background: rgba(20, 0, 0, 0.9);
border: 1px solid rgba(200, 0, 0, 0.1);
}
/* 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); }
.light.theme-pumpkin pre {
background: rgba(200, 50, 0, 0.03);
border: 1px solid rgba(200, 50, 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); }
.dark.theme-pumpkin hr {
border-color: rgba(200, 0, 0, 0.15);
}
/* 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%); }
.light.theme-pumpkin hr {
border-color: rgba(200, 50, 0, 0.1);
}
/* 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%); }
.dark.theme-pumpkin table th {
color: hsl(10, 100%, 55%);
}
.light.theme-pumpkin table th {
color: hsl(10, 90%, 40%);
}
.dark.theme-pumpkin table td {
border-color: rgba(200, 0, 0, 0.12);
}
.light.theme-pumpkin table td {
border-color: rgba(200, 50, 0, 0.1);
}
.dark.theme-pumpkin ::selection {
background: rgba(200, 0, 0, 0.2);
color: hsl(0, 0%, 100%);
}
.light.theme-pumpkin ::selection {
background: rgba(200, 50, 0, 0.12);
color: hsl(0, 0%, 0%);
}
.dark.theme-pumpkin input::placeholder,
.dark.theme-pumpkin textarea::placeholder {
color: hsl(20, 10%, 40%);
}
.light.theme-pumpkin input::placeholder,
.light.theme-pumpkin textarea::placeholder {
color: hsl(10, 10%, 65%);
}
.dark.theme-pumpkin .page-header-title {
color: hsl(10, 100%, 55%);
}
.light.theme-pumpkin .page-header-title {
color: hsl(10, 90%, 40%);
}
.dark.theme-pumpkin .stat-card-value {
color: hsl(10, 100%, 55%);
}
.light.theme-pumpkin .stat-card-value {
color: hsl(10, 90%, 40%);
}
.dark.theme-pumpkin .badge {
color: hsl(10, 100%, 55%);
}
.light.theme-pumpkin .badge {
color: hsl(10, 90%, 40%);
}
/* 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(10, 100%, 50%), hsl(10, 100%, 65%), hsl(10, 100%, 50%)) !important;
background: linear-gradient(to right, hsl(25, 95%, 50%), hsl(25, 95%, 65%), hsl(25, 95%, 50%)) !important;
}
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
background: linear-gradient(to right, hsl(30, 100%, 50%), hsl(30, 100%, 65%), hsl(30, 100%, 50%)) !important;
background: linear-gradient(to right, hsl(35, 100%, 45%), hsl(35, 100%, 60%), hsl(35, 100%, 45%)) !important;
}
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(10, 100%, 50%, 0.1) !important;
background: hsla(25, 95%, 50%, 0.12) !important;
}
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(30, 100%, 50%, 0.1) !important;
background: hsla(35, 100%, 45%, 0.12) !important;
}
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
color: hsl(10, 100%, 50%) !important;
color: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(30, 100%, 50%) !important;
color: hsl(35, 100%, 45%) !important;
}
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
background: linear-gradient(to right, transparent, hsla(10, 100%, 50%, 0.25), transparent) !important;
background: linear-gradient(to right, transparent, hsla(25, 95%, 50%, 0.25), transparent) !important;
}
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
background: linear-gradient(to right, transparent, hsla(30, 100%, 50%, 0.25), transparent) !important;
background: linear-gradient(to right, transparent, hsla(35, 100%, 45%, 0.25), transparent) !important;
}
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(10, 100%, 50%), hsl(30, 100%, 50%)) !important;
background: linear-gradient(to right, hsl(25, 95%, 50%), hsl(35, 100%, 45%)) !important;
}
.light.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
background: linear-gradient(to right, hsl(10, 90%, 45%), hsl(10, 90%, 55%), hsl(10, 90%, 45%)) !important;
background: linear-gradient(to right, hsl(25, 90%, 42%), hsl(25, 90%, 55%), hsl(25, 90%, 42%)) !important;
}
.light.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
background: linear-gradient(to right, hsl(30, 90%, 45%), hsl(30, 90%, 55%), hsl(30, 90%, 45%)) !important;
background: linear-gradient(to right, hsl(35, 90%, 42%), hsl(35, 90%, 55%), hsl(35, 90%, 42%)) !important;
}
/* ============================================================
Chart color overrides — Pumpkin Spice palette
============================================================ */
.dark.theme-pumpkin [class*="recharts-"] text,
.light.theme-pumpkin [class*="recharts-"] text {
fill: hsl(30, 12%, 60%) !important;
}
/* Donut chart gradient stops */
.dark.theme-pumpkin .recharts-pie-sector:nth-child(1) path,
.light.theme-pumpkin .recharts-pie-sector:nth-child(1) path {
fill: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .recharts-pie-sector:nth-child(2) path,
.light.theme-pumpkin .recharts-pie-sector:nth-child(2) path {
fill: hsl(35, 100%, 45%) !important;
}
.dark.theme-pumpkin .recharts-pie-sector:nth-child(3) path,
.light.theme-pumpkin .recharts-pie-sector:nth-child(3) path {
fill: hsl(15, 100%, 42%) !important;
}
/* Stat card icon colors */
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .h-8.w-8,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .h-8.w-8 {
background: hsla(25, 95%, 50%, 0.12) !important;
color: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .h-8.w-8,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .h-8.w-8 {
background: hsla(35, 100%, 45%, 0.12) !important;
color: hsl(35, 100%, 45%) !important;
}
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .h-8.w-8,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .h-8.w-8 {
background: hsla(15, 100%, 42%, 0.12) !important;
color: hsl(15, 100%, 42%) !important;
}
/* Stat card value colors */
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .text-2xl,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .text-2xl {
color: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .text-2xl,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .text-2xl {
color: hsl(35, 100%, 45%) !important;
}
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .text-2xl,
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .text-2xl {
color: hsl(15, 100%, 42%) !important;
}
/* Bar chart bar fills */
.dark.theme-pumpkin .recharts-bar-rectangle:nth-child(1) rect,
.light.theme-pumpkin .recharts-bar-rectangle:nth-child(1) rect {
fill: hsl(25, 95%, 50%) !important;
}
.dark.theme-pumpkin .recharts-bar-rectangle:nth-child(2) rect,
.light.theme-pumpkin .recharts-bar-rectangle:nth-child(2) rect {
fill: hsl(35, 100%, 45%) !important;
}
/* ============================================================
Card gradient accent — subtle pumpkin glow on hover
============================================================ */
.dark.theme-pumpkin .rounded-xl.border.border-border {
border-color: hsla(25, 20%, 18%, 0.8) !important;
}
.dark.theme-pumpkin .rounded-xl.border.border-border:hover {
border-color: hsla(25, 95%, 50%, 0.25) !important;
}
/* Growth word on login page — keep brand blue */
.dark.theme-pumpkin .growth-word,
.light.theme-pumpkin .growth-word {
-webkit-text-fill-color: #1BB0CE !important;
@@ -274,70 +289,88 @@
background: none !important;
}
/* ============================================================================
Client Portal Overrides — Background for portal pages
============================================================================ */
/* Make the outer portal container transparent so body::before background shows */
/* ============================================================
Client Portal Overrides
============================================================ */
.dark.theme-pumpkin .min-h-screen.bg-background {
background: transparent !important;
}
/* Portal sidebar — dark glass */
.dark.theme-pumpkin aside.bg-card.border-r.border-border {
background: hsl(240, 15%, 3%) !important;
border-right-color: hsl(270, 25%, 10%) !important;
background: hsl(25, 35%, 4%) !important;
border-right-color: hsl(25, 20%, 14%) !important;
}
/* Portal sidebar nav items — muted text */
.dark.theme-pumpkin aside .text-muted-foreground {
color: hsl(240, 8%, 50%) !important;
color: hsl(30, 10%, 50%) !important;
}
.dark.theme-pumpkin aside button:hover {
background: hsla(330, 100%, 50%, 0.08) !important;
background: hsla(25, 95%, 50%, 0.08) !important;
color: hsl(0, 0%, 90%) !important;
}
/* Portal sidebar active nav item */
.dark.theme-pumpkin aside .bg-primary\/10.text-primary {
background: hsl(330, 100%, 50%) !important;
background: hsl(25, 95%, 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;
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;
}
/* Portal header — dark glass */
.dark.theme-pumpkin header.bg-card\/80 {
background: hsl(240, 15%, 3%) !important;
border-bottom-color: hsl(270, 25%, 10%) !important;
background: hsl(25, 35%, 4%) !important;
border-bottom-color: hsl(25, 20%, 14%) !important;
}
/* Portal card backgrounds — semi-transparent glass */
.dark.theme-pumpkin .bg-card {
background: hsla(240, 15%, 7%, 0.65) !important;
background: hsla(25, 30%, 7%, 0.65) !important;
backdrop-filter: blur(10px) !important;
border-color: hsla(270, 30%, 20%, 0.35) !important;
border-color: hsla(25, 20%, 16%, 0.35) !important;
}
/* Portal borders */
.dark.theme-pumpkin .border-border {
border-color: hsl(270, 25%, 12%) !important;
border-color: hsl(25, 18%, 14%) !important;
}
/* Portal input fields */
.dark.theme-pumpkin .bg-muted {
background: hsl(240, 12%, 9%) !important;
background: hsl(25, 15%, 10%) !important;
}
/* Portal primary buttons */
.dark.theme-pumpkin .bg-primary {
background: hsl(330, 100%, 50%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
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(330, 100%, 55%) !important;
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
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;
}
+445 -84
View File
@@ -341,6 +341,7 @@ OFFER_PATTERNS = [
r"\bfree\s+domain\b",
r"\bweb\s+hosting\b",
r"\bfree\s+website\b",
r"\bfree\s+\w+\s+website\b",
r"\bprofessional\s+website",
r"\baffordable\s+web\b",
r"\bget\s+a\s+free\b",
@@ -365,6 +366,88 @@ OFFER_PATTERNS = [
r"\bsponsored\b",
r"\bpromoted\b",
r"\badvertisement\b",
r"\bdo\s+you\s+(need|want)\b",
r"\bwe\s+(can|will)\s+(build|design|create|develop|do)\b",
r"\bi\s+(can|will)\s+(build|design|create|develop|do)\b",
r"\bmy\s+portfolio\b",
r"\breasonable\s+(price|pricing|rate)\b",
r"\bwhatsapp\s+(me|us|at)\b",
r"\bwhatsapp\b",
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
r"\bhelp\s+your\s+business\b",
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\b",
r"\bcontact\s+me\b",
r"\bwe\s+(are|offer|provide)\s+(web|website|design|service)\b",
r"\btake\s+(the\s+|this\s+)?quiz\b",
r"\bhomeschool\b",
r"\byour\s+(home\s+)?tutor\b",
r"\blink\s+(in\s+)?(bio|comment|below)\b",
r"\bapply\s+now\b",
r"\bget\s+started\b",
r"\bfor\s+only\b",
r"\blow\s+(price|cost|rate)\b",
r"\bhit\s+me\s+up\b",
r"\bsend\s+me\s+(a\s+)?(message|dm|pm)\b",
r"\bi\s+do\s+(web|website|design|development)\b",
r"\bwe\s+do\s+(web|website|design|development)\b",
r"\bi'm\s+offering\b",
r"\bwe're\s+offering\b",
r"\bstarting\s+a\s+\w+\s+(service|business)\b",
r"\blooking\s+for\s+a\s+few\s+businesses?\b",
r"\blet('?)s\s+connect\b",
r"\bi\s+have\s+projects?\b",
r"\byour\s+business\b",
r"\bwordpress\s+development\b",
r"\be.?commerce\s+website\b",
r"\bwebsite\s+builder\b",
r"\bai\s+studio\b",
r"\bpage\s+speed\b",
r"\bguest\s+post",
r"\bguest\s+blog",
r"\bfor\s+sale\b",
r"\bselling\s+(my|a|the|this)\b",
r"\bpremium\b",
r"\bi'm\s+selling\b",
r"\bgroup\b",
r"\bi\s+can\s+help\b",
r"\binbox\s+me\b",
r"\bpm\s+me\b",
r"\bdm\s+me\b",
r"\bmessage\s+me\s+for\b",
r"\bquote\b",
r"\bdiscount\b",
r"\bbest\s+(deal|price|offer|service)\b",
r"\bprice\s+(start|begin|include|range)\b",
r"\breach\s+out\b",
r"\bclick\s+(the\s+)?link\b",
r"\bcheck\s+(out|this|my)\b",
r"\bwebsite\s+for\s+your\b",
r"\bprobono\b",
r"\bfreelance\s+opportunit",
r"\bfull.?stack\b",
r"\bresponsive\s+(web)?sites?\b",
r"\blooking\s+for\s+(new\s+)?(projects?|work)\b",
r"\bmern\s+stack\b",
r"\bexpress\s+js\b",
r"\breact\s+js\b",
r"\bnode\s+js\b",
r"\bwebsite\s+for\s+\$\b",
r"\bi\s+build\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+build\s+(websites?|shopify|wordpress)\b",
r"\bi\s+create\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+create\s+(websites?|shopify|wordpress)\b",
r"\bfull.?time\s+(position|job|role|work)\b",
r"\bremote\s+(position|job|role|work)\b",
r"\bhelp\s+wanted\b",
r"\bjob\s+(opening|vacancy|opportunity)\b",
r"\bnow\s+hiring\b",
r"\bpart.?time\s+(position|job|role|work)\b",
r"\byears?\s+of\s+(teaching|experience)\b",
r"\bsend\s+(you|me)\s+(the\s+)?link\b",
r"\bcomment\s+.*\bsend\b",
r"\bfor\s+free\b",
r"\bmake\s+money\b",
r"\bno\s+coding\b",
]
REQUEST_PATTERNS = [
@@ -549,6 +632,48 @@ FB_SEARCHES = [
"want to build a website for my",
]
TUTORING_SEARCHES = [
"need a tutor",
"looking for a tutor",
"tutor for my child",
"need help with homework",
"private tutor needed",
"looking for tutoring",
"need a math tutor",
"english tutor needed",
"online tutor for",
"lessons for my child",
"help my child with",
"need someone to tutor",
"looking for someone to teach",
"tutoring for my",
"need help learning",
"recommend a tutor",
"anyone know a tutor",
"need a private tutor",
"tutoring services for my child",
"need academic help",
"need a reading tutor",
"looking for piano teacher",
"need help with maths",
"need a science tutor",
"looking for language tutor",
"need programming tutor",
"coding tutor for my",
"sat tutor needed",
"exam preparation tutor",
"need a homeschool tutor",
"tutor near me for",
]
def _search_list_for_query(query: str) -> list[str]:
"""Pick the appropriate search query pool based on the search term."""
tl = query.lower()
tutoring_terms = ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child", "math", "english", "science", "exam", "homeschool", "coding", "programming", "piano", "reading"]
if any(t in tl for t in tutoring_terms):
return TUTORING_SEARCHES
return FB_SEARCHES
VIEWPORTS = [
{'width': 1280, 'height': 800},
{'width': 1366, 'height': 768},
@@ -663,16 +788,31 @@ def _clean_fb_text(text: str) -> str:
cleaned_lines.append(stripped)
return '\n'.join(cleaned_lines)
# Words that should never be treated as person names
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce","press","anonymous","tuition","parents","tutors","advanced","custom","fields","speed","optimization","elementor","woocommerce","acf","availability","january","february","march","april","may","june","july","august","september","october","november","december","mums","dads","uae","sharjah","dubai","abu","dhabi","hong","kong","canada","usa","united","kingdom","aunty","piano","plus","recommendations","needed"}
# ── Post Extraction ──────────────────────────────────────────────────
# Two strategies for extracting posts from page content:
# 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements()
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
# Both apply dedup (seen_texts), offer filtering, and request scoring.
_GROUP_SUFFIXES = [" - South Africa", " - Australia", " - Philippines", " PH", "Group", "help group", "info group", "public group", "private group", "group for", "community", "creators", "marketplace"]
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
posts = []
seen_texts = set()
now = datetime.utcnow()
for el in elements:
# Reject group posts immediately
if el.get('isGroup'):
continue
# Reject posts older than 2 days
ts = el.get('ts', 0)
if ts:
age_seconds = (now.timestamp() * 1000 - ts) / 1000
if age_seconds > 172800: # 2 days
continue
raw_text = el.get('text', '')
if len(raw_text) < 40:
continue
@@ -686,13 +826,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
if dekey in seen_texts:
continue
seen_texts.add(dekey)
# Reject if text contains group-like patterns
lower = text.lower()
if ' / ' in lower:
continue
skip = False
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
request_score = 2 if is_request(text) else 0
post_url = el.get('url') or base_url
# Prefer JS-extracted date, fall back to text parsing
js_date = (el.get('date') or '').strip()
if js_date:
# Try ISO datetime from <time datetime>
try:
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
date_str = dt.strftime('%Y-%m-%d')
@@ -720,6 +870,25 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
posts = []
seen_texts = set()
cur = []
def _find_author(candidate_lines: list[str]) -> str:
for line in reversed(candidate_lines):
ln = line.strip()
if not ln or len(ln) > 60 or len(ln) < 3:
continue
words = ln.split()
if not (1 < len(words) < 8):
continue
if not all(w[0].isalpha() and w[0].isupper() for w in words if w):
continue
lower_ln = ln.lower()
if any(kw in lower_ln for kw in BROAD_KEYWORDS) or is_offer(ln):
continue
if 'http' in ln or '/' in ln or '@' in ln:
continue
return ln
return ''
for l in lines:
words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) < 2:
@@ -735,7 +904,7 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": '',
"author": _find_author(cur[-3:]),
"url": url,
"source": "facebook",
"date": _parse_fb_date(cur + [l]),
@@ -759,7 +928,61 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
cur.append(l)
if len(cur) > 10:
cur.pop(0)
return posts
# Post-process: scan each post's text for inline names
def _scan_inline_name(text: str) -> str:
for m in re.finditer(r'([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,}){1,3})[\s,;:]', text):
cand = m.group(1).strip()
if cand and 3 <= len(cand) <= 60:
lower = cand.lower()
words = lower.split()
if not any(kw in lower for kw in BROAD_KEYWORDS) and not is_offer(cand):
if not any(w in _NON_NAMES for w in words):
return cand
return ''
for p in posts:
if not p.get('author'):
txt = p.get('content') or p.get('title') or ''
name = _scan_inline_name(txt)
if name:
p['author'] = name
# Remove group posts: any post whose text looks like it came from a group
filtered = []
for p in posts:
t = (p.get('title') or p.get('content') or '')
if ' / ' in t:
continue
lower = t.lower()
skip = False
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
filtered.append(p)
# Dedup: merge posts where one is a suffix of another (same post split)
merged = []
for p in filtered:
t = (p.get('title') or p.get('content') or '').lower()
# Check if this post is substantially contained in an already-merged post
found = False
for i, m in enumerate(merged):
mt = (m.get('title') or m.get('content') or '').lower()
# Word-level overlap: count shared words
twords = set(t.split())
mwords = set(mt.split())
if len(twords) > 3 and len(mwords) > 3:
overlap = len(twords & mwords)
smaller = min(len(twords), len(mwords))
if overlap / smaller >= 0.6:
# Keep the longer one
if len(t) > len(mt):
merged[i] = p
found = True
break
if not found:
merged.append(p)
return merged
# ── Human-like Behavior Simulation ──────────────────────────────────
# These functions add random delays, mouse movements, and scroll patterns
@@ -811,58 +1034,40 @@ async def _get_article_elements(page) -> list[dict]:
return await page.evaluate('''() => {
const results = [];
const seenTexts = new Set();
const selectors = [
'div[role="article"]',
'div[role="feed"] > div',
'div.x1yztbdb',
'div[data-pagelet]',
];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
for (const el of els) {
const text = (el.innerText || '').trim();
if (text.length < 40) continue;
const key = text.substring(0, 80);
if (seenTexts.has(key)) continue;
seenTexts.add(key);
const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce","tutor","tutoring","homeschool","math","reading","english","science","grade"];
const now = Date.now();
const DAY_MS = 86400000;
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
for (const a of anchors) {
const h = a.getAttribute('href') || '';
if (!h) continue;
const cell = a.closest('div[style]') || a.parentElement;
if (!cell) continue;
const txt = (cell.innerText || '').trim();
if (txt.length < 40) continue;
const lower = txt.toLowerCase();
let matched = false;
for (const t of terms) { if (lower.includes(t)) { matched = true; break; } }
if (!matched) continue;
const key = txt.substring(0, 80);
if (seenTexts.has(key)) continue;
seenTexts.add(key);
// --- Publisher name ---
let author = '';
const nameEl = el.querySelector('h4, strong, a[role="link"]');
if (nameEl) {
author = (nameEl.innerText || '').trim();
}
if (!author) {
const links = el.querySelectorAll('a');
for (const a of links) {
const t = (a.innerText || '').trim();
if (t && t.length > 1 && t.length < 60 && /^[A-Za-zÀ-ÿ]/.test(t) && !t.includes('facebook') && !t.includes('/')) {
author = t;
break;
}
}
}
// --- Post URL ---
let postUrl = '';
for (const a of el.querySelectorAll('a')) {
const h = (a.getAttribute('href') || '');
if (h.includes('/posts/') || h.includes('/photo/') || h.includes('/video/') || h.includes('/groups/') || h.includes('/permalink/')) {
postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
break;
}
}
// --- Date from <time datetime> ---
let date = '';
const timeEl = el.querySelector('time');
if (timeEl) {
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
}
results.push({ text, author, url: postUrl, date });
const isGroup = h.includes('/groups/');
let author = '';
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) author = (nameEl.innerText || '').trim();
if (!author || author.length > 60 || author.length < 2) author = '';
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
let date = '';
let ts = 0;
const timeEl = cell.querySelector('time');
if (timeEl) {
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
const dtVal = timeEl.getAttribute('datetime');
if (dtVal) { ts = new Date(dtVal).getTime(); }
}
if (results.length > 0) break;
results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
}
return results;
}''')
@@ -896,15 +1101,11 @@ async def search_facebook(page, context, query: str):
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
try:
await page.wait_for_selector('div[role="article"], div[role="feed"]', timeout=15000)
except Exception:
pass
await page.wait_for_timeout(random.randint(3000, 8000))
await page.wait_for_timeout(random.randint(5000, 10000))
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
await human_scroll(page, steps=random.randint(4, 7), total_delay=random.uniform(12, 25))
if random.random() < 0.1:
if random.random() < 0.3:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await page.wait_for_timeout(random.randint(1000, 3000))
await page.evaluate("window.scrollBy(0, -300)")
@@ -919,9 +1120,102 @@ async def search_facebook(page, context, query: str):
raw_articles = await _get_article_elements(page)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
if not posts:
raw = await page.evaluate('document.body.innerText')
posts = _extract_posts_from_text(raw, url)
raw = await page.evaluate('document.body.innerText')
text_posts = _extract_posts_from_text(raw, url)
existing = {(p.get('title') or p.get('content',''))[:80] for p in posts}
for tp in text_posts:
key = (tp.get('title') or tp.get('content',''))[:80]
if key not in existing:
posts.append(tp)
if posts:
# Extract author names from profile links in the DOM
try:
profiles = await page.evaluate(r'''() => {
const out = [];
const seenTxt = new Set();
for (const a of document.querySelectorAll('a[href*="/profile.php"], a[href*="/user/"], a[href*="/people/"], a[href*="/me/"]')) {
const name = (a.innerText || '').trim();
if (!name || name.length < 3 || name.length > 60) continue;
const words = name.split(' ');
if (words.length < 2 || words.length > 6) continue;
if (!/^[A-Z]/.test(name)) continue;
if (name.includes('facebook') || name.includes('/')) continue;
const cell = a.closest('div[style]') || a.parentElement;
const txt = cell ? (cell.innerText || '').substring(0, 200) : '';
if (!txt) continue;
const key = txt.substring(0, 80);
if (seenTxt.has(key)) continue;
seenTxt.add(key);
out.push({ name, textKey: key });
}
return out;
}''')
if profiles:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:80].strip()
if not pk: continue
for pr in profiles:
if pk[:30] in pr['textKey'] or pr['textKey'][:30] in pk:
if not p.get('author'):
p['author'] = pr['name']
break
except Exception:
pass
try:
meta = await page.evaluate(r'''() => {
const out = [];
const seenHref = new Set();
const all = document.querySelectorAll('a[href]');
for (const a of all) {
const h = a.href;
if (!h || seenHref.has(h)) continue;
const u = new URL(h);
if (!u.hostname.includes('facebook.com') && !u.hostname.includes('fb.com')) continue;
const path = u.pathname;
if (path === '/' || path === '/search/' || path === '/marketplace/' || path.startsWith('/messages/') || path.startsWith('/notifications/') || path.startsWith('/settings/')) continue;
const isPost = path.includes('/posts/') || path.includes('/videos/') || path.includes('/photos/') || path.startsWith('/story.php') || path.startsWith('/photo.php') || path.startsWith('/watch') || path.includes('/reel/') || path.startsWith('/permalink/');
if (!isPost) continue;
const cell = a.closest('[style*="position"], [role="article"], div[data-pagelet], div.x1yztbdb, div.x78zum5, div.x1n2onr6') || a.parentElement;
if (!cell) continue;
const txt = (cell.innerText || '').trim();
if (txt.length < 40) continue;
let author = '';
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) author = (nameEl.innerText || '').trim();
if (!author || author.length > 60) author = '';
const key = txt.substring(0, 100);
seenHref.add(h);
out.push({ key, href: h, author, textMap: txt.substring(0,200) });
}
return out;
}''')
if meta:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:100].strip()
if not pk: continue
for m in meta:
mk = m.get('key') or ''
if pk[:40] in mk or mk[:40] in pk:
if m.get('href') and 'search/' not in m['href']:
p['url'] = m['href']
if m.get('author') and not p.get('author'):
p['author'] = m['author']
break
except Exception:
pass
# Clean up: remove group names passed off as authors
for p in posts:
if p.get('author'):
a = p['author']
al = a.lower()
if any(kw in al for kw in BROAD_KEYWORDS) or is_offer(a) or len(a.split()) < 2 or any(w in _NON_NAMES for w in al.split()):
p['author'] = ''
# Final filter: strip any remaining group posts
posts = [p for p in posts if not (
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
or '/pages/' in p.get('url', '')
or ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e)
return page, []
@@ -971,7 +1265,7 @@ def cleanup_chrome():
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
# - If no profile found → Agent (browser-use + ChatOllama) fallback
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
async def scrape_facebook(profile_path: str | None = None, force: bool = False, query: str | None = None) -> dict:
"""Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
effective_path = profile_path or ""
browser_type = ""
@@ -1013,14 +1307,14 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
# Firefox path
if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force)
result = await _scrape_with_firefox(effective_path, force, query)
if result.get("success") or not result.get("flagged"):
return result
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
# Chromium-based (chrome / opera / edge)
result = await _scrape_with_chromium(effective_path, browser_type, force)
result = await _scrape_with_chromium(effective_path, browser_type, force, query)
if result.get("success") or not result.get("flagged"):
return result
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
@@ -1035,7 +1329,7 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
# human browsing). If flagged by Facebook, returns flagged=True for the
# dispatcher to fall through to the Agent.
async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None = None) -> dict:
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
@@ -1089,9 +1383,13 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, query in enumerate(searches):
page, posts = await search_facebook(page, context, query)
if query:
query_pool = _search_list_for_query(query)
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts)
if not posts:
continue
@@ -1142,6 +1440,12 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
pass
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ─────────────────────────────────────────────────
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
# Firefox scraper but with Chromium-specific launch config:
@@ -1152,7 +1456,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
# Anti-detection script differs slightly from Firefox variant.
# Same decoy-skip and flagged-fallback behavior as Firefox.
async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False) -> dict:
async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False, query: str | None = None) -> dict:
"""Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera)."""
if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
@@ -1214,9 +1518,13 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, query in enumerate(searches):
page, posts = await search_facebook(page, context, query)
if query:
query_pool = _search_list_for_query(query)
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts)
if not posts:
continue
@@ -1387,6 +1695,8 @@ NOT LEAD:
- Recruiting employees, hiring staff, looking for business partners
- Selling products, promoting services, affiliate offers
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
- Posts from groups, communities, or pages (group announcements, group posts, page posts)
- Posts containing the word "group", "page", "community", "creators" — these are NEVER individual leads
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
@@ -1449,15 +1759,57 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
"price for",
]
keyword_leads: list[dict] = []
offer_reject = [
'i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting',
'i do web design', 'i develop websites',
'do you need a', 'do you want a', 'we can build',
'i can build', 'my portfolio', 'reasonable price',
'whatsapp me', 'looking for a business', 'looking for client',
'help your business', 'i am a web', 'contact me',
'we offer web', 'we provide web',
'take the quiz', 'homeschool', 'your home tutor',
'link in bio', 'apply now', 'get started',
'for only', 'low price', 'hit me up',
'send me a message', 'i do website', 'we do website',
'we do web', 'i do web',
'website designer / web developer', 'website & software creators',
'website builders for small businesses', 'australia web designers',
'south africa', 'wix website design',
'for sale', 'selling my', 'premium',
'i\'m selling', 'i\'m offering', 'we\'re offering',
'free ecommerce', 'free website design',
'starting a', 'looking for a few businesses',
# Group-related rejections
'group', ' i need a website group', 'south africa web', 'philippines web', 'australia web',
'i can help', 'inbox me', 'dm me', 'pm me', 'message me for',
'best price', 'discount', 'reach out', 'check out my', 'check this',
'website for your', 'price start', 'price begin', 'website creator',
'website & software', 'creators &', 'creators marketplace',
'website group', 'page group',
# Self-promotion rejections
'i\'m a web', "i'm a web", 'i am a full stack', "i'm a full stack", 'i\'m a full stack',
'freelance opportunity', 'looking for new project', 'looking for new work',
'full stack web', 'mern stack', 'responsive business website',
'i build website', 'i build shopify', 'i build wordpress',
'we build website', 'we build shopify', 'we build wordpress',
'i create website', 'we create website',
'full time position', 'full time job', 'remote position', 'remote job',
'help wanted', 'now hiring', 'job opening',
'website speed', 'speed optimization', 'on page seo', 'off page seo',
'elementor pro', 'woocommerce', 'acf', 'custom post type',
'send you the link', 'comment website',
'for free', 'no coding', 'make money', 'website for free',
'part time job', 'part time position',
'years of experience', 'years of teaching',
]
for r in results:
t = r['title'].lower()
has_web = any(kw in t for kw in web_terms)
has_request = any(kw in t for kw in request_terms)
if not has_web or not has_request:
continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting',
'i do web design', 'i develop websites']):
if any(kw in t for kw in offer_reject):
continue
keyword_leads.append(r)
@@ -1469,6 +1821,10 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
if key not in seen_titles:
seen_titles.add(key)
merged.append(r)
# Final sweep: strip any remaining offers or group posts from merged
group_words = ['group', ' groups', 'page:', 'page |', 'community', 'creators', 'marketplace']
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
# Fill to 5 with loose keyword matches (at least web OR request term)
if len(merged) < 5:
@@ -1477,11 +1833,16 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
if key in seen_titles:
continue
t = r['title'].lower()
if any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms):
seen_titles.add(key)
merged.append(r)
if len(merged) >= 5:
break
if not (any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms)):
continue
if any(kw in t for kw in offer_reject):
continue
if any(gw in t for gw in group_words):
continue
seen_titles.add(key)
merged.append(r)
if len(merged) >= 5:
break
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
return merged[:10]
@@ -1601,8 +1962,8 @@ async def agent_run(task: str = Body(..., embed=True)):
return {"success": False, "error": str(e)}
@app.post("/scrape/facebook")
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
result = await scrape_facebook(profile_path, force)
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False), query: str | None = Query(None)):
result = await scrape_facebook(profile_path, force, query)
return result
if __name__ == "__main__":
+2 -10
View File
@@ -1,10 +1,2 @@
{"job_title":"Software Developer","keywords":["developer","programmer","software engineer","coder","full stack","backend","frontend"],"industry":"Technology","description":"Builds and maintains software applications and systems"}
{"job_title":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
{"job_title":"Sales Representative","keywords":["sales rep","account executive","business development","sales consultant"],"industry":"Sales","description":"Drives revenue through client acquisition and relationship management"}
{"job_title":"Project Manager","keywords":["project manager","program manager","scrum master","agile coach"],"industry":"Business","description":"Oversees project timelines, resources, and deliverables"}
{"job_title":"Graphic Designer","keywords":["designer","graphic designer","ui designer","ux designer","visual designer"],"industry":"Creative","description":"Creates visual concepts and designs for digital and print media"}
{"job_title":"Data Analyst","keywords":["data analyst","business analyst","data scientist","analytics"],"industry":"Technology","description":"Analyzes data to provide actionable business insights"}
{"job_title":"Customer Support Specialist","keywords":["customer support","customer service","support agent","help desk"],"industry":"Customer Service","description":"Assists customers with inquiries, issues, and product support"}
{"job_title":"Human Resources Manager","keywords":["HR manager","HR","recruiter","talent acquisition","people operations"],"industry":"Human Resources","description":"Manages recruitment, employee relations, and HR operations"}
{"job_title":"Financial Advisor","keywords":["financial advisor","financial planner","wealth manager","investment advisor"],"industry":"Finance","description":"Provides financial guidance and investment planning to clients"}
{"job_title":"Operations Manager","keywords":["operations manager","operations","logistics","supply chain"],"industry":"Business","description":"Oversees daily operations and process optimization"}
{"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
{"job_title":"Tutoring","keywords":["need a tutor","looking for a tutor","tutor for my child","need help with homework","private tutor needed","looking for tutoring","need a math tutor","english tutor needed","online tutor for","lessons for my child","help my child with","need someone to tutor","looking for someone to teach","tutoring for my","need help learning","recommend a tutor","anyone know a tutor","need a private tutor","tutoring services for my child","need academic help"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
+31
View File
@@ -0,0 +1,31 @@
-- ============================================================================
-- Fixes: password_change_required + audit trigger UUID cast
-- ============================================================================
-- 1. All users use the passwords already set in the seed — no forced change
UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE;
-- 2. Fix audit_password_change trigger: current_setting returns TEXT but
-- audit_logs.changed_by is UUID — cast to UUID to prevent type error
CREATE OR REPLACE FUNCTION audit_password_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
INSERT INTO audit_logs (
table_name, record_id, action, old_data, new_data, changed_by, ip_address
) VALUES (
'users',
NEW.id,
'UPDATE',
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
NULLIF(current_setting('app.current_ip', true), '')
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug)
ALTER TABLE users ENABLE TRIGGER trg_audit_password_change;
@@ -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);
+1 -1
View File
@@ -9,7 +9,7 @@
"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",
+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"
}
}
+184
View File
@@ -0,0 +1,184 @@
// ── Automated Database Migration Runner ──────────────────────────────
// Reads .env.local for DATABASE_URL, tracks applied migrations in
// a _migrations table, and runs unapplied .sql files via psql.
// Runs automatically on npm run dev and npm run setup.
// ============================================================================
import { readFileSync, existsSync } from "node:fs"
import { execSync } from "node:child_process"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, "..")
// ── Load .env.local into process.env ─────────────────────────────────
const envPath = resolve(ROOT, ".env.local")
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eq = trimmed.indexOf("=")
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
if (!process.env[key]) process.env[key] = value
}
}
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
console.log(" ~ DATABASE_URL not set — skipping migrations")
process.exit(0)
}
// ── Find psql ────────────────────────────────────────────────────────
function findPsql() {
try {
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
return "psql"
} catch {}
const candidates = [
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
]
for (const p of candidates) {
if (existsSync(p)) return `"${p}"`
}
return null
}
const PSQL = findPsql()
if (!PSQL) {
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
process.exit(0)
}
// ── Database connection (for tracking table) ─────────────────────────
const require = createRequire(import.meta.url)
const { Pool } = require("pg")
let pool
try {
pool = new Pool({
connectionString: DATABASE_URL,
max: 1,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 10000,
})
await pool.query("SELECT 1")
} catch {
console.log(" ~ Database not available — skipping migrations")
process.exit(0)
}
// ── Migration logic ──────────────────────────────────────────────────
async function runMigrations() {
// 1. Create tracking table if not exists
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// 2. Determine file order from run_all.sql (authoritative)
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
const runAllContent = readFileSync(runAllPath, "utf-8")
const fileOrder = []
for (const line of runAllContent.split("\n")) {
const match = line.match(/\\i\s+(\S+\.sql)/)
if (match) fileOrder.push(match[1])
}
if (fileOrder.length === 0) {
console.log(" ~ No migration files found in run_all.sql")
return
}
// 3. Get already-applied files
let appliedSet
try {
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
appliedSet = new Set(applied.map((r) => r.filename))
} catch {
appliedSet = new Set()
}
// 4. Build psql command base
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
// 5. Run unapplied files in order.
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
// If the error is "already exists", the file was already applied before
// tracking existed — mark it as applied and continue.
// Other errors are genuine failures — abort.
const ALREADY_APPLIED_PATTERNS = [
"already exists",
"duplicate key",
"cannot insert multiple commands into a prepared statement",
]
let count = 0
for (const file of fileOrder) {
if (appliedSet.has(file)) continue
const filePath = resolve(ROOT, "database", "migrations", file)
if (!existsSync(filePath)) {
console.error(` ✗ Migration file not found: ${file}`)
process.exit(1)
}
let stderr = ""
try {
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
timeout: 30000,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
})
// psql outputs notices/warnings to stderr even on success
stderr = result.stderr || ""
} catch (err) {
const msg = err.stderr || err.stdout || err.message
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
if (isAlreadyApplied) {
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
console.log(` ~ Already applied: ${file}`)
count++
continue
}
console.error(` ✗ Migration failed: ${file}`)
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
process.exit(1)
}
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
console.log(` ✓ Applied: ${file}`)
count++
}
if (count === 0) {
console.log(" ~ All migrations up to date")
}
}
// ── Run ──────────────────────────────────────────────────────────────
try {
await runMigrations()
} finally {
await pool.end()
}
+11 -2
View File
@@ -202,7 +202,16 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── .env.local already exists, skipping ──")
}
// 5. Self-healing build check (--self-heal flag)
// 5. Database migrations (auto-applies on fresh install)
console.log("\n── Running Database Migrations ──")
try {
execSync("node scripts/run-migrations.mjs", { stdio: "inherit", timeout: 30000, cwd: ROOT })
console.log(" ✓ Migrations complete")
} catch {
console.log(" ~ Database not available — run migrations later with: npm run dev")
}
// 6. Self-healing build check (--self-heal flag)
if (doSelfHeal) {
console.log("\n── Self-Healing Build Check ──")
const { generateModule } = loadRegistry()
@@ -252,7 +261,7 @@ if (doSelfHeal) {
}
}
// 6. Final summary
// 7. Final summary
console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
+133 -5
View File
@@ -1,7 +1,7 @@
"use client"
import { useState, useCallback, useRef, useEffect } from "react"
import { AIChat } from "@/components/ai/ai-chat"
import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector"
import { Bot, ChevronRight, Wifi, WifiOff } from "lucide-react"
@@ -17,7 +17,32 @@ export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
const [aiOnline, setAiOnline] = useState<boolean | null>(null)
const aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null)
const [searching, setSearching] = useState(false)
const [msgCount, setMsgCount] = useState(0)
const [tokenEstimate, setTokenEstimate] = useState("0")
const aiChatRef = useRef<AIChatHandle | null>(null)
useEffect(() => {
const saved = localStorage.getItem("ai-chat-messages")
if (saved) {
try {
const msgs = JSON.parse(saved)
if (Array.isArray(msgs)) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayMsgs = msgs.filter((m: any) => {
if (!m.ts) return true
return new Date(m.ts).getTime() >= today.getTime()
})
const userMsgs = todayMsgs.filter((m: any) => m.role === "user")
setMsgCount(userMsgs.length)
const totalChars = todayMsgs.reduce((s: number, m: any) => s + (m.content?.length || 0), 0)
const tokens = Math.round(totalChars / 4)
setTokenEstimate(tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens))
}
} catch {}
}
}, [])
const handleJobSelect = useCallback((job: typeof selectedJob) => {
setSelectedJob(job)
@@ -38,6 +63,103 @@ export default function AIAssistantPage() {
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)
}, [])
@@ -51,6 +173,12 @@ export default function AIAssistantPage() {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
return next
})
setMsgCount((prev) => prev + 1)
setTokenEstimate((prev) => {
const raw = parseInt(prev.replace("k", "")) * (prev.includes("k") ? 1000 : 1)
const updated = raw + Math.round(msg.length / 4)
return updated >= 1000 ? `${(updated / 1000).toFixed(1)}k` : String(updated)
})
}, [])
return (
@@ -95,7 +223,7 @@ export default function AIAssistantPage() {
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</div>
<JobSelector onSelect={handleJobSelect} />
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
{selectedJob && (
<div className="bg-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>
@@ -148,11 +276,11 @@ export default function AIAssistantPage() {
<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">24</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">12.4k</div>
<div className="text-foreground text-sm font-semibold mt-0.5">{tokenEstimate}</div>
</div>
</div>
</div>
+5 -5
View File
@@ -126,11 +126,11 @@ export default function CalendarPage() {
fetch("/api/event-users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
.catch((err) => console.error("Failed to fetch event users:", err))
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
.catch((err) => console.error("Failed to fetch leads:", err))
}, [user, router])
useEffect(() => {
@@ -139,13 +139,13 @@ export default function CalendarPage() {
}, [fetchMonthEvents])
useEffect(() => {
fetchUpcomingEvents().catch(() => {})
fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
}, [fetchUpcomingEvents])
useEffect(() => {
const interval = setInterval(() => {
fetchMonthEvents().catch(() => {})
fetchUpcomingEvents().catch(() => {})
fetchMonthEvents().catch((err) => console.error("Failed to fetch month events:", err))
fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
}, 15000)
return () => clearInterval(interval)
}, [fetchMonthEvents, fetchUpcomingEvents])
+14 -8
View File
@@ -39,8 +39,11 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
const searchLeads = async (q: string) => {
setLeadSearch(q)
if (q.length < 2) { setSearchResults([]); return }
const res = await fetch(`/api/leads?search=${encodeURIComponent(q)}&limit=10`)
if (res.ok) setSearchResults(await res.json())
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 () => {
@@ -84,12 +87,15 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
}
const updateStatus = async (status: string) => {
await fetch(`/api/campaigns/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
})
fetchCampaign()
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>
+9 -9
View File
@@ -235,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)
}
@@ -297,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()
@@ -320,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)
@@ -799,8 +799,8 @@ const formatPreviewContent = (content: string) => {
prev.map((c) => (c.id === convId ? { ...c, pinned: !pinned } : c))
)
}
} catch {
console.warn("Failed to toggle pin")
} catch (err) {
console.error("Failed to toggle pin:", err)
}
}
@@ -912,7 +912,7 @@ const formatPreviewContent = (content: string) => {
setSearchQuery("")
}
}
} catch { console.warn("Failed to create conversation in chats page") }
} catch (err) { console.error("Failed to create conversation:", err) }
}}
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
>
+2 -2
View File
@@ -39,8 +39,8 @@ export default function DashboardPage() {
const data = await res.json()
setStats(data)
}
} catch {
console.warn("Failed to fetch dashboard stats")
} catch (err) {
console.error("Failed to fetch dashboard stats:", err)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default function EmailsPage() {
useEffect(() => {
if (!user) { router.push("/login"); return }
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || []))
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || [])).catch((err) => console.error("Failed to fetch emails:", err))
}, [user, router])
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
+2 -2
View File
@@ -39,8 +39,8 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
fetchNotes(),
])
if (leadRes.ok) setLead(await leadRes.json())
} catch {
console.warn("Failed to fetch lead details")
} catch (err) {
console.error("Failed to fetch lead details:", err)
}
setLoading(false)
}
+19 -28
View File
@@ -42,33 +42,24 @@ interface Totals {
teamUtilization: number
}
function utilizationColor(pct: number): string {
if (pct > 100) return "bg-red-500"
if (pct > 90) return "bg-orange-500"
if (pct > 70) return "bg-yellow-500"
return "bg-emerald-500"
}
function utilizationTextColor(pct: number): string {
if (pct > 100) return "text-red-600"
if (pct > 90) return "text-orange-600"
if (pct > 70) return "text-yellow-600"
return "text-emerald-600"
}
function utilizationBgColor(pct: number): string {
if (pct > 100) return "bg-red-50 border-red-200"
if (pct > 90) return "bg-orange-50 border-orange-200"
if (pct > 70) return "bg-yellow-50 border-yellow-200"
return "bg-emerald-50 border-emerald-200"
function statusKey(pct: number): string {
if (pct > 100) return "overloaded"
if (pct > 90) return "near-capacity"
if (pct > 70) return "busy"
if (pct > 50) return "available"
return "underutilized"
}
function statusLabel(pct: number): { label: string; color: string } {
if (pct > 100) return { label: "Overloaded", color: "bg-red-100 text-red-700 border-red-300" }
if (pct > 90) return { label: "Near Capacity", color: "bg-orange-100 text-orange-700 border-orange-300" }
if (pct > 70) return { label: "Busy", color: "bg-yellow-100 text-yellow-700 border-yellow-300" }
if (pct > 50) return { label: "Available", color: "bg-emerald-100 text-emerald-700 border-emerald-300" }
return { label: "Underutilized", color: "bg-slate-100 text-slate-600 border-slate-300" }
const key = statusKey(pct)
const labels: Record<string, string> = {
overloaded: "Overloaded",
"near-capacity": "Near Capacity",
busy: "Busy",
available: "Available",
underutilized: "Underutilized",
}
return { label: labels[key], color: `util-badge-${key}` }
}
export default function ResourcePlanningPage() {
@@ -120,7 +111,7 @@ export default function ResourcePlanningPage() {
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<TrendingUp className="h-4 w-4" /> Utilisation
</div>
<p className={`text-2xl font-bold ${utilizationTextColor(totals?.teamUtilization ?? 0)}`}>
<p className={`text-2xl font-bold util-text-${statusKey(totals?.teamUtilization ?? 0)}`}>
{totals?.teamUtilization ?? 0}%
</p>
</Card>
@@ -157,7 +148,7 @@ export default function ResourcePlanningPage() {
{members.map((m) => {
const status = statusLabel(m.utilization)
return (
<Card key={m.id} className={`p-4 space-y-3 border ${utilizationBgColor(m.utilization)}`}>
<Card key={m.id} className={`p-4 space-y-3 border util-card-${statusKey(m.utilization)}`}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
@@ -179,11 +170,11 @@ export default function ResourcePlanningPage() {
<div className="flex justify-between text-xs text-muted-foreground">
<span>Capacity: {m.capacityHours}h</span>
<span>Logged: {m.totalHours}h</span>
<span className={utilizationTextColor(m.utilization)}>{m.utilization}%</span>
<span className={`util-text-${statusKey(m.utilization)}`}>{m.utilization}%</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${utilizationColor(m.utilization)}`}
className={`h-full rounded-full transition-all util-bar-${statusKey(m.utilization)}`}
style={{ width: `${Math.min(m.utilization, 100)}%` }}
/>
</div>
+2 -2
View File
@@ -24,8 +24,8 @@ export default function UsersPage() {
const data = await res.json()
setUsers(data.users || [])
}
} catch {
console.warn("Failed to fetch users in users page")
} catch (err) {
console.error("Failed to fetch users:", err)
} finally {
setLoading(false)
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server"
import crypto from "crypto"
import { query } from "@/lib/db"
import { getUserByEmail } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email } = await request.json()
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 })
}
const user = await getUserByEmail(email)
if (!user) {
return NextResponse.json({ message: "If that email exists, a reset link has been generated." })
}
const token = crypto.randomBytes(32).toString("hex")
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await query(
`INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)`,
[user.id, token, expiresAt],
)
const resetUrl = `${request.nextUrl.origin}/reset-password/${token}`
return NextResponse.json({ message: "If that email exists, a reset link has been generated.", resetUrl })
} catch (error) {
console.error("Forgot password error:", error)
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { token, password } = await request.json()
if (!token || !password) {
return NextResponse.json({ error: "Token and password are required" }, { status: 400 })
}
if (password.length < 6) {
return NextResponse.json({ error: "Password must be at least 6 characters" }, { status: 400 })
}
const result = await query(
`SELECT id, user_id, expires_at FROM password_reset_tokens
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
[token],
)
const row = result.rows[0]
if (!row) {
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 })
}
const passwordHash = await hashPassword(password)
await query(`UPDATE users SET password_hash = $1, password_change_required = FALSE WHERE id = $2`, [passwordHash, row.user_id])
await query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id])
return NextResponse.json({ message: "Password has been reset successfully." })
} catch (error) {
console.error("Reset password error:", error)
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const currentUser = await getSessionUser()
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const q = request.nextUrl.searchParams.get("q") || ""
if (!q.trim()) return NextResponse.json({ results: [] })
const like = `%${q}%`
const [leadsResult, projectsResult, proposalsResult, conversationsResult] = await Promise.all([
query(
`SELECT id, company_name AS title, 'lead' AS type FROM leads WHERE deleted_at IS NULL AND company_name ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, name AS title, 'project' AS type FROM projects WHERE deleted_at IS NULL AND name ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, title, 'proposal' AS type FROM proposals WHERE deleted_at IS NULL AND title ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, title, 'conversation' AS type FROM conversations WHERE title ILIKE $1 LIMIT 5`,
[like],
),
])
const results = [
...leadsResult.rows,
...projectsResult.rows,
...proposalsResult.rows,
...conversationsResult.rows,
]
return NextResponse.json({ results })
} catch (error) {
console.error("Search error:", error)
return NextResponse.json({ error: "Search failed" }, { status: 500 })
}
}
+25 -7
View File
@@ -8,12 +8,13 @@ export async function GET() {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
[user.id],
)
const websiteTheme = result.rows[0]?.website_theme || "default"
return NextResponse.json({ websiteTheme })
const colorTheme = result.rows[0]?.color_theme || null
return NextResponse.json({ websiteTheme, colorTheme })
} catch (error) {
console.error("Website theme GET error:", error)
const message = error instanceof Error ? error.message : "Failed to load website theme"
@@ -27,14 +28,31 @@ export async function PUT(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const theme = body.websiteTheme || "default"
const update: Record<string, string> = {}
if (body.websiteTheme !== undefined) {
update.website_theme = body.websiteTheme || "default"
}
if (body.colorTheme !== undefined) {
update.color_theme = body.colorTheme || "default"
}
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
[user.id, JSON.stringify({ website_theme: theme })],
if (Object.keys(update).length > 0) {
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
[user.id, JSON.stringify(update)],
)
}
const result = await query(
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
[user.id],
)
return NextResponse.json({ success: true, websiteTheme: theme })
return NextResponse.json({
success: true,
websiteTheme: result.rows[0]?.website_theme || "default",
colorTheme: result.rows[0]?.color_theme || null,
})
} catch (error) {
console.error("Website theme PUT error:", error)
const message = error instanceof Error ? error.message : "Failed to save website theme"
+15 -1
View File
@@ -22,12 +22,14 @@ interface Event {
export default function ClientActivity() {
const router = useRouter()
const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("/client-portal/api/activity", { credentials: "include" })
.then((r) => r.json())
.then((d) => setEvents(d.events || []))
.catch(() => {})
.catch((err) => console.error("Failed to fetch activity:", err))
.finally(() => setLoading(false))
}, [])
const getIcon = (type: string) => {
@@ -57,6 +59,18 @@ export default function ClientActivity() {
return date.toLocaleDateString([], { month: "short", day: "numeric" })
}
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
<p className="text-sm text-muted-foreground mb-6">Recent project updates and changes</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
+31 -20
View File
@@ -14,6 +14,7 @@ interface Deadline {
export default function ClientDashboard() {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 })
const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] })
const [name, setName] = useState("")
@@ -21,30 +22,32 @@ export default function ClientDashboard() {
const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject()
useEffect(() => {
fetch("/client-portal/api/projects", { credentials: "include" })
.then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 })))
.catch(() => {})
Promise.allSettled([
fetch("/client-portal/api/projects", { credentials: "include" })
.then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 })))
.catch((err) => console.error("Failed to fetch projects:", err)),
fetch("/client-portal/api/invoices", { credentials: "include" })
.then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 })))
.catch(() => {})
fetch("/client-portal/api/invoices", { credentials: "include" })
.then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 })))
.catch((err) => console.error("Failed to fetch invoices:", err)),
fetch("/client-portal/api/support", { credentials: "include" })
.then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 })))
.catch(() => {})
fetch("/client-portal/api/support", { credentials: "include" })
.then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 })))
.catch((err) => console.error("Failed to fetch tickets:", err)),
fetch("/client-portal/api/dashboard/stats", { credentials: "include" })
.then(safeJson)
.then((d) => setStats(d))
.catch(() => {})
fetch("/client-portal/api/dashboard/stats", { credentials: "include" })
.then(safeJson)
.then((d) => setStats(d))
.catch((err) => console.error("Failed to fetch stats:", err)),
fetch("/api/auth/me", { credentials: "include" })
.then(safeJson)
.then((d) => setName(d.user?.firstName || d.user?.email || "Client"))
.catch(() => {})
fetch("/api/auth/me", { credentials: "include" })
.then(safeJson)
.then((d) => setName(d.user?.firstName || d.user?.email || "Client"))
.catch((err) => console.error("Failed to fetch user:", err)),
]).finally(() => setLoading(false))
}, [])
const cards = [
@@ -58,6 +61,14 @@ export default function ClientDashboard() {
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Welcome, {name}</h1>
+1 -10
View File
@@ -12,7 +12,6 @@ export default function ClientLoginPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [checking, setChecking] = useState(true)
const [debugInfo, setDebugInfo] = useState("")
const [sessionUser, setSessionUser] = useState<{ email: string; role: string } | null>(null)
@@ -38,7 +37,6 @@ export default function ClientLoginPage() {
e.preventDefault()
setLoading(true)
setError("")
setDebugInfo("")
try {
const res = await fetch("/api/auth/login", {
@@ -48,27 +46,21 @@ export default function ClientLoginPage() {
})
const data = await res.json()
console.log("LOGIN RESPONSE:", res.status, data)
if (!res.ok) {
setError(data.error || "Login failed")
setDebugInfo(`HTTP ${res.status}`)
return
}
if (data.user?.role !== "client") {
setError("This portal is for clients only")
setDebugInfo(`User role: ${data.user?.role}`)
return
}
setError("Login successful! Redirecting...")
setDebugInfo("")
setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300)
} catch (err) {
console.error("LOGIN FETCH ERROR:", err)
} catch {
setError("Connection error")
setDebugInfo("Check network tab for details")
} finally {
setLoading(false)
}
@@ -147,7 +139,6 @@ export default function ClientLoginPage() {
{error !== "Login successful! Redirecting..." && <AlertCircle className="h-4 w-4 shrink-0" />}
<span className="font-medium">{error}</span>
</div>
{debugInfo && <div className="mt-1 text-xs opacity-60">{debugInfo}</div>}
</div>
)}
+1 -1
View File
@@ -40,7 +40,7 @@ export default function ClientProfile() {
setProfile(d.profile || null)
setContacts(d.contacts || [])
})
.catch(() => {})
.catch((err) => console.error("Failed to fetch profile:", err))
}, [])
if (!profile) {
+15 -1
View File
@@ -18,14 +18,28 @@ interface Project {
export default function ClientProjects() {
const router = useRouter()
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("/client-portal/api/projects", { credentials: "include" })
.then((r) => r.json())
.then((d) => setProjects(d.projects || []))
.catch(() => {})
.catch((err) => console.error("Failed to fetch projects:", err))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
<p className="text-sm text-muted-foreground mb-6">View your project milestones and progress</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
+15 -1
View File
@@ -15,14 +15,28 @@ interface Receipt {
export default function ClientReceipts() {
const [receipts, setReceipts] = useState<Receipt[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("/client-portal/api/receipts", { credentials: "include" })
.then((r) => r.json())
.then((d) => setReceipts(d.receipts || []))
.catch(() => {})
.catch((err) => console.error("Failed to fetch receipts:", err))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
<p className="text-sm text-muted-foreground mb-6">View all your payment receipts in one place</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
+1
View File
@@ -36,6 +36,7 @@ const bgOptions = [
{ value: "bw", label: "Black & White", color: "bg-gradient-to-r from-gray-900 to-gray-400", ring: "ring-gray-500", desc: "Clean monochrome" },
{ value: "forest", label: "Forest", color: "bg-gradient-to-r from-green-700 to-green-400", ring: "ring-green-600", desc: "Earthy greens with tree silhouettes" },
{ value: "luminous", label: "Luminous", color: "bg-gradient-to-r from-blue-500 to-teal-400", ring: "ring-blue-500", desc: "Deep Glass 2.0 with specular highlights" },
{ value: "cuba", label: "Cuba", color: "bg-gradient-to-r from-orange-500 to-turquoise-400", ring: "ring-orange-500", desc: "Classic Cuban car on a playa at golden hour" },
]
function getStoredColorTheme(): string {
+1 -1
View File
@@ -35,7 +35,7 @@ export default function ClientSupport() {
fetch("/client-portal/api/support", { credentials: "include" })
.then((r) => r.json())
.then((d) => setTickets(d.tickets || []))
.catch(() => {})
.catch((err) => console.error("Failed to fetch tickets:", err))
.finally(() => setLoading(false))
}
+110
View File
@@ -0,0 +1,110 @@
"use client"
import { useState } from "react"
import { Mail, ArrowLeft, CheckCircle, AlertCircle, Loader2 } from "lucide-react"
import Link from "next/link"
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [sent, setSent] = useState(false)
const [resetUrl, setResetUrl] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Something went wrong")
return
}
setSent(true)
if (data.resetUrl) setResetUrl(data.resetUrl)
} catch {
setError("Connection error")
} finally {
setLoading(false)
}
}
if (sent) {
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm text-center">
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
<h1 className="text-xl font-bold text-foreground mb-2">Check your email</h1>
<p className="text-sm text-muted-foreground mb-6">
If an account with that email exists, we&apos;ve sent a password reset link.
</p>
{resetUrl && (
<div className="bg-card border border-border rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-muted-foreground mb-2">Development mode reset link:</p>
<a href={resetUrl} className="text-sm text-primary break-all hover:underline">{resetUrl}</a>
</div>
)}
<Link href="/login" className="text-sm text-primary hover:underline">
Back to login
</Link>
</div>
</div>
)
}
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm">
<Link href="/login" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors">
<ArrowLeft className="h-4 w-4" />
Back to login
</Link>
<h1 className="text-xl font-bold text-foreground mb-2">Reset your password</h1>
<p className="text-sm text-muted-foreground mb-6">
Enter your email and we&apos;ll send you a reset link.
</p>
{error && (
<div className="mb-4 flex items-center gap-2 text-sm text-destructive bg-destructive/5 border border-destructive/10 rounded-lg px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1.5">Email</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-muted border border-border rounded-lg pl-10 pr-3 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="your@email.com"
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40 flex items-center justify-center gap-2"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Sending..." : "Send reset link"}
</button>
</form>
</div>
</div>
)
}
+92 -25
View File
@@ -119,34 +119,47 @@
}
.dark {
--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%;
/* ── Slate palette tokens ── */
--bg-page: 225 18% 9%;
--bg-sidebar: 227 25% 7%;
--bg-card: 224 24% 11%;
--bg-card-inset: 224 24% 13%;
--bg-nav-active: 221 43% 20%;
--border: 228 21% 21%;
--border-strong: 220 69% 59%;
--text-primary: 226 30% 95%;
--text-secondary: 228 13% 59%;
--text-active: 221 100% 87%;
--accent-hover: 220 70% 66%;
/* ── Existing CSS variable overrides ── */
--background: 225 18% 9%;
--foreground: 226 30% 95%;
--card: 224 24% 11%;
--card-foreground: 226 30% 95%;
--popover: 224 24% 13%;
--popover-foreground: 226 30% 95%;
--primary: 220 69% 59%;
--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%;
--secondary: 224 24% 13%;
--secondary-foreground: 226 30% 95%;
--muted: 224 24% 13%;
--muted-foreground: 228 13% 59%;
--accent: 224 24% 13%;
--accent-foreground: 226 30% 95%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--sidebar: 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%;
--border: 228 21% 21%;
--input: 228 21% 21%;
--ring: 220 69% 59%;
--sidebar: 227 25% 7%;
--sidebar-foreground: 226 30% 95%;
--sidebar-primary: 221 43% 20%;
--sidebar-primary-foreground: 221 100% 87%;
--sidebar-accent: 224 24% 13%;
--sidebar-accent-foreground: 226 30% 95%;
--sidebar-border: 228 21% 21%;
--sidebar-ring: 220 69% 59%;
}
.ocean {
@@ -745,4 +758,58 @@ main {
opacity: 1 !important;
}
/* ============================================================
Utilisation status card backgrounds (theme-aware)
============================================================ */
.util-card-overloaded {
background-color: color-mix(in srgb, hsl(0 80% 50% / 0.1), hsl(var(--card))) !important;
border-color: hsl(0 70% 50% / 0.3) !important;
}
.util-card-near-capacity {
background-color: color-mix(in srgb, hsl(25 95% 50% / 0.1), hsl(var(--card))) !important;
border-color: hsl(25 85% 45% / 0.3) !important;
}
.util-card-busy {
background-color: color-mix(in srgb, hsl(45 90% 50% / 0.08), hsl(var(--card))) !important;
border-color: hsl(45 80% 45% / 0.25) !important;
}
.util-card-available {
background-color: color-mix(in srgb, hsl(150 65% 40% / 0.08), hsl(var(--card))) !important;
border-color: hsl(150 60% 40% / 0.25) !important;
}
.util-card-underutilized {
/* Default card styling — no tint needed */
}
/* Utilisation status text */
.util-text-overloaded { @apply text-red-600 dark:text-red-400; }
.util-text-near-capacity { @apply text-orange-600 dark:text-orange-400; }
.util-text-busy { @apply text-yellow-600 dark:text-yellow-400; }
.util-text-available { @apply text-emerald-600 dark:text-emerald-400; }
.util-text-underutilized { @apply text-slate-600 dark:text-slate-400; }
/* Utilisation status badge */
.util-badge-overloaded {
@apply bg-red-100 text-red-700 border-red-300 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800;
}
.util-badge-near-capacity {
@apply bg-orange-100 text-orange-700 border-orange-300 dark:bg-orange-900/30 dark:text-orange-300 dark:border-orange-800;
}
.util-badge-busy {
@apply bg-yellow-100 text-yellow-700 border-yellow-300 dark:bg-yellow-900/25 dark:text-yellow-300 dark:border-yellow-800;
}
.util-badge-available {
@apply bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-900/25 dark:text-emerald-300 dark:border-emerald-800;
}
.util-badge-underutilized {
@apply bg-slate-100 text-slate-600 border-slate-300 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700;
}
/* Utilisation bar fill */
.util-bar-overloaded { @apply bg-red-500; }
.util-bar-near-capacity { @apply bg-orange-500; }
.util-bar-busy { @apply bg-yellow-500; }
.util-bar-available { @apply bg-emerald-500; }
.util-bar-underutilized { @apply bg-slate-400 dark:bg-slate-500; }
+5
View File
@@ -41,6 +41,11 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} min-h-screen antialiased`}>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("crm-color-theme");if(t&&t!=="default")document.documentElement.classList.add(t);var w=localStorage.getItem("crm-website-theme");if(w==="spidey")document.documentElement.classList.add("theme-spidey")}catch(e){}})()`,
}}
/>
<ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
<WebsiteThemeProvider>
{children}
+3 -2
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import Link from "next/link"
import { Eye, EyeOff, Loader2 } from "lucide-react"
const WEBSITE_THEME_KEY = "crm-website-theme"
@@ -384,9 +385,9 @@ function LoginForm() {
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
Password
</label>
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
<Link href="/forgot-password" className="text-[12px] text-[#1BB0CE] hover:underline">
Forgot password?
</button>
</Link>
</div>
<div className="input-sheen input-sheen-delayed">
<div className="relative">
+138
View File
@@ -0,0 +1,138 @@
"use client"
import { useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { Eye, EyeOff, AlertCircle, CheckCircle, Loader2 } from "lucide-react"
import Link from "next/link"
export default function ResetPasswordPage() {
const { token } = useParams<{ token: string }>()
const router = useRouter()
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [done, setDone] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
return
}
setLoading(true)
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Something went wrong")
return
}
setDone(true)
} catch {
setError("Connection error")
} finally {
setLoading(false)
}
}
if (done) {
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm text-center">
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
<h1 className="text-xl font-bold text-foreground mb-2">Password reset</h1>
<p className="text-sm text-muted-foreground mb-6">
Your password has been reset successfully.
</p>
<Link
href="/login"
className="inline-block bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-6 py-2.5 text-sm font-medium transition-all"
>
Sign in
</Link>
</div>
</div>
)
}
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm">
<h1 className="text-xl font-bold text-foreground mb-2">Set new password</h1>
<p className="text-sm text-muted-foreground mb-6">Enter your new password below.</p>
{error && (
<div className="mb-4 flex items-center gap-2 text-sm text-destructive bg-destructive/5 border border-destructive/10 rounded-lg px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1.5">New password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-muted border border-border rounded-lg px-3 py-2.5 pr-10 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="Min 6 characters"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1.5">Confirm password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-muted border border-border rounded-lg px-3 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="Repeat password"
required
minLength={6}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40 flex items-center justify-center gap-2"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Resetting..." : "Reset password"}
</button>
</form>
<p className="text-center mt-6">
<Link href="/login" className="text-sm text-primary hover:underline">
Back to login
</Link>
</p>
</div>
</div>
)
}
+31 -10
View File
@@ -55,6 +55,7 @@ function formatBullets(text: string) {
interface ChatMessage {
role: "user" | "assistant"
content: string
ts?: number
gif?: {
url: string
previewUrl: string
@@ -83,7 +84,12 @@ interface AIChatProps {
onMessageSent?: (msg: string) => void
}
export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatProps>(({ onMessageSent }, ref) => {
export interface AIChatHandle {
fillInput: (text: string) => void
addAssistantMessage: (content: string) => void
}
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
@@ -101,6 +107,17 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr
setInput(text)
setTimeout(() => textareaRef.current?.focus(), 50)
},
addAssistantMessage(content: string) {
setMessages((prev) => {
if (!prev.some((m) => m.role === "user")) {
return [
{ role: "user", content: "Search Facebook" },
{ role: "assistant", content },
]
}
return [...prev, { role: "assistant", content }]
})
},
}), [])
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
@@ -120,14 +137,20 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr
}
}, [showGifPicker])
const _WEEK_MS = 604800000
useEffect(() => {
const saved = localStorage.getItem("ai-chat-messages")
if (saved) {
try {
const parsed = JSON.parse(saved)
let parsed = JSON.parse(saved)
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed)
loadedFromStorage.current = true
const cutoff = Date.now() - _WEEK_MS
parsed = parsed.filter((m: any) => !m.ts || m.ts > cutoff)
if (parsed.length > 0) {
setMessages(parsed)
loadedFromStorage.current = true
}
}
} catch {}
} else {
@@ -137,7 +160,8 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr
useEffect(() => {
if (messages.length > 0) {
localStorage.setItem("ai-chat-messages", JSON.stringify(messages))
const now = Date.now()
localStorage.setItem("ai-chat-messages", JSON.stringify(messages.map(m => ({ ...m, ts: m.ts || now }))))
}
}, [messages])
@@ -155,6 +179,7 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr
}, [])
useEffect(() => {
checkServer()
if (loadedFromStorage.current) return
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -184,11 +209,7 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr
},
])
})
}, [])
useEffect(() => {
setBootState("ready")
}, [])
}, [checkServer])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
+22 -9
View File
@@ -1,7 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import { Briefcase, ChevronDown, Loader2 } from "lucide-react"
import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
interface Job {
job_title: string
@@ -12,9 +12,11 @@ interface Job {
interface JobSelectorProps {
onSelect: (job: Job | null) => void
onSearch?: (job: Job) => void
searching?: boolean
}
export function JobSelector({ onSelect }: JobSelectorProps) {
export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
@@ -39,36 +41,47 @@ export function JobSelector({ onSelect }: JobSelectorProps) {
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2.5 bg-[#1a1d2e] border border-[#ffffff0f] hover:border-primary/30 rounded-xl px-4 py-3 text-sm text-[#9ca3af] hover:text-[#e5e7eb] transition-all duration-200"
className="w-full flex items-center gap-2.5 bg-card/50 border border-border hover:border-primary/20 rounded-xl px-4 py-3 text-sm text-muted-foreground hover:text-foreground transition-all duration-200"
>
<Briefcase className="h-4 w-4 text-primary flex-none" />
<Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
<span className="flex-1 text-left truncate">
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
</span>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-[#4b5563] transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl shadow-xl shadow-black/40 max-h-60 overflow-y-auto">
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-card border border-border rounded-xl shadow-xl max-h-60 overflow-y-auto">
{jobs.map((job, i) => (
<button
key={i}
type="button"
onClick={() => handleSelect(job)}
className="w-full text-left px-4 py-3 text-sm text-[#9ca3af] hover:bg-[#1f2437] hover:text-[#e5e7eb] transition-all duration-150 border-b border-[#ffffff08] last:border-0 border-l-2 border-l-transparent hover:border-l-primary/40"
className="w-full text-left px-4 py-3 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-150 border-b border-border/20 last:border-b-0 border-l-2 border-l-transparent hover:border-l-primary/40"
>
<div className="font-medium">{job.job_title}</div>
<div className="text-xs text-[#4b5563] mt-0.5">{job.industry} &mdash; {job.description}</div>
<div className="text-xs text-muted-foreground/60 mt-0.5">{job.industry} &mdash; {job.description}</div>
</button>
))}
{jobs.length === 0 && !loading && (
<div className="px-4 py-4 text-xs text-[#4b5563] text-center">No job categories loaded</div>
<div className="px-4 py-4 text-xs text-muted-foreground/60 text-center">No job categories loaded</div>
)}
</div>
</>
)}
{selected && onSearch && (
<button
type="button"
onClick={() => onSearch(selected)}
disabled={searching}
className="w-full flex items-center justify-center gap-2 mt-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed text-primary-foreground text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-primary/20"
>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{searching ? "Searching..." : "Search Leads"}
</button>
)}
</div>
)
}
+1 -1
View File
@@ -4,7 +4,7 @@ export function SidebarNameLogo() {
return (
<svg
viewBox="0 0 192.756 192.756"
className="fill-[#2D3020] dark:fill-[#e5e7eb] w-full h-auto"
className="fill-[#2D3020] dark:fill-[#eef0f7] w-full h-auto"
aria-hidden="true"
>
<g fill-rule="evenodd" clip-rule="evenodd">
+89 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
@@ -31,6 +31,11 @@ import {
Bug,
CheckCheck,
Trash2,
Building2,
FolderKanban,
FileText,
MessageSquare,
Loader2,
} from "lucide-react";
import { useWebsiteTheme } from "@/providers/website-theme-provider";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
@@ -48,11 +53,60 @@ export function Topbar({ onMenuClick }: TopbarProps) {
useNotifications();
const [mounted, setMounted] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<{ id: number; title: string; type: string }[]>([]);
const [searching, setSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
const [bugModalOpen, setBugModalOpen] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); setSearching(false); return }
setSearching(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
const data = await res.json()
setResults(data.results || [])
} catch { setResults([]) }
setSearching(false)
}, []);
const handleSearchInput = useCallback((value: string) => {
setQuery(value)
setShowResults(true)
if (searchTimer.current) clearTimeout(searchTimer.current)
if (!value.trim()) { setResults([]); return }
searchTimer.current = setTimeout(() => doSearch(value), 250)
}, [doSearch]);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") setShowResults(false)
}, []);
const navigateTo = useCallback((type: string, id: number) => {
setShowResults(false)
setQuery("")
setResults([])
const paths: Record<string, string> = { lead: `/leads/${id}`, project: `/projects/${id}`, proposal: `/proposals/${id}`, conversation: `/conversations/${id}` }
router.push(paths[type] || "/")
}, [router]);
const typeIcons: Record<string, typeof Building2> = { lead: Building2, project: FolderKanban, proposal: FileText, conversation: MessageSquare }
if (!user) return null;
function formatTimeAgo(ts: string): string {
@@ -93,12 +147,43 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</Button>
{/* Search */}
<div className="relative hidden flex-1 sm:block md:w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground dark:text-[#666666]" />
<div className="relative hidden flex-1 sm:block md:w-80" ref={searchRef}>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => handleSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown}
onFocus={() => query.trim() && setShowResults(true)}
placeholder="Search leads, companies..."
className="h-9 w-full max-w-sm pl-9 bg-muted dark:bg-[#1E1E1E] border-border dark:border-[#222222] text-foreground dark:text-white"
className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground"
/>
{showResults && (results.length > 0 || searching) && (
<div className="absolute left-0 right-0 top-full mt-1 bg-card border border-border rounded-xl shadow-xl overflow-hidden z-50">
{searching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching...
</div>
) : (
results.map((r) => {
const Icon = typeIcons[r.type] || Building2
return (
<button
key={`${r.type}-${r.id}`}
onClick={() => navigateTo(r.type, r.id)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/10 text-primary">
<Icon className="h-3.5 w-3.5" />
</div>
<span className="font-medium text-foreground truncate">{r.title}</span>
<span className="ml-auto text-[10px] uppercase tracking-wider text-muted-foreground/60">{r.type}</span>
</button>
)
})
)}
</div>
)}
</div>
<div className="flex flex-1 items-center justify-end gap-3">
+2 -27
View File
@@ -9,8 +9,6 @@ import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor, Palette, Shield } from "lucide-react"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
const COLOR_THEME_KEY = "crm-color-theme"
const modeOptions = [
{ value: "light", icon: Sun, label: "Light" },
{ value: "dark", icon: Moon, label: "Dark" },
@@ -43,38 +41,15 @@ const backgroundOptions = [
{ value: "forest", label: "Forest", icon: Shield, color: "bg-gradient-to-r from-green-700 to-green-400", ring: "ring-green-600", desc: "Earthy greens with tree silhouettes and wood textures" },
]
function getStoredColorTheme(): string {
if (typeof window === "undefined") return "default"
return localStorage.getItem(COLOR_THEME_KEY) || "default"
}
function applyColorTheme(theme: string) {
const colorThemes = ["default","ocean","forest","sunset","midnight","rose","amber","violet","slate","ruby"]
const classes = document.documentElement.className.split(" ").filter(c => !colorThemes.includes(c))
if (theme !== "default") {
classes.push(theme)
}
document.documentElement.className = classes.join(" ").trim()
localStorage.setItem(COLOR_THEME_KEY, theme)
}
export function ThemeSettings() {
const { theme, setTheme } = useTheme()
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
const [colorTheme, setColorTheme] = useState("default")
const { websiteTheme, setWebsiteTheme, colorTheme, setColorTheme } = useWebsiteTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
setColorTheme(getStoredColorTheme())
applyColorTheme(getStoredColorTheme())
}, [])
function handleColorChange(value: string) {
setColorTheme(value)
applyColorTheme(value)
}
if (!mounted) return null
return (
@@ -115,7 +90,7 @@ export function ThemeSettings() {
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup value={colorTheme} onValueChange={handleColorChange} className="grid grid-cols-5 gap-4">
<RadioGroup value={colorTheme} onValueChange={setColorTheme} className="grid grid-cols-5 gap-4">
{themeOptions.map(({ value, label, color, ring }) => (
<div key={value}>
<RadioGroupItem value={value} id={`color-${value}`} className="peer sr-only" />
+1 -1
View File
@@ -19,7 +19,7 @@ export function PageHeader({ title, description, children, className }: PageHead
className={cn("flex items-center justify-between pb-6", className)}
>
<div>
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#C84B4B] dark:border-[#FF1111] pl-4 text-[#2D3020] dark:text-white">{title}</h1>
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#C84B4B] dark:border-primary pl-4 text-[#2D3020] dark:text-white">{title}</h1>
{description && <p className="text-sm text-[#8A9078] dark:text-[#AAAAAA] mt-1">{description}</p>}
</div>
{children && <div className="flex items-center gap-3">{children}</div>}
+55 -8
View File
@@ -3,6 +3,7 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
const WEBSITE_THEME_KEY = "crm-website-theme"
const COLOR_THEME_KEY = "crm-color-theme"
const themeClasses: Record<string, string> = {
spidey: "theme-spidey",
@@ -16,9 +17,13 @@ const themeClasses: Record<string, string> = {
forest: "theme-forest",
}
const colorThemes = ["default","ocean","forest","sunset","midnight","rose","amber","violet","slate","ruby"]
interface WebsiteThemeContextValue {
websiteTheme: string
setWebsiteTheme: (theme: string) => Promise<void>
colorTheme: string
setColorTheme: (theme: string) => Promise<void>
loading: boolean
}
@@ -35,9 +40,19 @@ function applyWebsiteTheme(theme: string) {
root.className = classes.join(" ").trim()
}
function getStoredTheme(): string | null {
function applyColorTheme(theme: string) {
const root = document.documentElement
const classes = root.className.split(" ").filter((c) => !colorThemes.includes(c))
if (theme !== "default") {
classes.push(theme)
}
root.className = classes.join(" ").trim()
localStorage.setItem(COLOR_THEME_KEY, theme)
}
function getStored(key: string): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem(WEBSITE_THEME_KEY)
return localStorage.getItem(key)
}
function storeTheme(theme: string) {
@@ -47,22 +62,40 @@ function storeTheme(theme: string) {
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
const [websiteTheme, setWebsiteThemeState] = useState<string>("default")
const [colorTheme, setColorThemeState] = useState<string>("default")
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("/api/settings/website-theme")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const theme = data?.websiteTheme || "default"
setWebsiteThemeState(theme)
storeTheme(theme)
applyWebsiteTheme(theme)
const wt = data?.websiteTheme || "default"
setWebsiteThemeState(wt)
storeTheme(wt)
applyWebsiteTheme(wt)
if (data?.colorTheme) {
setColorThemeState(data.colorTheme)
applyColorTheme(data.colorTheme)
} else {
const stored = getStored(COLOR_THEME_KEY)
if (stored) {
setColorThemeState(stored)
applyColorTheme(stored)
}
}
})
.catch(() => {
const stored = getStoredTheme()
const stored = getStored(WEBSITE_THEME_KEY)
const theme = stored || "default"
setWebsiteThemeState(theme)
applyWebsiteTheme(theme)
const storedColor = getStored(COLOR_THEME_KEY)
if (storedColor) {
setColorThemeState(storedColor)
applyColorTheme(storedColor)
}
})
.finally(() => setLoading(false))
}, [])
@@ -82,8 +115,22 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
}
}, [])
const setColorTheme = useCallback(async (theme: string) => {
setColorThemeState(theme)
applyColorTheme(theme)
try {
await fetch("/api/settings/website-theme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ colorTheme: theme }),
})
} catch {
console.warn("Failed to persist color theme")
}
}, [])
return (
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, loading }}>
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, colorTheme, setColorTheme, loading }}>
{children}
</WebsiteThemeContext.Provider>
)