Compare commits
24 Commits
7820896a35
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d6cdfb01 | |||
| 4035f5fad4 | |||
| dbec6c0851 | |||
| cfc69d47d3 | |||
| 51ec96ce7b | |||
| dd6767980a | |||
| c1c4afadbb | |||
| 37679a7a60 | |||
| c0cb715ced | |||
| 1269f6cce8 | |||
| 370f935cbb | |||
| faf9dd551a | |||
| f67d9377a0 | |||
| 38fb3a47a8 | |||
| bc422edcf7 | |||
| da5f8360bc | |||
| 37af1febcc | |||
| 84632e043f | |||
| b2bb6d94f5 | |||
| 566fa3df88 | |||
| 16710e3019 | |||
| e920d4925a | |||
| e8d80c3a16 | |||
| 77cccba8a1 |
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Generated
Vendored
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+8
-10
@@ -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"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"playwright": "^1.48.0"
|
||||
"playwright": "^1.61.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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'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'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
@@ -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; }
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
@@ -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} — {job.description}</div>
|
||||
<div className="text-xs text-muted-foreground/60 mt-0.5">{job.industry} — {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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user