Compare commits
60 Commits
48f78cf2ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d6cdfb01 | |||
| 4035f5fad4 | |||
| dbec6c0851 | |||
| cfc69d47d3 | |||
| 51ec96ce7b | |||
| 7820896a35 | |||
| a446a19760 | |||
| dd56014318 | |||
| 9df7475f35 | |||
| 12115644e9 | |||
| d1c62d940f | |||
| a7f3058668 | |||
| a1fef1d8bb | |||
| 5d315bed22 | |||
| abcef070c3 | |||
| 211d026fb8 | |||
| dd6767980a | |||
| 4c732c850f | |||
| aabdbf9b91 | |||
| fc099c6d00 | |||
| c1c4afadbb | |||
| 37679a7a60 | |||
| 0a4988156f | |||
| c0cb715ced | |||
| 53ae0944be | |||
| 956e2697c9 | |||
| 4a33ca0368 | |||
| 7606af04ab | |||
| 26319281d4 | |||
| 183d6c629d | |||
| 6f622b6329 | |||
| ef2e12ec5e | |||
| a02f013f38 | |||
| 2979078edd | |||
| 3fe32d923e | |||
| 1269f6cce8 | |||
| 727dcddf37 | |||
| 370f935cbb | |||
| 9bfd4e47ee | |||
| 00bda54695 | |||
| 963ea1d1b8 | |||
| eef290a20a | |||
| faf9dd551a | |||
| f67d9377a0 | |||
| 38fb3a47a8 | |||
| bc422edcf7 | |||
| ef75d16bf4 | |||
| da5f8360bc | |||
| 37af1febcc | |||
| 9f4dd4fc6b | |||
| bcd2d73e70 | |||
| 84632e043f | |||
| b2bb6d94f5 | |||
| 566fa3df88 | |||
| 16710e3019 | |||
| e920d4925a | |||
| 72b839f5d4 | |||
| cb1944a9ea | |||
| e8d80c3a16 | |||
| 77cccba8a1 |
@@ -13,6 +13,13 @@ pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
```bash
|
||||
npm install build
|
||||
# and
|
||||
npm run build
|
||||
# and
|
||||
npm run start
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+491
-45
@@ -1,63 +1,64 @@
|
||||
.dark.theme-bw {
|
||||
--background: 0 0% 4%;
|
||||
--foreground: 0 0% 95%;
|
||||
--card: 0 0% 8%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
--popover: 0 0% 8%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
--primary: 0 0% 95%;
|
||||
--background: 0 0% 5%;
|
||||
--foreground: 0 0% 97%;
|
||||
--card: 0 0% 10%;
|
||||
|
||||
--card-foreground: 0 0% 97%;
|
||||
--popover: 0 0% 10%;
|
||||
--popover-foreground: 0 0% 97%;
|
||||
--primary: 0 0% 97%;
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 0 0% 50%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 0 0% 12%;
|
||||
--muted-foreground: 0 0% 55%;
|
||||
--accent: 0 0% 95%;
|
||||
--accent-foreground: 0 0% 0%;
|
||||
--destructive: 0 70% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 0 0% 20%;
|
||||
--input: 0 0% 20%;
|
||||
--secondary: 0 0% 68%;
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 72%;
|
||||
--accent: 0 0% 25%;
|
||||
--accent-foreground: 0 0% 97%;
|
||||
--destructive: 0 0% 40%;
|
||||
--destructive-foreground: 0 0% 97%;
|
||||
--border: 0 0% 25%;
|
||||
--input: 0 0% 25%;
|
||||
--ring: 0 0% 70%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: 0 0% 4%;
|
||||
--sidebar-foreground: 0 0% 95%;
|
||||
--sidebar-primary: 0 0% 95%;
|
||||
--sidebar-foreground: 0 0% 97%;
|
||||
--sidebar-primary: 0 0% 97%;
|
||||
--sidebar-primary-foreground: 0 0% 0%;
|
||||
--sidebar-accent: 0 0% 95%;
|
||||
--sidebar-accent-foreground: 0 0% 0%;
|
||||
--sidebar-border: 0 0% 15%;
|
||||
--sidebar-accent: 0 0% 15%;
|
||||
--sidebar-accent-foreground: 0 0% 97%;
|
||||
--sidebar-border: 0 0% 18%;
|
||||
--sidebar-ring: 0 0% 70%;
|
||||
}
|
||||
|
||||
.light.theme-bw {
|
||||
--background: 0 0% 97%;
|
||||
--foreground: 0 0% 10%;
|
||||
--foreground: 0 0% 8%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 10%;
|
||||
--card-foreground: 0 0% 8%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 10%;
|
||||
--primary: 0 0% 10%;
|
||||
--popover-foreground: 0 0% 8%;
|
||||
--primary: 0 0% 8%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 40%;
|
||||
--secondary: 0 0% 55%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 0 0% 90%;
|
||||
--muted-foreground: 0 0% 45%;
|
||||
--accent: 0 0% 10%;
|
||||
--muted: 0 0% 93%;
|
||||
--muted-foreground: 0 0% 38%;
|
||||
--accent: 0 0% 20%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 0 0% 82%;
|
||||
--input: 0 0% 82%;
|
||||
--ring: 0 0% 50%;
|
||||
--destructive: 0 0% 60%;
|
||||
--destructive-foreground: 0 0% 10%;
|
||||
--border: 0 0% 78%;
|
||||
--input: 0 0% 78%;
|
||||
--ring: 0 0% 45%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 0 0% 10%;
|
||||
--sidebar-primary: 0 0% 10%;
|
||||
--sidebar-foreground: 0 0% 8%;
|
||||
--sidebar-primary: 0 0% 8%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 0 0% 10%;
|
||||
--sidebar-accent-foreground: 0 0% 100%;
|
||||
--sidebar-border: 0 0% 82%;
|
||||
--sidebar-ring: 0 0% 50%;
|
||||
--sidebar-accent: 0 0% 90%;
|
||||
--sidebar-accent-foreground: 0 0% 8%;
|
||||
--sidebar-border: 0 0% 80%;
|
||||
--sidebar-ring: 0 0% 45%;
|
||||
}
|
||||
|
||||
.dark.theme-bw body {
|
||||
@@ -312,9 +313,454 @@
|
||||
background: linear-gradient(to right, hsl(0, 0%, 50%), hsl(0, 0%, 65%), hsl(0, 0%, 50%)) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .growth-word,
|
||||
.light.theme-bw .growth-word {
|
||||
-webkit-text-fill-color: #1BB0CE !important;
|
||||
color: #1BB0CE !important;
|
||||
.dark.theme-bw .growth-word {
|
||||
-webkit-text-fill-color: hsl(0, 0%, 70%) !important;
|
||||
color: hsl(0, 0%, 70%) !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.light.theme-bw .growth-word {
|
||||
-webkit-text-fill-color: hsl(0, 0%, 40%) !important;
|
||||
color: hsl(0, 0%, 40%) !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Client Portal Overrides — Background for portal pages
|
||||
============================================================================ */
|
||||
|
||||
.dark.theme-bw .min-h-screen.bg-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw aside.bg-card.border-r.border-border {
|
||||
background: hsl(0, 0%, 6%) !important;
|
||||
border-right-color: hsl(0, 0%, 18%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw aside .text-muted-foreground {
|
||||
color: hsl(0, 0%, 50%) !important;
|
||||
}
|
||||
.dark.theme-bw aside button:hover {
|
||||
background: hsla(0, 0%, 100%, 0.08) !important;
|
||||
color: hsl(0, 0%, 90%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw aside .bg-primary\/10.text-primary {
|
||||
background: hsl(0, 0%, 50%) !important;
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
box-shadow: 0 0 10px hsla(0, 0%, 50%, 0.3), 0 0 20px hsla(0, 0%, 50%, 0.1);
|
||||
border-color: hsl(0, 0%, 50%) !important;
|
||||
}
|
||||
.dark.theme-bw aside .bg-primary\/10.text-primary svg {
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw header.bg-card\/80 {
|
||||
background: hsl(0, 0%, 6%) !important;
|
||||
border-bottom-color: hsl(0, 0%, 18%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .bg-card {
|
||||
background: hsla(0, 0%, 7%, 0.65) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-color: hsla(0, 0%, 20%, 0.35) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .border-border {
|
||||
border-color: hsl(0, 0%, 15%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .bg-muted {
|
||||
background: hsl(0, 0%, 9%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .bg-primary {
|
||||
background: hsl(0, 0%, 50%) !important;
|
||||
box-shadow: 0 0 10px hsla(0, 0%, 50%, 0.2);
|
||||
}
|
||||
.dark.theme-bw .bg-primary:hover {
|
||||
background: hsl(0, 0%, 60%) !important;
|
||||
box-shadow: 0 0 15px hsla(0, 0%, 60%, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Dashboard Chart Overrides — donut chart & leads-per-month bar chart
|
||||
Overrides SVG presentation attributes (fill, stop-color, flood-color)
|
||||
Distinguishable greys per segment + glow on hover
|
||||
============================================================================ */
|
||||
|
||||
/* ---- Donut chart: individual segment fills with varied greys ---- */
|
||||
.dark.theme-bw [fill="url(#chart-grad-0)"] { fill: hsl(0,0%,55%) !important; }
|
||||
.dark.theme-bw [fill="url(#chart-grad-1)"] { fill: hsl(0,0%,35%) !important; }
|
||||
.dark.theme-bw [fill="url(#chart-grad-2)"] { fill: hsl(0,0%,65%) !important; }
|
||||
.dark.theme-bw [fill="url(#chart-grad-3)"] { fill: hsl(0,0%,30%) !important; }
|
||||
.dark.theme-bw [fill="url(#chart-grad-4)"] { fill: hsl(0,0%,70%) !important; }
|
||||
.dark.theme-bw [fill="url(#chart-grad-5)"] { fill: hsl(0,0%,40%) !important; }
|
||||
|
||||
.light.theme-bw [fill="url(#chart-grad-0)"] { fill: hsl(0,0%,40%) !important; }
|
||||
.light.theme-bw [fill="url(#chart-grad-1)"] { fill: hsl(0,0%,55%) !important; }
|
||||
.light.theme-bw [fill="url(#chart-grad-2)"] { fill: hsl(0,0%,30%) !important; }
|
||||
.light.theme-bw [fill="url(#chart-grad-3)"] { fill: hsl(0,0%,60%) !important; }
|
||||
.light.theme-bw [fill="url(#chart-grad-4)"] { fill: hsl(0,0%,45%) !important; }
|
||||
.light.theme-bw [fill="url(#chart-grad-5)"] { fill: hsl(0,0%,50%) !important; }
|
||||
|
||||
/* ---- Donut chart: gradient defs stops (match the fills above) ---- */
|
||||
.dark.theme-bw #chart-grad-0 stop { stop-color: hsl(0,0%,55%) !important; }
|
||||
.dark.theme-bw #chart-grad-1 stop { stop-color: hsl(0,0%,35%) !important; }
|
||||
.dark.theme-bw #chart-grad-2 stop { stop-color: hsl(0,0%,65%) !important; }
|
||||
.dark.theme-bw #chart-grad-3 stop { stop-color: hsl(0,0%,30%) !important; }
|
||||
.dark.theme-bw #chart-grad-4 stop { stop-color: hsl(0,0%,70%) !important; }
|
||||
.dark.theme-bw #chart-grad-5 stop { stop-color: hsl(0,0%,40%) !important; }
|
||||
|
||||
.light.theme-bw #chart-grad-0 stop { stop-color: hsl(0,0%,40%) !important; }
|
||||
.light.theme-bw #chart-grad-1 stop { stop-color: hsl(0,0%,55%) !important; }
|
||||
.light.theme-bw #chart-grad-2 stop { stop-color: hsl(0,0%,30%) !important; }
|
||||
.light.theme-bw #chart-grad-3 stop { stop-color: hsl(0,0%,60%) !important; }
|
||||
.light.theme-bw #chart-grad-4 stop { stop-color: hsl(0,0%,45%) !important; }
|
||||
.light.theme-bw #chart-grad-5 stop { stop-color: hsl(0,0%,50%) !important; }
|
||||
|
||||
/* ---- Bar chart: distinguishable fills for two series ---- */
|
||||
.dark.theme-bw [fill="url(#newLeadsGrad)"] { fill: hsl(0,0%,55%) !important; }
|
||||
.dark.theme-bw [fill="url(#closedGrad)"] { fill: hsl(0,0%,30%) !important; }
|
||||
.light.theme-bw [fill="url(#newLeadsGrad)"] { fill: hsl(0,0%,40%) !important; }
|
||||
.light.theme-bw [fill="url(#closedGrad)"] { fill: hsl(0,0%,55%) !important; }
|
||||
|
||||
/* Bar chart: gradient defs stops */
|
||||
.dark.theme-bw #newLeadsGrad stop { stop-color: hsl(0,0%,55%) !important; }
|
||||
.dark.theme-bw #closedGrad stop { stop-color: hsl(0,0%,30%) !important; }
|
||||
.light.theme-bw #newLeadsGrad stop { stop-color: hsl(0,0%,40%) !important; }
|
||||
.light.theme-bw #closedGrad stop { stop-color: hsl(0,0%,55%) !important; }
|
||||
|
||||
/* ---- feDropShadow flood-color override ---- */
|
||||
.dark.theme-bw feDropShadow { flood-color: hsl(0,0%,50%) !important; flood-opacity: 0.25 !important; }
|
||||
.light.theme-bw feDropShadow { flood-color: hsl(0,0%,45%) !important; flood-opacity: 0.15 !important; }
|
||||
|
||||
/* ---- Hover glow: donut segments + bar rects ---- */
|
||||
.dark.theme-bw [fill*="chart-grad"]:hover,
|
||||
.dark.theme-bw [fill="url(#newLeadsGrad)"]:hover,
|
||||
.dark.theme-bw [fill="url(#closedGrad)"]:hover {
|
||||
filter: drop-shadow(0 0 14px hsla(0,0%,80%,0.45)) brightness(1.25) !important;
|
||||
}
|
||||
|
||||
.light.theme-bw [fill*="chart-grad"]:hover,
|
||||
.light.theme-bw [fill="url(#newLeadsGrad)"]:hover,
|
||||
.light.theme-bw [fill="url(#closedGrad)"]:hover {
|
||||
filter: drop-shadow(0 0 12px hsla(0,0%,10%,0.25)) brightness(1.15) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Avatar PFP Overrides — target SVG data URLs with hardcoded blue (#1d4ed8)
|
||||
Desaturate + brighten the blue fill to light grey
|
||||
============================================================================ */
|
||||
|
||||
.dark.theme-bw img[src*="1d4ed8"],
|
||||
.dark.theme-bw img[src*="fill=%231d4ed8"] {
|
||||
filter: saturate(0%) brightness(1.7) !important;
|
||||
}
|
||||
.light.theme-bw img[src*="1d4ed8"],
|
||||
.light.theme-bw img[src*="fill=%231d4ed8"] {
|
||||
filter: saturate(0%) brightness(0.6) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
BW THEME: Force all colored Tailwind utilities to greyscale
|
||||
Targets both regular classes AND dark: variant classes
|
||||
Only applies when .theme-bw is active — no source files touched
|
||||
============================================================================ */
|
||||
|
||||
/* ----- Dark mode: bg-{color}-{shade} ----- */
|
||||
.dark.theme-bw .bg-blue-500, .dark.theme-bw .dark\:bg-blue-500,
|
||||
.dark.theme-bw .bg-amber-500, .dark.theme-bw .dark\:bg-amber-500,
|
||||
.dark.theme-bw .bg-purple-500, .dark.theme-bw .dark\:bg-purple-500,
|
||||
.dark.theme-bw .bg-emerald-500, .dark.theme-bw .dark\:bg-emerald-500,
|
||||
.dark.theme-bw .bg-red-500, .dark.theme-bw .dark\:bg-red-500,
|
||||
.dark.theme-bw .bg-orange-500, .dark.theme-bw .dark\:bg-orange-500,
|
||||
.dark.theme-bw .bg-yellow-500, .dark.theme-bw .dark\:bg-yellow-500,
|
||||
.dark.theme-bw .bg-violet-500, .dark.theme-bw .dark\:bg-violet-500,
|
||||
.dark.theme-bw .bg-green-500, .dark.theme-bw .dark\:bg-green-500,
|
||||
.dark.theme-bw .bg-sky-500, .dark.theme-bw .dark\:bg-sky-500,
|
||||
.dark.theme-bw .bg-teal-500, .dark.theme-bw .dark\:bg-teal-500,
|
||||
.dark.theme-bw .bg-indigo-500, .dark.theme-bw .dark\:bg-indigo-500,
|
||||
.dark.theme-bw .bg-pink-500, .dark.theme-bw .dark\:bg-pink-500 { background-color: hsl(0,0%,50%) !important; }
|
||||
|
||||
.dark.theme-bw .bg-blue-600, .dark.theme-bw .dark\:bg-blue-600,
|
||||
.dark.theme-bw .bg-amber-600, .dark.theme-bw .dark\:bg-amber-600,
|
||||
.dark.theme-bw .bg-emerald-600, .dark.theme-bw .dark\:bg-emerald-600,
|
||||
.dark.theme-bw .bg-red-600, .dark.theme-bw .dark\:bg-red-600,
|
||||
.dark.theme-bw .bg-green-600, .dark.theme-bw .dark\:bg-green-600,
|
||||
.dark.theme-bw .bg-orange-600, .dark.theme-bw .dark\:bg-orange-600,
|
||||
.dark.theme-bw .bg-yellow-600, .dark.theme-bw .dark\:bg-yellow-600,
|
||||
.dark.theme-bw .bg-violet-600, .dark.theme-bw .dark\:bg-violet-600 { background-color: hsl(0,0%,35%) !important; }
|
||||
|
||||
.dark.theme-bw .bg-blue-400, .dark.theme-bw .dark\:bg-blue-400,
|
||||
.dark.theme-bw .bg-red-400, .dark.theme-bw .dark\:bg-red-400,
|
||||
.dark.theme-bw .bg-emerald-400, .dark.theme-bw .dark\:bg-emerald-400,
|
||||
.dark.theme-bw .bg-amber-400, .dark.theme-bw .dark\:bg-amber-400,
|
||||
.dark.theme-bw .bg-sky-400, .dark.theme-bw .dark\:bg-sky-400,
|
||||
.dark.theme-bw .bg-green-400, .dark.theme-bw .dark\:bg-green-400,
|
||||
.dark.theme-bw .bg-purple-400, .dark.theme-bw .dark\:bg-purple-400,
|
||||
.dark.theme-bw .bg-orange-400, .dark.theme-bw .dark\:bg-orange-400 { background-color: hsl(0,0%,60%) !important; }
|
||||
|
||||
.dark.theme-bw .bg-red-50, .dark.theme-bw .dark\:bg-red-50,
|
||||
.dark.theme-bw .bg-orange-50, .dark.theme-bw .dark\:bg-orange-50,
|
||||
.dark.theme-bw .bg-yellow-50, .dark.theme-bw .dark\:bg-yellow-50,
|
||||
.dark.theme-bw .bg-emerald-50, .dark.theme-bw .dark\:bg-emerald-50,
|
||||
.dark.theme-bw .bg-blue-50, .dark.theme-bw .dark\:bg-blue-50,
|
||||
.dark.theme-bw .bg-amber-50, .dark.theme-bw .dark\:bg-amber-50,
|
||||
.dark.theme-bw .bg-violet-50, .dark.theme-bw .dark\:bg-violet-50,
|
||||
.dark.theme-bw .bg-green-50, .dark.theme-bw .dark\:bg-green-50 { background-color: hsl(0,0%,95%) !important; }
|
||||
|
||||
.dark.theme-bw .bg-red-100, .dark.theme-bw .dark\:bg-red-100,
|
||||
.dark.theme-bw .bg-orange-100, .dark.theme-bw .dark\:bg-orange-100,
|
||||
.dark.theme-bw .bg-yellow-100, .dark.theme-bw .dark\:bg-yellow-100,
|
||||
.dark.theme-bw .bg-emerald-100, .dark.theme-bw .dark\:bg-emerald-100,
|
||||
.dark.theme-bw .bg-blue-100, .dark.theme-bw .dark\:bg-blue-100,
|
||||
.dark.theme-bw .bg-amber-100, .dark.theme-bw .dark\:bg-amber-100,
|
||||
.dark.theme-bw .bg-violet-100, .dark.theme-bw .dark\:bg-violet-100,
|
||||
.dark.theme-bw .bg-green-100, .dark.theme-bw .dark\:bg-green-100 { background-color: hsl(0,0%,88%) !important; }
|
||||
|
||||
.dark.theme-bw .bg-blue-500\/10, .dark.theme-bw .dark\:bg-blue-500\/10,
|
||||
.dark.theme-bw .bg-amber-500\/10, .dark.theme-bw .dark\:bg-amber-500\/10,
|
||||
.dark.theme-bw .bg-purple-500\/10, .dark.theme-bw .dark\:bg-purple-500\/10,
|
||||
.dark.theme-bw .bg-emerald-500\/10, .dark.theme-bw .dark\:bg-emerald-500\/10,
|
||||
.dark.theme-bw .bg-red-500\/10, .dark.theme-bw .dark\:bg-red-500\/10,
|
||||
.dark.theme-bw .bg-orange-500\/10, .dark.theme-bw .dark\:bg-orange-500\/10,
|
||||
.dark.theme-bw .bg-yellow-500\/10, .dark.theme-bw .dark\:bg-yellow-500\/10,
|
||||
.dark.theme-bw .bg-violet-500\/10, .dark.theme-bw .dark\:bg-violet-500\/10,
|
||||
.dark.theme-bw .bg-green-500\/10, .dark.theme-bw .dark\:bg-green-500\/10,
|
||||
.dark.theme-bw .bg-sky-500\/10, .dark.theme-bw .dark\:bg-sky-500\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
|
||||
|
||||
.dark.theme-bw .bg-blue-500\/20, .dark.theme-bw .dark\:bg-blue-500\/20,
|
||||
.dark.theme-bw .bg-amber-500\/20, .dark.theme-bw .dark\:bg-amber-500\/20,
|
||||
.dark.theme-bw .bg-purple-500\/20, .dark.theme-bw .dark\:bg-purple-500\/20,
|
||||
.dark.theme-bw .bg-emerald-500\/20, .dark.theme-bw .dark\:bg-emerald-500\/20,
|
||||
.dark.theme-bw .bg-red-500\/20, .dark.theme-bw .dark\:bg-red-500\/20,
|
||||
.dark.theme-bw .bg-violet-500\/20, .dark.theme-bw .dark\:bg-violet-500\/20 { background-color: hsla(0,0%,50%,0.2) !important; }
|
||||
|
||||
.dark.theme-bw .bg-blue-500\/5, .dark.theme-bw .dark\:bg-blue-500\/5,
|
||||
.dark.theme-bw .bg-amber-500\/5, .dark.theme-bw .dark\:bg-amber-500\/5,
|
||||
.dark.theme-bw .bg-purple-500\/5, .dark.theme-bw .dark\:bg-purple-500\/5,
|
||||
.dark.theme-bw .bg-emerald-500\/5, .dark.theme-bw .dark\:bg-emerald-500\/5,
|
||||
.dark.theme-bw .bg-red-500\/5, .dark.theme-bw .dark\:bg-red-500\/5,
|
||||
.dark.theme-bw .bg-violet-500\/5, .dark.theme-bw .dark\:bg-violet-500\/5 { background-color: hsla(0,0%,50%,0.05) !important; }
|
||||
|
||||
/* ----- Dark mode: text-{color}-{shade} (regular AND dark: variant) ----- */
|
||||
.dark.theme-bw .text-blue-500, .dark.theme-bw .dark\:text-blue-500,
|
||||
.dark.theme-bw .text-amber-500, .dark.theme-bw .dark\:text-amber-500,
|
||||
.dark.theme-bw .text-purple-500, .dark.theme-bw .dark\:text-purple-500,
|
||||
.dark.theme-bw .text-emerald-500, .dark.theme-bw .dark\:text-emerald-500,
|
||||
.dark.theme-bw .text-red-500, .dark.theme-bw .dark\:text-red-500,
|
||||
.dark.theme-bw .text-orange-500, .dark.theme-bw .dark\:text-orange-500,
|
||||
.dark.theme-bw .text-yellow-500, .dark.theme-bw .dark\:text-yellow-500,
|
||||
.dark.theme-bw .text-violet-500, .dark.theme-bw .dark\:text-violet-500,
|
||||
.dark.theme-bw .text-green-500, .dark.theme-bw .dark\:text-green-500,
|
||||
.dark.theme-bw .text-sky-500, .dark.theme-bw .dark\:text-sky-500,
|
||||
.dark.theme-bw .text-teal-500, .dark.theme-bw .dark\:text-teal-500,
|
||||
.dark.theme-bw .text-indigo-500, .dark.theme-bw .dark\:text-indigo-500,
|
||||
.dark.theme-bw .text-pink-500, .dark.theme-bw .dark\:text-pink-500,
|
||||
.dark.theme-bw .text-rose-500, .dark.theme-bw .dark\:text-rose-500 { color: hsl(0,0%,65%) !important; }
|
||||
|
||||
.dark.theme-bw .text-blue-600, .dark.theme-bw .dark\:text-blue-600,
|
||||
.dark.theme-bw .text-amber-600, .dark.theme-bw .dark\:text-amber-600,
|
||||
.dark.theme-bw .text-emerald-600, .dark.theme-bw .dark\:text-emerald-600,
|
||||
.dark.theme-bw .text-red-600, .dark.theme-bw .dark\:text-red-600,
|
||||
.dark.theme-bw .text-green-600, .dark.theme-bw .dark\:text-green-600,
|
||||
.dark.theme-bw .text-orange-600, .dark.theme-bw .dark\:text-orange-600,
|
||||
.dark.theme-bw .text-yellow-600, .dark.theme-bw .dark\:text-yellow-600,
|
||||
.dark.theme-bw .text-violet-600, .dark.theme-bw .dark\:text-violet-600 { color: hsl(0,0%,50%) !important; }
|
||||
|
||||
.dark.theme-bw .text-blue-400, .dark.theme-bw .dark\:text-blue-400,
|
||||
.dark.theme-bw .text-red-400, .dark.theme-bw .dark\:text-red-400,
|
||||
.dark.theme-bw .text-emerald-400, .dark.theme-bw .dark\:text-emerald-400,
|
||||
.dark.theme-bw .text-amber-400, .dark.theme-bw .dark\:text-amber-400,
|
||||
.dark.theme-bw .text-sky-400, .dark.theme-bw .dark\:text-sky-400,
|
||||
.dark.theme-bw .text-green-400, .dark.theme-bw .dark\:text-green-400,
|
||||
.dark.theme-bw .text-rose-400, .dark.theme-bw .dark\:text-rose-400,
|
||||
.dark.theme-bw .text-orange-400, .dark.theme-bw .dark\:text-orange-400,
|
||||
.dark.theme-bw .text-purple-400, .dark.theme-bw .dark\:text-purple-400 { color: hsl(0,0%,75%) !important; }
|
||||
|
||||
.dark.theme-bw .text-blue-700, .dark.theme-bw .dark\:text-blue-700,
|
||||
.dark.theme-bw .text-amber-700, .dark.theme-bw .dark\:text-amber-700,
|
||||
.dark.theme-bw .text-emerald-700, .dark.theme-bw .dark\:text-emerald-700,
|
||||
.dark.theme-bw .text-red-700, .dark.theme-bw .dark\:text-red-700,
|
||||
.dark.theme-bw .text-orange-700, .dark.theme-bw .dark\:text-orange-700,
|
||||
.dark.theme-bw .text-yellow-700, .dark.theme-bw .dark\:text-yellow-700,
|
||||
.dark.theme-bw .text-violet-700, .dark.theme-bw .dark\:text-violet-700 { color: hsl(0,0%,35%) !important; }
|
||||
|
||||
.dark.theme-bw .text-emerald-600\/60, .dark.theme-bw .dark\:text-emerald-600\/60 { color: hsla(0,0%,50%,0.7) !important; }
|
||||
.dark.theme-bw .text-red-500\/40, .dark.theme-bw .dark\:text-red-500\/40 { color: hsla(0,0%,65%,0.5) !important; }
|
||||
|
||||
/* ----- Dark mode: border-{color}-{shade} ----- */
|
||||
.dark.theme-bw .border-blue-500, .dark.theme-bw .dark\:border-blue-500,
|
||||
.dark.theme-bw .border-amber-500, .dark.theme-bw .dark\:border-amber-500,
|
||||
.dark.theme-bw .border-purple-500, .dark.theme-bw .dark\:border-purple-500,
|
||||
.dark.theme-bw .border-emerald-500, .dark.theme-bw .dark\:border-emerald-500,
|
||||
.dark.theme-bw .border-red-500, .dark.theme-bw .dark\:border-red-500,
|
||||
.dark.theme-bw .border-orange-500, .dark.theme-bw .dark\:border-orange-500,
|
||||
.dark.theme-bw .border-yellow-500, .dark.theme-bw .dark\:border-yellow-500,
|
||||
.dark.theme-bw .border-violet-500, .dark.theme-bw .dark\:border-violet-500 { border-color: hsl(0,0%,50%) !important; }
|
||||
|
||||
.dark.theme-bw .border-blue-500\/20, .dark.theme-bw .dark\:border-blue-500\/20,
|
||||
.dark.theme-bw .border-amber-500\/20, .dark.theme-bw .dark\:border-amber-500\/20,
|
||||
.dark.theme-bw .border-purple-500\/20, .dark.theme-bw .dark\:border-purple-500\/20,
|
||||
.dark.theme-bw .border-emerald-500\/20, .dark.theme-bw .dark\:border-emerald-500\/20,
|
||||
.dark.theme-bw .border-red-500\/20, .dark.theme-bw .dark\:border-red-500\/20 { border-color: hsla(0,0%,50%,0.2) !important; }
|
||||
|
||||
.dark.theme-bw .border-red-200, .dark.theme-bw .dark\:border-red-200,
|
||||
.dark.theme-bw .border-orange-200, .dark.theme-bw .dark\:border-orange-200,
|
||||
.dark.theme-bw .border-yellow-200, .dark.theme-bw .dark\:border-yellow-200,
|
||||
.dark.theme-bw .border-emerald-200, .dark.theme-bw .dark\:border-emerald-200,
|
||||
.dark.theme-bw .border-blue-200, .dark.theme-bw .dark\:border-blue-200,
|
||||
.dark.theme-bw .border-violet-200, .dark.theme-bw .dark\:border-violet-200 { border-color: hsl(0,0%,80%) !important; }
|
||||
|
||||
.dark.theme-bw .border-red-300, .dark.theme-bw .dark\:border-red-300,
|
||||
.dark.theme-bw .border-orange-300, .dark.theme-bw .dark\:border-orange-300,
|
||||
.dark.theme-bw .border-yellow-300, .dark.theme-bw .dark\:border-yellow-300,
|
||||
.dark.theme-bw .border-emerald-300, .dark.theme-bw .dark\:border-emerald-300,
|
||||
.dark.theme-bw .border-blue-300, .dark.theme-bw .dark\:border-blue-300,
|
||||
.dark.theme-bw .border-violet-300, .dark.theme-bw .dark\:border-violet-300 { border-color: hsl(0,0%,70%) !important; }
|
||||
|
||||
.dark.theme-bw .ring-blue-500\/20, .dark.theme-bw .dark\:ring-blue-500\/20,
|
||||
.dark.theme-bw .ring-amber-500\/20, .dark.theme-bw .dark\:ring-amber-500\/20,
|
||||
.dark.theme-bw .ring-emerald-500\/20, .dark.theme-bw .dark\:ring-emerald-500\/20,
|
||||
.dark.theme-bw .ring-red-500\/20, .dark.theme-bw .dark\:ring-red-500\/20,
|
||||
.dark.theme-bw .ring-purple-500\/20, .dark.theme-bw .dark\:ring-purple-500\/20 { --tw-ring-color: hsla(0,0%,50%,0.2) !important; }
|
||||
|
||||
/* ----- Dark mode: Gradient direction classes ----- */
|
||||
.dark.theme-bw .from-blue-500, .dark.theme-bw .dark\:from-blue-500,
|
||||
.dark.theme-bw .from-amber-500, .dark.theme-bw .dark\:from-amber-500,
|
||||
.dark.theme-bw .from-purple-500, .dark.theme-bw .dark\:from-purple-500,
|
||||
.dark.theme-bw .from-emerald-500, .dark.theme-bw .dark\:from-emerald-500,
|
||||
.dark.theme-bw .from-red-500, .dark.theme-bw .dark\:from-red-500,
|
||||
.dark.theme-bw .from-orange-500, .dark.theme-bw .dark\:from-orange-500,
|
||||
.dark.theme-bw .from-violet-500, .dark.theme-bw .dark\:from-violet-500,
|
||||
.dark.theme-bw .from-green-500, .dark.theme-bw .dark\:from-green-500,
|
||||
.dark.theme-bw .from-pink-500, .dark.theme-bw .dark\:from-pink-500,
|
||||
.dark.theme-bw .from-cyan-400, .dark.theme-bw .dark\:from-cyan-400,
|
||||
.dark.theme-bw .from-fuchsia-500, .dark.theme-bw .dark\:from-fuchsia-500 { --tw-gradient-from: hsl(0,0%,50%) !important; }
|
||||
|
||||
.dark.theme-bw .to-blue-500, .dark.theme-bw .dark\:to-blue-500,
|
||||
.dark.theme-bw .to-amber-500, .dark.theme-bw .dark\:to-amber-500,
|
||||
.dark.theme-bw .to-purple-500, .dark.theme-bw .dark\:to-purple-500,
|
||||
.dark.theme-bw .to-emerald-500, .dark.theme-bw .dark\:to-emerald-500,
|
||||
.dark.theme-bw .to-red-500, .dark.theme-bw .dark\:to-red-500,
|
||||
.dark.theme-bw .to-orange-500, .dark.theme-bw .dark\:to-orange-500,
|
||||
.dark.theme-bw .to-green-500, .dark.theme-bw .dark\:to-green-500,
|
||||
.dark.theme-bw .to-cyan-400, .dark.theme-bw .dark\:to-cyan-400 { --tw-gradient-to: hsl(0,0%,50%) !important; }
|
||||
|
||||
.dark.theme-bw .from-blue-500\/5, .dark.theme-bw .dark\:from-blue-500\/5,
|
||||
.dark.theme-bw .from-amber-500\/5, .dark.theme-bw .dark\:from-amber-500\/5,
|
||||
.dark.theme-bw .from-purple-500\/5, .dark.theme-bw .dark\:from-purple-500\/5,
|
||||
.dark.theme-bw .from-emerald-500\/5, .dark.theme-bw .dark\:from-emerald-500\/5,
|
||||
.dark.theme-bw .from-red-500\/5, .dark.theme-bw .dark\:from-red-500\/5 { --tw-gradient-from: hsla(0,0%,50%,0.05) !important; }
|
||||
|
||||
.dark.theme-bw .to-violet-500\/\[0\.02\], .dark.theme-bw .dark\:to-violet-500\/\[0\.02\],
|
||||
.dark.theme-bw .to-amber-500\/\[0\.02\], .dark.theme-bw .dark\:to-amber-500\/\[0\.02\] { --tw-gradient-to: hsla(0,0%,50%,0.02) !important; }
|
||||
|
||||
.dark.theme-bw .from-violet-500\/5, .dark.theme-bw .dark\:from-violet-500\/5,
|
||||
.dark.theme-bw .from-amber-500\/5, .dark.theme-bw .dark\:from-amber-500\/5 { --tw-gradient-from: hsla(0,0%,50%,0.05) !important; }
|
||||
|
||||
/* ----- Dark mode: Inline arbitrary hex colors (stat-card, voice-call, etc) ----- */
|
||||
.dark.theme-bw .text-\[#C84B4B\], .dark.theme-bw .dark\:text-\[\#C84B4B\],
|
||||
.dark.theme-bw .text-\[\#FF1111\], .dark.theme-bw .dark\:text-\[\#FF1111\] { color: hsl(0,0%,60%) !important; }
|
||||
|
||||
.dark.theme-bw .text-\[#5A8FC4\], .dark.theme-bw .dark\:text-\[\#5A8FC4\],
|
||||
.dark.theme-bw .text-\[\#1144FF\], .dark.theme-bw .dark\:text-\[\#1144FF\] { color: hsl(0,0%,50%) !important; }
|
||||
|
||||
.dark.theme-bw .text-\[#2D3020\], .dark.theme-bw .dark\:text-\[\#2D3020\] { color: hsl(0,0%,20%) !important; }
|
||||
.dark.theme-bw .text-\[#8A9078\], .dark.theme-bw .dark\:text-\[\#8A9078\] { color: hsl(0,0%,55%) !important; }
|
||||
|
||||
.dark.theme-bw .bg-\[#C84B4B\]\/10, .dark.theme-bw .dark\:bg-\[\#C84B4B\]\/10,
|
||||
.dark.theme-bw .bg-\[#5A8FC4\]\/10, .dark.theme-bw .dark\:bg-\[\#5A8FC4\]\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
|
||||
|
||||
.dark.theme-bw .from-\[#C84B4B\], .dark.theme-bw .dark\:from-\[\#C84B4B\],
|
||||
.dark.theme-bw .via-\[#C84B4B\], .dark.theme-bw .dark\:via-\[\#C84B4B\],
|
||||
.dark.theme-bw .to-\[#C84B4B\], .dark.theme-bw .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(0,0%,50%) !important; }
|
||||
|
||||
.dark.theme-bw .from-\[#5A8FC4\], .dark.theme-bw .dark\:from-\[\#5A8FC4\],
|
||||
.dark.theme-bw .via-\[#5A8FC4\], .dark.theme-bw .dark\:via-\[\#5A8FC4\],
|
||||
.dark.theme-bw .to-\[#5A8FC4\], .dark.theme-bw .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(0,0%,50%) !important; }
|
||||
|
||||
/* ----- Dark mode: SVG stroke/fill (app-shell, etc) ----- */
|
||||
.dark.theme-bw [stroke="#CC0000"] { stroke: hsl(0,0%,50%) !important; }
|
||||
.dark.theme-bw [stroke="#0033CC"] { stroke: hsl(0,0%,50%) !important; }
|
||||
.dark.theme-bw [fill="#2D3020"] { fill: hsl(0,0%,20%) !important; }
|
||||
.dark.theme-bw [fill="#8A9078"] { fill: hsl(0,0%,55%) !important; }
|
||||
|
||||
/* ----- Dark mode: Hover states ----- */
|
||||
.dark.theme-bw .hover\:bg-sky-400\/20:hover { background-color: hsla(0,0%,50%,0.2) !important; }
|
||||
.dark.theme-bw .hover\:bg-emerald-700:hover { background-color: hsl(0,0%,30%) !important; }
|
||||
|
||||
/* ----- Light mode overrides ----- */
|
||||
.light.theme-bw .bg-blue-500, .light.theme-bw .dark\:bg-blue-500,
|
||||
.light.theme-bw .bg-amber-500, .light.theme-bw .dark\:bg-amber-500,
|
||||
.light.theme-bw .bg-purple-500, .light.theme-bw .dark\:bg-purple-500,
|
||||
.light.theme-bw .bg-emerald-500, .light.theme-bw .dark\:bg-emerald-500,
|
||||
.light.theme-bw .bg-red-500, .light.theme-bw .dark\:bg-red-500,
|
||||
.light.theme-bw .bg-orange-500, .light.theme-bw .dark\:bg-orange-500,
|
||||
.light.theme-bw .bg-yellow-500, .light.theme-bw .dark\:bg-yellow-500,
|
||||
.light.theme-bw .bg-violet-500, .light.theme-bw .dark\:bg-violet-500,
|
||||
.light.theme-bw .bg-green-500, .light.theme-bw .dark\:bg-green-500,
|
||||
.light.theme-bw .bg-sky-500, .light.theme-bw .dark\:bg-sky-500 { background-color: hsl(0,0%,50%) !important; }
|
||||
|
||||
.light.theme-bw .text-blue-500, .light.theme-bw .dark\:text-blue-500,
|
||||
.light.theme-bw .text-amber-500, .light.theme-bw .dark\:text-amber-500,
|
||||
.light.theme-bw .text-purple-500, .light.theme-bw .dark\:text-purple-500,
|
||||
.light.theme-bw .text-emerald-500, .light.theme-bw .dark\:text-emerald-500,
|
||||
.light.theme-bw .text-red-500, .light.theme-bw .dark\:text-red-500,
|
||||
.light.theme-bw .text-orange-500, .light.theme-bw .dark\:text-orange-500,
|
||||
.light.theme-bw .text-yellow-500, .light.theme-bw .dark\:text-yellow-500,
|
||||
.light.theme-bw .text-violet-500, .light.theme-bw .dark\:text-violet-500,
|
||||
.light.theme-bw .text-sky-400, .light.theme-bw .dark\:text-sky-400,
|
||||
.light.theme-bw .text-rose-500, .light.theme-bw .dark\:text-rose-500,
|
||||
.light.theme-bw .text-green-500, .light.theme-bw .dark\:text-green-500 { color: hsl(0,0%,40%) !important; }
|
||||
|
||||
.light.theme-bw .bg-blue-500\/10, .light.theme-bw .dark\:bg-blue-500\/10,
|
||||
.light.theme-bw .bg-amber-500\/10, .light.theme-bw .dark\:bg-amber-500\/10,
|
||||
.light.theme-bw .bg-purple-500\/10, .light.theme-bw .dark\:bg-purple-500\/10,
|
||||
.light.theme-bw .bg-emerald-500\/10, .light.theme-bw .dark\:bg-emerald-500\/10,
|
||||
.light.theme-bw .bg-red-500\/10, .light.theme-bw .dark\:bg-red-500\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
|
||||
|
||||
/* ============================================================================
|
||||
Pipeline (Kanban Board + Stat Cards) — white glow instead of stage colours
|
||||
============================================================================ */
|
||||
|
||||
/* ---- Kanban columns ---- */
|
||||
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border {
|
||||
box-shadow: 0 0 20px hsla(0,0%,100%,0.06), 0 0 50px hsla(0,0%,100%,0.02) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border .rounded-t-lg {
|
||||
background: transparent !important;
|
||||
border-bottom-color: hsla(0,0%,100%,0.1) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border .h-2.w-2.rounded-full {
|
||||
background: hsl(0,0%,65%) !important;
|
||||
box-shadow: 0 0 6px hsla(0,0%,100%,0.4) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .overflow-x-auto .cursor-grab .rounded-full {
|
||||
background: hsl(0,0%,50%) !important;
|
||||
box-shadow: 0 0 6px hsla(0,0%,100%,0.2) !important;
|
||||
}
|
||||
|
||||
/* ---- Dashboard cards (Pipeline Overview stat cards, Analytics charts, Recent Leads table) ---- */
|
||||
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card {
|
||||
box-shadow: 0 0 16px hsla(0,0%,100%,0.05), 0 0 40px hsla(0,0%,100%,0.015) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .rounded-xl {
|
||||
background: transparent !important;
|
||||
box-shadow: 0 0 12px hsla(0,0%,100%,0.15), inset 0 0 8px hsla(0,0%,100%,0.05) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .rounded-xl svg {
|
||||
color: hsl(0,0%,70%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||
background: hsl(0,0%,50%) !important;
|
||||
box-shadow: 0 0 8px hsla(0,0%,100%,0.3) !important;
|
||||
}
|
||||
@@ -287,3 +287,232 @@
|
||||
color: #1BB0CE !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Charts — Donut (LeadStatus) & Bar (LeadsPerMonth) — Cosmic colors
|
||||
============================================================================ */
|
||||
|
||||
/* ---- SVG gradient overrides (donut chart segments) ---- */
|
||||
.dark.theme-cosmic #chart-grad-0 stop { stop-color: hsl(280, 60%, 55%) !important; }
|
||||
.dark.theme-cosmic #chart-grad-1 stop { stop-color: hsl(120, 100%, 45%) !important; }
|
||||
.dark.theme-cosmic #chart-grad-2 stop { stop-color: hsl(320, 80%, 60%) !important; }
|
||||
.dark.theme-cosmic #chart-grad-3 stop { stop-color: hsl(170, 80%, 50%) !important; }
|
||||
.dark.theme-cosmic #chart-grad-4 stop { stop-color: hsl(260, 15%, 40%) !important; }
|
||||
|
||||
.light.theme-cosmic #chart-grad-0 stop { stop-color: hsl(280, 50%, 50%) !important; }
|
||||
.light.theme-cosmic #chart-grad-1 stop { stop-color: hsl(120, 80%, 40%) !important; }
|
||||
.light.theme-cosmic #chart-grad-2 stop { stop-color: hsl(320, 70%, 55%) !important; }
|
||||
.light.theme-cosmic #chart-grad-3 stop { stop-color: hsl(170, 70%, 40%) !important; }
|
||||
.light.theme-cosmic #chart-grad-4 stop { stop-color: hsl(260, 10%, 55%) !important; }
|
||||
|
||||
/* ---- SVG gradient overrides (bar chart) ---- */
|
||||
.dark.theme-cosmic #newLeadsGrad stop { stop-color: hsl(280, 60%, 55%) !important; }
|
||||
.dark.theme-cosmic #closedGrad stop { stop-color: hsl(120, 100%, 45%) !important; }
|
||||
.light.theme-cosmic #newLeadsGrad stop { stop-color: hsl(280, 50%, 50%) !important; }
|
||||
.light.theme-cosmic #closedGrad stop { stop-color: hsl(120, 80%, 40%) !important; }
|
||||
|
||||
/* ---- Inline chart colors (legend dots, hover text) ---- */
|
||||
.dark.theme-cosmic [style*="background: #3b82f6"],
|
||||
.dark.theme-cosmic [style*="background:#3b82f6"] { background: hsl(280, 60%, 55%) !important; }
|
||||
.dark.theme-cosmic [style*="background: #f59e0b"],
|
||||
.dark.theme-cosmic [style*="background:#f59e0b"] { background: hsl(120, 100%, 45%) !important; }
|
||||
.dark.theme-cosmic [style*="background: #8b5cf6"],
|
||||
.dark.theme-cosmic [style*="background:#8b5cf6"] { background: hsl(320, 80%, 60%) !important; }
|
||||
.dark.theme-cosmic [style*="background: #10b981"],
|
||||
.dark.theme-cosmic [style*="background:#10b981"] { background: hsl(170, 80%, 50%) !important; }
|
||||
.dark.theme-cosmic [style*="background: #6B7280"],
|
||||
.dark.theme-cosmic [style*="background:#6B7280"] { background: hsl(260, 15%, 40%) !important; }
|
||||
|
||||
.light.theme-cosmic [style*="background: #3b82f6"],
|
||||
.light.theme-cosmic [style*="background:#3b82f6"] { background: hsl(280, 50%, 50%) !important; }
|
||||
.light.theme-cosmic [style*="background: #f59e0b"],
|
||||
.light.theme-cosmic [style*="background:#f59e0b"] { background: hsl(120, 80%, 40%) !important; }
|
||||
.light.theme-cosmic [style*="background: #8b5cf6"],
|
||||
.light.theme-cosmic [style*="background:#8b5cf6"] { background: hsl(320, 70%, 55%) !important; }
|
||||
.light.theme-cosmic [style*="background: #10b981"],
|
||||
.light.theme-cosmic [style*="background:#10b981"] { background: hsl(170, 70%, 40%) !important; }
|
||||
.light.theme-cosmic [style*="background: #6B7280"],
|
||||
.light.theme-cosmic [style*="background:#6B7280"] { background: hsl(260, 10%, 55%) !important; }
|
||||
|
||||
/* ---- Chart legend hover borders/box-shadows ---- */
|
||||
.dark.theme-cosmic [style*="border: 1px solid #3b82f6"],
|
||||
.dark.theme-cosmic [style*="border:1px solid #3b82f6"] { border-color: hsl(280, 60%, 55%) !important; }
|
||||
.dark.theme-cosmic [style*="border: 1px solid #f59e0b"],
|
||||
.dark.theme-cosmic [style*="border:1px solid #f59e0b"] { border-color: hsl(120, 100%, 45%) !important; }
|
||||
.dark.theme-cosmic [style*="border: 1px solid #8b5cf6"],
|
||||
.dark.theme-cosmic [style*="border:1px solid #8b5cf6"] { border-color: hsl(320, 80%, 60%) !important; }
|
||||
.dark.theme-cosmic [style*="border: 1px solid #10b981"],
|
||||
.dark.theme-cosmic [style*="border:1px solid #10b981"] { border-color: hsl(170, 80%, 50%) !important; }
|
||||
.dark.theme-cosmic [style*="border: 1px solid #6B7280"],
|
||||
.dark.theme-cosmic [style*="border:1px solid #6B7280"] { border-color: hsl(260, 15%, 40%) !important; }
|
||||
|
||||
/* ---- Donut chart segment text color on hover ---- */
|
||||
.dark.theme-cosmic [style*="color: #3b82f6"] { color: hsl(280, 60%, 55%) !important; }
|
||||
.dark.theme-cosmic [style*="color: #f59e0b"] { color: hsl(120, 100%, 45%) !important; }
|
||||
.dark.theme-cosmic [style*="color: #8b5cf6"] { color: hsl(320, 80%, 60%) !important; }
|
||||
.dark.theme-cosmic [style*="color: #10b981"] { color: hsl(170, 80%, 50%) !important; }
|
||||
.dark.theme-cosmic [style*="color: #6B7280"] { color: hsl(260, 15%, 40%) !important; }
|
||||
|
||||
.light.theme-cosmic [style*="color: #3b82f6"] { color: hsl(280, 50%, 50%) !important; }
|
||||
.light.theme-cosmic [style*="color: #f59e0b"] { color: hsl(120, 80%, 40%) !important; }
|
||||
.light.theme-cosmic [style*="color: #8b5cf6"] { color: hsl(320, 70%, 55%) !important; }
|
||||
.light.theme-cosmic [style*="color: #10b981"] { color: hsl(170, 70%, 40%) !important; }
|
||||
.light.theme-cosmic [style*="color: #6B7280"] { color: hsl(260, 10%, 55%) !important; }
|
||||
|
||||
/* ============================================================================
|
||||
Stat Cards — Icon backgrounds, text colors, progress bars
|
||||
============================================================================ */
|
||||
|
||||
.dark.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
|
||||
background: hsla(280, 60%, 55%, 0.12) !important;
|
||||
}
|
||||
.dark.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
|
||||
color: hsl(280, 60%, 55%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-cosmic .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||
background: linear-gradient(to right, hsl(280, 60%, 55%), hsl(120, 100%, 45%)) !important;
|
||||
}
|
||||
|
||||
.dark.theme-cosmic .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
|
||||
background: linear-gradient(to right, transparent, hsla(280, 60%, 55%, 0.2), transparent) !important;
|
||||
}
|
||||
|
||||
.light.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
|
||||
background: hsla(280, 50%, 50%, 0.1) !important;
|
||||
}
|
||||
.light.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
|
||||
color: hsl(280, 50%, 50%) !important;
|
||||
}
|
||||
|
||||
.light.theme-cosmic .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||
background: linear-gradient(to right, hsl(280, 50%, 50%), hsl(120, 80%, 40%)) !important;
|
||||
}
|
||||
|
||||
.light.theme-cosmic .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
|
||||
background: linear-gradient(to right, transparent, hsla(280, 50%, 50%, 0.15), transparent) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Inline hex color overrides — stat card values, page icons, etc.
|
||||
============================================================================ */
|
||||
|
||||
.dark.theme-cosmic .text-\[#C84B4B\], .dark.theme-cosmic .dark\:text-\[\#C84B4B\] { color: hsl(280, 60%, 55%) !important; }
|
||||
.dark.theme-cosmic .text-\[#5A8FC4\], .dark.theme-cosmic .dark\:text-\[\#5A8FC4\] { color: hsl(120, 100%, 45%) !important; }
|
||||
.dark.theme-cosmic .text-\[#FF1111\], .dark.theme-cosmic .dark\:text-\[\#FF1111\] { color: hsl(280, 80%, 60%) !important; }
|
||||
.dark.theme-cosmic .text-\[#1144FF\], .dark.theme-cosmic .dark\:text-\[\#1144FF\] { color: hsl(120, 100%, 55%) !important; }
|
||||
|
||||
.light.theme-cosmic .text-\[#C84B4B\], .light.theme-cosmic .dark\:text-\[\#C84B4B\] { color: hsl(280, 50%, 50%) !important; }
|
||||
.light.theme-cosmic .text-\[#5A8FC4\], .light.theme-cosmic .dark\:text-\[\#5A8FC4\] { color: hsl(120, 80%, 40%) !important; }
|
||||
|
||||
.dark.theme-cosmic .bg-\[#C84B4B\]\/10, .dark.theme-cosmic .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(280, 60%, 55%, 0.1) !important; }
|
||||
.dark.theme-cosmic .bg-\[#5A8FC4\]\/10, .dark.theme-cosmic .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(120, 100%, 45%, 0.1) !important; }
|
||||
|
||||
.light.theme-cosmic .bg-\[#C84B4B\]\/10, .light.theme-cosmic .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(280, 50%, 50%, 0.08) !important; }
|
||||
.light.theme-cosmic .bg-\[#5A8FC4\]\/10, .light.theme-cosmic .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(120, 80%, 40%, 0.08) !important; }
|
||||
|
||||
.dark.theme-cosmic .from-\[#C84B4B\], .dark.theme-cosmic .dark\:from-\[\#C84B4B\],
|
||||
.dark.theme-cosmic .via-\[#C84B4B\], .dark.theme-cosmic .dark\:via-\[\#C84B4B\],
|
||||
.dark.theme-cosmic .to-\[#C84B4B\], .dark.theme-cosmic .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(280, 60%, 55%) !important; }
|
||||
|
||||
.dark.theme-cosmic .from-\[#5A8FC4\], .dark.theme-cosmic .dark\:from-\[\#5A8FC4\],
|
||||
.dark.theme-cosmic .via-\[#5A8FC4\], .dark.theme-cosmic .dark\:via-\[\#5A8FC4\],
|
||||
.dark.theme-cosmic .to-\[#5A8FC4\], .dark.theme-cosmic .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(120, 100%, 45%) !important; }
|
||||
|
||||
.light.theme-cosmic .from-\[#C84B4B\], .light.theme-cosmic .dark\:from-\[\#C84B4B\],
|
||||
.light.theme-cosmic .via-\[#C84B4B\], .light.theme-cosmic .dark\:via-\[\#C84B4B\],
|
||||
.light.theme-cosmic .to-\[#C84B4B\], .light.theme-cosmic .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(280, 50%, 50%) !important; }
|
||||
|
||||
.light.theme-cosmic .from-\[#5A8FC4\], .light.theme-cosmic .dark\:from-\[\#5A8FC4\],
|
||||
.light.theme-cosmic .via-\[#5A8FC4\], .light.theme-cosmic .dark\:via-\[\#5A8FC4\],
|
||||
.light.theme-cosmic .to-\[#5A8FC4\], .light.theme-cosmic .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(120, 80%, 40%) !important; }
|
||||
|
||||
/* ---- SVG stroke/fill overrides ---- */
|
||||
.dark.theme-cosmic [stroke="#CC0000"] { stroke: hsl(280, 60%, 55%) !important; }
|
||||
.dark.theme-cosmic [stroke="#0033CC"] { stroke: hsl(120, 100%, 45%) !important; }
|
||||
.light.theme-cosmic [stroke="#CC0000"] { stroke: hsl(280, 50%, 50%) !important; }
|
||||
.light.theme-cosmic [stroke="#0033CC"] { stroke: hsl(120, 80%, 40%) !important; }
|
||||
|
||||
/* ============================================================================
|
||||
Light mode — deeper visual polish
|
||||
============================================================================ */
|
||||
|
||||
.light.theme-cosmic .rounded-xl.border.bg-card {
|
||||
box-shadow: 0 1px 3px hsla(280, 50%, 30%, 0.08), 0 4px 12px hsla(280, 50%, 30%, 0.04) !important;
|
||||
}
|
||||
|
||||
.light.theme-cosmic aside .group:hover {
|
||||
background: hsla(280, 50%, 50%, 0.04) !important;
|
||||
}
|
||||
|
||||
.light.theme-cosmic .hover\:bg-accent:hover {
|
||||
background: hsla(280, 50%, 50%, 0.06) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Client Portal Overrides — Background for portal pages
|
||||
============================================================================ */
|
||||
|
||||
/* Make the outer portal container transparent so body::before background shows */
|
||||
.dark.theme-cosmic .min-h-screen.bg-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar — dark glass */
|
||||
.dark.theme-cosmic aside.bg-card.border-r.border-border {
|
||||
background: hsl(240, 15%, 3%) !important;
|
||||
border-right-color: hsl(270, 25%, 10%) !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar nav items — muted text */
|
||||
.dark.theme-cosmic aside .text-muted-foreground {
|
||||
color: hsl(240, 8%, 50%) !important;
|
||||
}
|
||||
.dark.theme-cosmic aside button:hover {
|
||||
background: hsla(330, 100%, 50%, 0.08) !important;
|
||||
color: hsl(0, 0%, 90%) !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar active nav item */
|
||||
.dark.theme-cosmic aside .bg-primary\/10.text-primary {
|
||||
background: hsl(330, 100%, 50%) !important;
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.1);
|
||||
border-color: hsl(330, 100%, 50%) !important;
|
||||
}
|
||||
.dark.theme-cosmic aside .bg-primary\/10.text-primary svg {
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
}
|
||||
|
||||
/* Portal header — dark glass */
|
||||
.dark.theme-cosmic header.bg-card\/80 {
|
||||
background: hsl(240, 15%, 3%) !important;
|
||||
border-bottom-color: hsl(270, 25%, 10%) !important;
|
||||
}
|
||||
|
||||
/* Portal card backgrounds — semi-transparent glass */
|
||||
.dark.theme-cosmic .bg-card {
|
||||
background: hsla(240, 15%, 7%, 0.65) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-color: hsla(270, 30%, 20%, 0.35) !important;
|
||||
}
|
||||
|
||||
/* Portal borders */
|
||||
.dark.theme-cosmic .border-border {
|
||||
border-color: hsl(270, 25%, 12%) !important;
|
||||
}
|
||||
|
||||
/* Portal input fields */
|
||||
.dark.theme-cosmic .bg-muted {
|
||||
background: hsl(240, 12%, 9%) !important;
|
||||
}
|
||||
|
||||
/* Portal primary buttons */
|
||||
.dark.theme-cosmic .bg-primary {
|
||||
background: hsl(330, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
|
||||
}
|
||||
.dark.theme-cosmic .bg-primary:hover {
|
||||
background: hsl(330, 100%, 55%) !important;
|
||||
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/* ============================================================================
|
||||
Cuba Theme — Classic Cuban car on a playa at golden hour (6pm sunset)
|
||||
Malecón, Havana — warm sunset tones, turquoise Caribbean accents
|
||||
============================================================================ */
|
||||
|
||||
.dark.theme-cuba {
|
||||
--background: 25 40% 6%;
|
||||
--foreground: 30 20% 90%;
|
||||
--card: 25 35% 10%;
|
||||
--card-foreground: 30 20% 90%;
|
||||
--popover: 25 35% 10%;
|
||||
--popover-foreground: 30 20% 90%;
|
||||
--primary: 15 85% 55%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 185 60% 50%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 25 20% 15%;
|
||||
--muted-foreground: 25 10% 55%;
|
||||
--accent: 195 65% 55%;
|
||||
--accent-foreground: 0 0% 0%;
|
||||
--destructive: 0 75% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--success: 145 55% 45%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 35 90% 55%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--border: 25 25% 18%;
|
||||
--input: 25 20% 15%;
|
||||
--ring: 15 85% 55%;
|
||||
--radius: 0.75rem;
|
||||
--sidebar: 25 40% 6%;
|
||||
--sidebar-foreground: 30 20% 90%;
|
||||
--sidebar-primary: 15 85% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 25 30% 12%;
|
||||
--sidebar-accent-foreground: 30 20% 90%;
|
||||
--sidebar-border: 25 25% 15%;
|
||||
--sidebar-ring: 15 85% 55%;
|
||||
--chart-1: 15 85% 55%;
|
||||
--chart-2: 185 60% 50%;
|
||||
--chart-3: 45 85% 55%;
|
||||
--chart-4: 195 65% 55%;
|
||||
--chart-5: 335 60% 55%;
|
||||
}
|
||||
|
||||
.light.theme-cuba {
|
||||
--background: 30 30% 96%;
|
||||
--foreground: 25 40% 15%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 25 40% 15%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 25 40% 15%;
|
||||
--primary: 15 80% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 185 55% 45%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 30 15% 90%;
|
||||
--muted-foreground: 25 10% 45%;
|
||||
--accent: 195 60% 50%;
|
||||
--accent-foreground: 0 0% 0%;
|
||||
--destructive: 0 72% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--success: 145 50% 40%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 35 85% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--border: 30 15% 85%;
|
||||
--input: 30 10% 88%;
|
||||
--ring: 15 80% 50%;
|
||||
--radius: 0.75rem;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 25 40% 15%;
|
||||
--sidebar-primary: 15 80% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 30 15% 92%;
|
||||
--sidebar-accent-foreground: 25 40% 15%;
|
||||
--sidebar-border: 30 15% 85%;
|
||||
--sidebar-ring: 15 80% 50%;
|
||||
--chart-1: 15 80% 50%;
|
||||
--chart-2: 185 55% 45%;
|
||||
--chart-3: 45 80% 50%;
|
||||
--chart-4: 195 60% 50%;
|
||||
--chart-5: 335 55% 50%;
|
||||
}
|
||||
|
||||
/* ── Malecón, Havana at golden hour background ──────────────────── */
|
||||
.dark.theme-cuba body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: url("https://images.unsplash.com/photo-1584098179114-7962ad5d6653?w=1920&q=80") no-repeat center center fixed;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.light.theme-cuba body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: hsl(30, 30%, 96%);
|
||||
}
|
||||
|
||||
/* ── Make main containers transparent so body::before shows ──────── */
|
||||
.dark.theme-cuba .min-h-screen.bg-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.dark.theme-cuba .min-h-screen.bg-\[\#F8F8F8\],
|
||||
.dark.theme-cuba .dark\:bg-\[\#0A0A0A\] {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.light.theme-cuba .min-h-screen {
|
||||
background: hsl(30, 30%, 96%) !important;
|
||||
}
|
||||
|
||||
/* ── Frosted glass cards ────────────────────────────────────────── */
|
||||
.dark.theme-cuba .bg-card,
|
||||
.dark.theme-cuba .bg-popover,
|
||||
.dark.theme-cuba .bg-muted {
|
||||
background-color: hsl(var(--card) / 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.dark.theme-cuba .bg-sidebar {
|
||||
background-color: hsl(var(--sidebar) / 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.light.theme-cuba .bg-card,
|
||||
.light.theme-cuba .bg-popover,
|
||||
.light.theme-cuba .bg-muted {
|
||||
background-color: hsl(var(--card) / 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.light.theme-cuba .bg-sidebar {
|
||||
background-color: hsl(var(--sidebar) / 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.theme-cuba .bg-card,
|
||||
.theme-cuba .bg-popover,
|
||||
.theme-cuba .bg-muted,
|
||||
.theme-cuba .bg-sidebar {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"id": "cuba",
|
||||
"name": "Cuba Theme",
|
||||
"description": "Classic Cuban car on a playa at golden hour — warm sunset tones with turquoise Caribbean accents.",
|
||||
"type": "dual",
|
||||
"default": false,
|
||||
"dark": {
|
||||
"colors": {
|
||||
"background": "hsl(25, 40%, 6%)",
|
||||
"foreground": "hsl(30, 20%, 90%)",
|
||||
"card": "hsl(25, 35%, 10%)",
|
||||
"cardForeground": "hsl(30, 20%, 90%)",
|
||||
"popover": "hsl(25, 35%, 10%)",
|
||||
"popoverForeground": "hsl(30, 20%, 90%)",
|
||||
"primary": "hsl(15, 85%, 55%)",
|
||||
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||
"secondary": "hsl(185, 60%, 50%)",
|
||||
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||
"muted": "hsl(25, 20%, 15%)",
|
||||
"mutedForeground": "hsl(25, 10%, 55%)",
|
||||
"accent": "hsl(195, 65%, 55%)",
|
||||
"accentForeground": "hsl(0, 0%, 0%)",
|
||||
"destructive": "hsl(0, 75%, 50%)",
|
||||
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||
"border": "hsl(25, 25%, 18%)",
|
||||
"input": "hsl(25, 20%, 15%)",
|
||||
"ring": "hsl(15, 85%, 55%)",
|
||||
"sidebar": "hsl(25, 40%, 6%)",
|
||||
"sidebarForeground": "hsl(30, 20%, 90%)",
|
||||
"sidebarPrimary": "hsl(15, 85%, 55%)",
|
||||
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||
"sidebarAccent": "hsl(25, 30%, 12%)",
|
||||
"sidebarAccentForeground": "hsl(30, 20%, 90%)",
|
||||
"sidebarBorder": "hsl(25, 25%, 15%)",
|
||||
"sidebarRing": "hsl(15, 85%, 55%)"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"colors": {
|
||||
"background": "hsl(30, 30%, 96%)",
|
||||
"foreground": "hsl(25, 40%, 15%)",
|
||||
"card": "hsl(0, 0%, 100%)",
|
||||
"cardForeground": "hsl(25, 40%, 15%)",
|
||||
"popover": "hsl(0, 0%, 100%)",
|
||||
"popoverForeground": "hsl(25, 40%, 15%)",
|
||||
"primary": "hsl(15, 80%, 50%)",
|
||||
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||
"secondary": "hsl(185, 55%, 45%)",
|
||||
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||
"muted": "hsl(30, 15%, 90%)",
|
||||
"mutedForeground": "hsl(25, 10%, 45%)",
|
||||
"accent": "hsl(195, 60%, 50%)",
|
||||
"accentForeground": "hsl(0, 0%, 0%)",
|
||||
"destructive": "hsl(0, 72%, 50%)",
|
||||
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||
"border": "hsl(30, 15%, 85%)",
|
||||
"input": "hsl(30, 10%, 88%)",
|
||||
"ring": "hsl(15, 80%, 50%)",
|
||||
"sidebar": "hsl(0, 0%, 100%)",
|
||||
"sidebarForeground": "hsl(25, 40%, 15%)",
|
||||
"sidebarPrimary": "hsl(15, 80%, 50%)",
|
||||
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||
"sidebarAccent": "hsl(30, 15%, 92%)",
|
||||
"sidebarAccentForeground": "hsl(25, 40%, 15%)",
|
||||
"sidebarBorder": "hsl(30, 15%, 85%)",
|
||||
"sidebarRing": "hsl(15, 80%, 50%)"
|
||||
}
|
||||
},
|
||||
"borderRadius": "0.75rem",
|
||||
"typography": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"headingFont": "Inter, sans-serif"
|
||||
},
|
||||
"keyColors": {
|
||||
"primary": "#e85d28",
|
||||
"background": "#0f0a06 (dark) / #f5f0eb (light)",
|
||||
"card": "#1a120c (dark) / #ffffff (light)",
|
||||
"sidebar": "#0f0a06 (dark) / #ffffff (light)",
|
||||
"border": "#2a1f15 (dark) / #d9cfc5 (light)",
|
||||
"text": "#e6ddd4 (dark) / #1a110a (light)",
|
||||
"mutedText": "#8a7a6a (dark) / #6b5d50 (light)"
|
||||
},
|
||||
"sunset": {
|
||||
"orange": "#e85d28",
|
||||
"golden": "#f4a340",
|
||||
"turquoise": "#30c5d2",
|
||||
"pink": "#e8677a",
|
||||
"purple": "#8b5cf6",
|
||||
"cream": "#f5e6d0"
|
||||
},
|
||||
"chartSegments": {
|
||||
"open": { "label": "Open", "color": "#e85d28", "description": "Sunset Orange — 35%" },
|
||||
"contacted": { "label": "Contacted", "color": "#30c5d2", "description": "Caribbean Turquoise — 25%" },
|
||||
"pending": { "label": "Pending", "color": "#f4a340", "description": "Golden Hour — 17%" },
|
||||
"closed": { "label": "Closed", "color": "#50b87a", "description": "Palm Green — 23%" },
|
||||
"ignored": { "label": "Ignored", "color": "#8b7a6a", "description": "Warm Muted — 0%" }
|
||||
},
|
||||
"barChart": {
|
||||
"newLeads": { "color": "#e85d28", "glow": "rgba(232, 93, 40, 0.5)" },
|
||||
"closed": { "color": "#30c5d2", "glow": "rgba(48, 197, 210, 0.4)" }
|
||||
},
|
||||
"statusBadges": {
|
||||
"open": { "color": "#30c5d2", "label": "Turquoise" },
|
||||
"contacted": { "color": "#e8677a", "label": "Sunset Pink" },
|
||||
"pending": { "color": "#f4a340", "label": "Golden" },
|
||||
"closed": { "color": "#50b87a", "label": "Palm Green" },
|
||||
"ignored": { "color": "#8b7a6a", "label": "Warm Muted" }
|
||||
}
|
||||
}
|
||||
@@ -318,3 +318,232 @@
|
||||
color: #1BB0CE !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Charts — Donut (LeadStatus) & Bar (LeadsPerMonth) — Cyber2 colors
|
||||
============================================================================ */
|
||||
|
||||
/* ---- SVG gradient overrides (donut chart segments) ---- */
|
||||
.dark.theme-cyber2 #chart-grad-0 stop { stop-color: hsl(180, 100%, 50%) !important; }
|
||||
.dark.theme-cyber2 #chart-grad-1 stop { stop-color: hsl(280, 80%, 55%) !important; }
|
||||
.dark.theme-cyber2 #chart-grad-2 stop { stop-color: hsl(310, 90%, 60%) !important; }
|
||||
.dark.theme-cyber2 #chart-grad-3 stop { stop-color: hsl(170, 100%, 45%) !important; }
|
||||
.dark.theme-cyber2 #chart-grad-4 stop { stop-color: hsl(180, 20%, 40%) !important; }
|
||||
|
||||
.light.theme-cyber2 #chart-grad-0 stop { stop-color: hsl(180, 80%, 40%) !important; }
|
||||
.light.theme-cyber2 #chart-grad-1 stop { stop-color: hsl(280, 60%, 50%) !important; }
|
||||
.light.theme-cyber2 #chart-grad-2 stop { stop-color: hsl(310, 70%, 55%) !important; }
|
||||
.light.theme-cyber2 #chart-grad-3 stop { stop-color: hsl(170, 80%, 38%) !important; }
|
||||
.light.theme-cyber2 #chart-grad-4 stop { stop-color: hsl(180, 15%, 50%) !important; }
|
||||
|
||||
/* ---- SVG gradient overrides (bar chart) ---- */
|
||||
.dark.theme-cyber2 #newLeadsGrad stop { stop-color: hsl(180, 100%, 50%) !important; }
|
||||
.dark.theme-cyber2 #closedGrad stop { stop-color: hsl(280, 80%, 55%) !important; }
|
||||
.light.theme-cyber2 #newLeadsGrad stop { stop-color: hsl(180, 80%, 40%) !important; }
|
||||
.light.theme-cyber2 #closedGrad stop { stop-color: hsl(280, 60%, 50%) !important; }
|
||||
|
||||
/* ---- Inline chart colors (legend dots, hover text) ---- */
|
||||
.dark.theme-cyber2 [style*="background: #3b82f6"],
|
||||
.dark.theme-cyber2 [style*="background:#3b82f6"] { background: hsl(180, 100%, 50%) !important; }
|
||||
.dark.theme-cyber2 [style*="background: #f59e0b"],
|
||||
.dark.theme-cyber2 [style*="background:#f59e0b"] { background: hsl(280, 80%, 55%) !important; }
|
||||
.dark.theme-cyber2 [style*="background: #8b5cf6"],
|
||||
.dark.theme-cyber2 [style*="background:#8b5cf6"] { background: hsl(310, 90%, 60%) !important; }
|
||||
.dark.theme-cyber2 [style*="background: #10b981"],
|
||||
.dark.theme-cyber2 [style*="background:#10b981"] { background: hsl(170, 100%, 45%) !important; }
|
||||
.dark.theme-cyber2 [style*="background: #6B7280"],
|
||||
.dark.theme-cyber2 [style*="background:#6B7280"] { background: hsl(180, 20%, 40%) !important; }
|
||||
|
||||
.light.theme-cyber2 [style*="background: #3b82f6"],
|
||||
.light.theme-cyber2 [style*="background:#3b82f6"] { background: hsl(180, 80%, 40%) !important; }
|
||||
.light.theme-cyber2 [style*="background: #f59e0b"],
|
||||
.light.theme-cyber2 [style*="background:#f59e0b"] { background: hsl(280, 60%, 50%) !important; }
|
||||
.light.theme-cyber2 [style*="background: #8b5cf6"],
|
||||
.light.theme-cyber2 [style*="background:#8b5cf6"] { background: hsl(310, 70%, 55%) !important; }
|
||||
.light.theme-cyber2 [style*="background: #10b981"],
|
||||
.light.theme-cyber2 [style*="background:#10b981"] { background: hsl(170, 80%, 38%) !important; }
|
||||
.light.theme-cyber2 [style*="background: #6B7280"],
|
||||
.light.theme-cyber2 [style*="background:#6B7280"] { background: hsl(180, 15%, 50%) !important; }
|
||||
|
||||
/* ---- Chart legend hover borders ---- */
|
||||
.dark.theme-cyber2 [style*="border: 1px solid #3b82f6"],
|
||||
.dark.theme-cyber2 [style*="border:1px solid #3b82f6"] { border-color: hsl(180, 100%, 50%) !important; }
|
||||
.dark.theme-cyber2 [style*="border: 1px solid #f59e0b"],
|
||||
.dark.theme-cyber2 [style*="border:1px solid #f59e0b"] { border-color: hsl(280, 80%, 55%) !important; }
|
||||
.dark.theme-cyber2 [style*="border: 1px solid #8b5cf6"],
|
||||
.dark.theme-cyber2 [style*="border:1px solid #8b5cf6"] { border-color: hsl(310, 90%, 60%) !important; }
|
||||
.dark.theme-cyber2 [style*="border: 1px solid #10b981"],
|
||||
.dark.theme-cyber2 [style*="border:1px solid #10b981"] { border-color: hsl(170, 100%, 45%) !important; }
|
||||
.dark.theme-cyber2 [style*="border: 1px solid #6B7280"],
|
||||
.dark.theme-cyber2 [style*="border:1px solid #6B7280"] { border-color: hsl(180, 20%, 40%) !important; }
|
||||
|
||||
/* ---- Donut chart segment text color on hover ---- */
|
||||
.dark.theme-cyber2 [style*="color: #3b82f6"] { color: hsl(180, 100%, 50%) !important; }
|
||||
.dark.theme-cyber2 [style*="color: #f59e0b"] { color: hsl(280, 80%, 55%) !important; }
|
||||
.dark.theme-cyber2 [style*="color: #8b5cf6"] { color: hsl(310, 90%, 60%) !important; }
|
||||
.dark.theme-cyber2 [style*="color: #10b981"] { color: hsl(170, 100%, 45%) !important; }
|
||||
.dark.theme-cyber2 [style*="color: #6B7280"] { color: hsl(180, 20%, 40%) !important; }
|
||||
|
||||
.light.theme-cyber2 [style*="color: #3b82f6"] { color: hsl(180, 80%, 40%) !important; }
|
||||
.light.theme-cyber2 [style*="color: #f59e0b"] { color: hsl(280, 60%, 50%) !important; }
|
||||
.light.theme-cyber2 [style*="color: #8b5cf6"] { color: hsl(310, 70%, 55%) !important; }
|
||||
.light.theme-cyber2 [style*="color: #10b981"] { color: hsl(170, 80%, 38%) !important; }
|
||||
.light.theme-cyber2 [style*="color: #6B7280"] { color: hsl(180, 15%, 50%) !important; }
|
||||
|
||||
/* ============================================================================
|
||||
Stat Cards — Icon backgrounds, text colors, progress bars
|
||||
============================================================================ */
|
||||
|
||||
.dark.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
|
||||
background: hsla(180, 100%, 50%, 0.12) !important;
|
||||
}
|
||||
.dark.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
|
||||
color: hsl(180, 100%, 50%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-cyber2 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(280, 80%, 55%)) !important;
|
||||
}
|
||||
|
||||
.dark.theme-cyber2 .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
|
||||
background: linear-gradient(to right, transparent, hsla(180, 100%, 50%, 0.2), transparent) !important;
|
||||
}
|
||||
|
||||
.light.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
|
||||
background: hsla(180, 80%, 40%, 0.1) !important;
|
||||
}
|
||||
.light.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
|
||||
color: hsl(180, 80%, 40%) !important;
|
||||
}
|
||||
|
||||
.light.theme-cyber2 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||
background: linear-gradient(to right, hsl(180, 80%, 40%), hsl(280, 60%, 50%)) !important;
|
||||
}
|
||||
|
||||
.light.theme-cyber2 .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
|
||||
background: linear-gradient(to right, transparent, hsla(180, 80%, 40%, 0.15), transparent) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Inline hex color overrides — stat card values, page icons, etc.
|
||||
============================================================================ */
|
||||
|
||||
.dark.theme-cyber2 .text-\[#C84B4B\], .dark.theme-cyber2 .dark\:text-\[\#C84B4B\] { color: hsl(180, 100%, 50%) !important; }
|
||||
.dark.theme-cyber2 .text-\[#5A8FC4\], .dark.theme-cyber2 .dark\:text-\[\#5A8FC4\] { color: hsl(280, 80%, 55%) !important; }
|
||||
.dark.theme-cyber2 .text-\[#FF1111\], .dark.theme-cyber2 .dark\:text-\[\#FF1111\] { color: hsl(180, 100%, 55%) !important; }
|
||||
.dark.theme-cyber2 .text-\[#1144FF\], .dark.theme-cyber2 .dark\:text-\[\#1144FF\] { color: hsl(280, 80%, 60%) !important; }
|
||||
|
||||
.light.theme-cyber2 .text-\[#C84B4B\], .light.theme-cyber2 .dark\:text-\[\#C84B4B\] { color: hsl(180, 80%, 40%) !important; }
|
||||
.light.theme-cyber2 .text-\[#5A8FC4\], .light.theme-cyber2 .dark\:text-\[\#5A8FC4\] { color: hsl(280, 60%, 50%) !important; }
|
||||
|
||||
.dark.theme-cyber2 .bg-\[#C84B4B\]\/10, .dark.theme-cyber2 .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(180, 100%, 50%, 0.1) !important; }
|
||||
.dark.theme-cyber2 .bg-\[#5A8FC4\]\/10, .dark.theme-cyber2 .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(280, 80%, 55%, 0.1) !important; }
|
||||
|
||||
.light.theme-cyber2 .bg-\[#C84B4B\]\/10, .light.theme-cyber2 .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(180, 80%, 40%, 0.08) !important; }
|
||||
.light.theme-cyber2 .bg-\[#5A8FC4\]\/10, .light.theme-cyber2 .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(280, 60%, 50%, 0.08) !important; }
|
||||
|
||||
.dark.theme-cyber2 .from-\[#C84B4B\], .dark.theme-cyber2 .dark\:from-\[\#C84B4B\],
|
||||
.dark.theme-cyber2 .via-\[#C84B4B\], .dark.theme-cyber2 .dark\:via-\[\#C84B4B\],
|
||||
.dark.theme-cyber2 .to-\[#C84B4B\], .dark.theme-cyber2 .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(180, 100%, 50%) !important; }
|
||||
|
||||
.dark.theme-cyber2 .from-\[#5A8FC4\], .dark.theme-cyber2 .dark\:from-\[\#5A8FC4\],
|
||||
.dark.theme-cyber2 .via-\[#5A8FC4\], .dark.theme-cyber2 .dark\:via-\[\#5A8FC4\],
|
||||
.dark.theme-cyber2 .to-\[#5A8FC4\], .dark.theme-cyber2 .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(280, 80%, 55%) !important; }
|
||||
|
||||
.light.theme-cyber2 .from-\[#C84B4B\], .light.theme-cyber2 .dark\:from-\[\#C84B4B\],
|
||||
.light.theme-cyber2 .via-\[#C84B4B\], .light.theme-cyber2 .dark\:via-\[\#C84B4B\],
|
||||
.light.theme-cyber2 .to-\[#C84B4B\], .light.theme-cyber2 .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(180, 80%, 40%) !important; }
|
||||
|
||||
.light.theme-cyber2 .from-\[#5A8FC4\], .light.theme-cyber2 .dark\:from-\[\#5A8FC4\],
|
||||
.light.theme-cyber2 .via-\[#5A8FC4\], .light.theme-cyber2 .dark\:via-\[\#5A8FC4\],
|
||||
.light.theme-cyber2 .to-\[#5A8FC4\], .light.theme-cyber2 .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(280, 60%, 50%) !important; }
|
||||
|
||||
/* ---- SVG stroke/fill overrides ---- */
|
||||
.dark.theme-cyber2 [stroke="#CC0000"] { stroke: hsl(180, 100%, 50%) !important; }
|
||||
.dark.theme-cyber2 [stroke="#0033CC"] { stroke: hsl(280, 80%, 55%) !important; }
|
||||
.light.theme-cyber2 [stroke="#CC0000"] { stroke: hsl(180, 80%, 40%) !important; }
|
||||
.light.theme-cyber2 [stroke="#0033CC"] { stroke: hsl(280, 60%, 50%) !important; }
|
||||
|
||||
/* ============================================================================
|
||||
Light mode — deeper visual polish
|
||||
============================================================================ */
|
||||
|
||||
.light.theme-cyber2 .rounded-xl.border.bg-card {
|
||||
box-shadow: 0 1px 3px hsla(180, 80%, 30%, 0.08), 0 4px 12px hsla(180, 80%, 30%, 0.04) !important;
|
||||
}
|
||||
|
||||
.light.theme-cyber2 aside .group:hover {
|
||||
background: hsla(180, 80%, 40%, 0.04) !important;
|
||||
}
|
||||
|
||||
.light.theme-cyber2 .hover\:bg-accent:hover {
|
||||
background: hsla(180, 80%, 40%, 0.06) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Client Portal Overrides — Background for portal pages
|
||||
============================================================================ */
|
||||
|
||||
/* Make the outer portal container transparent so body::before background shows */
|
||||
.dark.theme-cyber2 .min-h-screen.bg-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar — dark glass */
|
||||
.dark.theme-cyber2 aside.bg-card.border-r.border-border {
|
||||
background: hsl(240, 15%, 3%) !important;
|
||||
border-right-color: hsl(270, 25%, 10%) !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar nav items — muted text */
|
||||
.dark.theme-cyber2 aside .text-muted-foreground {
|
||||
color: hsl(240, 8%, 50%) !important;
|
||||
}
|
||||
.dark.theme-cyber2 aside button:hover {
|
||||
background: hsla(330, 100%, 50%, 0.08) !important;
|
||||
color: hsl(0, 0%, 90%) !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar active nav item */
|
||||
.dark.theme-cyber2 aside .bg-primary\/10.text-primary {
|
||||
background: hsl(330, 100%, 50%) !important;
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.1);
|
||||
border-color: hsl(330, 100%, 50%) !important;
|
||||
}
|
||||
.dark.theme-cyber2 aside .bg-primary\/10.text-primary svg {
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
}
|
||||
|
||||
/* Portal header — dark glass */
|
||||
.dark.theme-cyber2 header.bg-card\/80 {
|
||||
background: hsl(240, 15%, 3%) !important;
|
||||
border-bottom-color: hsl(270, 25%, 10%) !important;
|
||||
}
|
||||
|
||||
/* Portal card backgrounds — semi-transparent glass */
|
||||
.dark.theme-cyber2 .bg-card {
|
||||
background: hsla(240, 15%, 7%, 0.65) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-color: hsla(270, 30%, 20%, 0.35) !important;
|
||||
}
|
||||
|
||||
/* Portal borders */
|
||||
.dark.theme-cyber2 .border-border {
|
||||
border-color: hsl(270, 25%, 12%) !important;
|
||||
}
|
||||
|
||||
/* Portal input fields */
|
||||
.dark.theme-cyber2 .bg-muted {
|
||||
background: hsl(240, 12%, 9%) !important;
|
||||
}
|
||||
|
||||
/* Portal primary buttons */
|
||||
.dark.theme-cyber2 .bg-primary {
|
||||
background: hsl(330, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
|
||||
}
|
||||
.dark.theme-cyber2 .bg-primary:hover {
|
||||
background: hsl(330, 100%, 55%) !important;
|
||||
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
|
||||
}
|
||||
|
||||
@@ -38,34 +38,38 @@
|
||||
--sidebar-ring: 330 100% 50%;
|
||||
}
|
||||
.light.theme-cyberpunk {
|
||||
--background: 220 15% 96%;
|
||||
--foreground: 240 30% 10%;
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 240 10% 15%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 30% 10%;
|
||||
--card-foreground: 240 10% 15%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 30% 10%;
|
||||
--primary: 330 80% 48%;
|
||||
--popover-foreground: 240 10% 15%;
|
||||
--primary: 330 100% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 180 80% 38%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 240 8% 92%;
|
||||
--muted-foreground: 240 8% 48%;
|
||||
--accent: 260 65% 52%;
|
||||
--secondary: 180 100% 50%;
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 240 5% 94%;
|
||||
--muted-foreground: 240 4% 48%;
|
||||
--accent: 260 80% 60%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 80% 55%;
|
||||
--destructive: 0 84% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 270 15% 84%;
|
||||
--input: 240 8% 92%;
|
||||
--ring: 330 80% 48%;
|
||||
--success: 110 100% 50%;
|
||||
--success-foreground: 0 0% 0%;
|
||||
--warning: 30 100% 55%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--border: 270 10% 88%;
|
||||
--input: 240 5% 92%;
|
||||
--ring: 330 100% 50%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 240 30% 10%;
|
||||
--sidebar-primary: 330 80% 48%;
|
||||
--sidebar-foreground: 240 10% 15%;
|
||||
--sidebar-primary: 330 100% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 8% 92%;
|
||||
--sidebar-accent-foreground: 240 30% 10%;
|
||||
--sidebar-border: 270 15% 84%;
|
||||
--sidebar-ring: 330 80% 48%;
|
||||
--sidebar-accent: 240 5% 94%;
|
||||
--sidebar-accent-foreground: 240 10% 15%;
|
||||
--sidebar-border: 270 10% 88%;
|
||||
--sidebar-ring: 330 100% 50%;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -89,11 +93,7 @@
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, transparent 0px, transparent 59px, rgba(255, 0, 127, 0.03) 59px, transparent 61px),
|
||||
repeating-linear-gradient(0deg, transparent 0px, transparent 39px, rgba(0, 200, 255, 0.02) 39px, transparent 41px);
|
||||
background-size: 200% 200%;
|
||||
animation: cyberpunk-bg 50s ease-in-out infinite;
|
||||
background: hsl(0, 0%, 98%);
|
||||
}
|
||||
|
||||
@keyframes cyberpunk-bg {
|
||||
@@ -106,12 +106,17 @@
|
||||
/* ============================================================================
|
||||
App Shell � Make the background transparent so body::before shows through
|
||||
============================================================================ */
|
||||
.dark.theme-cyberpunk .min-h-screen.bg-\[\#F8F8F8\] {
|
||||
.dark.theme-cyberpunk .min-h-screen.bg-\[\#F8F8F8\],
|
||||
.light.theme-cyberpunk .min-h-screen.bg-\[\#F8F8F8\] {
|
||||
background: transparent !important;
|
||||
}
|
||||
.dark.theme-cyberpunk .dark\:bg-\[\#0A0A0A\] {
|
||||
.dark.theme-cyberpunk .dark\:bg-\[\#0A0A0A\],
|
||||
.light.theme-cyberpunk .dark\:bg-\[\#0A0A0A\] {
|
||||
background: transparent !important;
|
||||
}
|
||||
.light.theme-cyberpunk .min-h-screen {
|
||||
background: hsl(0, 0%, 98%) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Top Navigation & Header Bar
|
||||
@@ -152,24 +157,32 @@
|
||||
/* ============================================================================
|
||||
Page Header � Dashboard title
|
||||
============================================================================ */
|
||||
.dark.theme-cyberpunk .border-l-4.border-\[\#C84B4B\] {
|
||||
.dark.theme-cyberpunk .border-l-4.border-\[\#C84B4B\],
|
||||
.light.theme-cyberpunk .border-l-4.border-\[\#C84B4B\] {
|
||||
border-left-color: hsl(330, 100%, 50%) !important;
|
||||
}
|
||||
.dark.theme-cyberpunk .dark\:border-\[\#FF1111\] {
|
||||
.dark.theme-cyberpunk .dark\:border-\[\#FF1111\],
|
||||
.light.theme-cyberpunk .dark\:border-\[\#FF1111\] {
|
||||
border-left-color: hsl(330, 100%, 50%) !important;
|
||||
}
|
||||
.dark.theme-cyberpunk .dark\:text-white.text-2xl.font-bold {
|
||||
color: hsl(0, 0%, 97%) !important;
|
||||
text-shadow: 0 0 6px rgba(255, 0, 127, 0.15);
|
||||
}
|
||||
.dark.theme-cyberpunk .dark\:text-\[\#AAAAAA\] {
|
||||
.light.theme-cyberpunk .text-2xl.font-bold {
|
||||
color: hsl(220, 20%, 12%) !important;
|
||||
font-weight: 800 !important;
|
||||
}
|
||||
.dark.theme-cyberpunk .dark\:text-\[\#AAAAAA\],
|
||||
.light.theme-cyberpunk .text-\[\#AAAAAA\] {
|
||||
color: hsl(240, 8%, 50%) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Section Subtitles � "Pipeline Overview" / "Analytics"
|
||||
============================================================================ */
|
||||
.dark.theme-cyberpunk .dark\:text-\[\#666666\].text-xs.font-semibold.tracking-widest {
|
||||
.dark.theme-cyberpunk .dark\:text-\[\#666666\].text-xs.font-semibold.tracking-widest,
|
||||
.light.theme-cyberpunk .text-xs.font-semibold.tracking-widest {
|
||||
color: hsl(240, 8%, 38%) !important;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
@@ -257,7 +270,8 @@
|
||||
============================================================================ */
|
||||
|
||||
/* Pipeline Overview metric cards � semi-transparent glass + neon-pink glow border */
|
||||
.dark.theme-cyberpunk .group.h-full > .rounded-xl.border {
|
||||
.dark.theme-cyberpunk .group.h-full > .rounded-xl.border,
|
||||
.light.theme-cyberpunk .group.h-full > .rounded-xl.border {
|
||||
background: rgba(20, 20, 25, 0.75) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border: 2px solid hsl(330, 100%, 50%) !important;
|
||||
@@ -267,7 +281,8 @@
|
||||
0 -2px 8px rgba(255, 0, 127, 0.15),
|
||||
0 4px 24px rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
.dark.theme-cyberpunk .group.h-full:hover > .rounded-xl.border {
|
||||
.dark.theme-cyberpunk .group.h-full:hover > .rounded-xl.border,
|
||||
.light.theme-cyberpunk .group.h-full:hover > .rounded-xl.border {
|
||||
border-color: hsl(330, 100%, 55%) !important;
|
||||
box-shadow: 0 0 0 2px hsla(330, 100%, 50%, 0.5),
|
||||
0 0 20px rgba(255, 0, 127, 0.3),
|
||||
@@ -277,13 +292,15 @@
|
||||
}
|
||||
|
||||
/* Neon-pink top border strip on every card */
|
||||
.dark.theme-cyberpunk .h-1.w-full.bg-gradient-to-r.shadow-sm {
|
||||
.dark.theme-cyberpunk .h-1.w-full.bg-gradient-to-r.shadow-sm,
|
||||
.light.theme-cyberpunk .h-1.w-full.bg-gradient-to-r.shadow-sm {
|
||||
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(330, 100%, 65%), hsl(330, 100%, 50%)) !important;
|
||||
box-shadow: 0 0 8px rgba(255, 0, 127, 0.3), 0 0 16px rgba(255, 0, 127, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Stat value � bright white with subtle glow */
|
||||
.dark.theme-cyberpunk .text-3xl.font-bold.tracking-tight {
|
||||
.dark.theme-cyberpunk .text-3xl.font-bold.tracking-tight,
|
||||
.light.theme-cyberpunk .text-3xl.font-bold.tracking-tight {
|
||||
color: hsl(0, 0%, 97%) !important;
|
||||
text-shadow: 0 0 8px rgba(255, 0, 127, 0.1);
|
||||
}
|
||||
@@ -336,7 +353,8 @@
|
||||
}
|
||||
|
||||
/* Bottom glow bar on cards */
|
||||
.dark.theme-cyberpunk .absolute.bottom-0.left-0.right-0.h-1 {
|
||||
.dark.theme-cyberpunk .absolute.bottom-0.left-0.right-0.h-1,
|
||||
.light.theme-cyberpunk .absolute.bottom-0.left-0.right-0.h-1 {
|
||||
background: linear-gradient(to right, transparent, hsla(330, 100%, 50%, 0.25), transparent) !important;
|
||||
}
|
||||
|
||||
@@ -829,7 +847,10 @@
|
||||
============================================================================ */
|
||||
.dark.theme-cyberpunk .grid.grid-cols-1.md\:grid-cols-2.xl\:grid-cols-3.gap-4 > div,
|
||||
.dark.theme-cyberpunk [class*="bg-card"],
|
||||
.dark.theme-cyberpunk .rounded-xl.border.bg-card {
|
||||
.dark.theme-cyberpunk .rounded-xl.border.bg-card,
|
||||
.light.theme-cyberpunk .grid.grid-cols-1.md\:grid-cols-2.xl\:grid-cols-3.gap-4 > div,
|
||||
.light.theme-cyberpunk [class*="bg-card"],
|
||||
.light.theme-cyberpunk .rounded-xl.border.bg-card {
|
||||
border-top: 2px solid hsl(330, 100%, 50%) !important;
|
||||
border-bottom: 2px solid hsl(330, 100%, 50%) !important;
|
||||
box-shadow: 0 4px 20px rgba(255, 0, 127, 0.15) !important;
|
||||
@@ -929,6 +950,11 @@
|
||||
.dark.theme-cyberpunk .dark\:text-\[\#666666\].text-xs.font-semibold.tracking-widest.uppercase {
|
||||
color: hsl(210, 20%, 88%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .text-xs.font-semibold.tracking-widest.uppercase {
|
||||
color: hsl(220, 15%, 20%) !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Pipeline Color Bars — Cyberpunk neon pink/cyan
|
||||
@@ -969,3 +995,322 @@
|
||||
color: #1BB0CE !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* Progress bar background track — light theme */
|
||||
.light.theme-cyberpunk .w-full.h-1\.5.bg-muted.rounded-full {
|
||||
background: hsl(240, 5%, 90%) !important;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
/* Progress bar fill — red/faded-red → neon pink */
|
||||
.light.theme-cyberpunk .h-full.rounded-full.bg-gradient-to-r {
|
||||
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(180, 100%, 50%)) !important;
|
||||
box-shadow: 0 0 6px rgba(255, 0, 127, 0.3) !important;
|
||||
}
|
||||
/* Progress bar fill — blue → neon cyan */
|
||||
.light.theme-cyberpunk .h-full.rounded-full {
|
||||
background: hsl(180, 100%, 50%) !important;
|
||||
}
|
||||
/* Percentage text above progress bars — dark slate */
|
||||
.light.theme-cyberpunk .text-sm.font-semibold {
|
||||
color: hsl(220, 15%, 20%) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
LIGHT THEME — Metric Card Icons & Indicator Lines Override
|
||||
============================================================================ */
|
||||
.light.theme-cyberpunk .grid.grid-cols-1.md\:grid-cols-2.xl\:grid-cols-3.gap-4 > div,
|
||||
.light.theme-cyberpunk [class*="bg-card"],
|
||||
.light.theme-cyberpunk .rounded-xl.border.bg-card {
|
||||
border-top: 2px solid hsl(330, 100%, 50%) !important;
|
||||
border-bottom: 2px solid hsl(330, 100%, 50%) !important;
|
||||
box-shadow: 0 4px 20px rgba(255, 0, 127, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06) !important;
|
||||
background: hsl(0, 0%, 100%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .group.h-full > .rounded-xl.border {
|
||||
background: hsl(0, 0%, 100%) !important;
|
||||
border: 2px solid hsl(330, 100%, 50%) !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 20px rgba(255, 0, 127, 0.1) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .group.h-full:hover > .rounded-xl.border {
|
||||
border-color: hsl(330, 100%, 55%) !important;
|
||||
box-shadow: 0 4px 24px rgba(255, 0, 127, 0.15), 0 1px 3px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .h-1.w-full.bg-gradient-to-r.shadow-sm {
|
||||
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(330, 100%, 65%), hsl(330, 100%, 50%)) !important;
|
||||
box-shadow: 0 0 8px rgba(255, 0, 127, 0.2) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .text-3xl.font-bold.tracking-tight {
|
||||
color: hsl(240, 10%, 15%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .text-sm.font-medium.text-muted-foreground {
|
||||
color: hsl(240, 4%, 48%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .flex.h-12.w-12.items-center.justify-center.rounded-xl {
|
||||
background: hsla(330, 100%, 50%, 0.08) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .flex.h-12.w-12.items-center.justify-center.rounded-xl svg.h-6 {
|
||||
color: hsl(330, 100%, 50%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .absolute.bottom-0.left-0.right-0.h-1 {
|
||||
background: linear-gradient(to right, transparent, hsla(330, 100%, 50%, 0.2), transparent) !important;
|
||||
}
|
||||
|
||||
/* Pipeline accent lines — odd cards neon pink */
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(330, 100%, 65%), hsl(330, 100%, 50%)) !important;
|
||||
box-shadow: 0 0 8px rgba(255, 0, 127, 0.3) !important;
|
||||
}
|
||||
/* Pipeline accent lines — even cards neon cyan */
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(180, 100%, 65%), hsl(180, 100%, 50%)) !important;
|
||||
box-shadow: 0 0 8px rgba(0, 240, 255, 0.3) !important;
|
||||
}
|
||||
/* Icon containers — odd neon pink, even neon cyan */
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
|
||||
background: hsla(330, 100%, 50%, 0.12) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
|
||||
background: hsla(180, 100%, 50%, 0.1) !important;
|
||||
}
|
||||
/* Icon SVG colors — odd neon pink, even neon cyan */
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
|
||||
color: hsl(330, 100%, 50%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
|
||||
color: hsl(180, 100%, 50%) !important;
|
||||
}
|
||||
/* Bottom glow bars — odd neon pink, even neon cyan */
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
|
||||
background: linear-gradient(to right, transparent, hsla(330, 100%, 50%, 0.3), transparent) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
|
||||
background: linear-gradient(to right, transparent, hsla(180, 100%, 50%, 0.25), transparent) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
|
||||
background: linear-gradient(to right, hsl(330, 100%, 50%), hsl(180, 100%, 50%)) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
LIGHT THEME — Donut Chart Segment Colors (mirrors dark mode)
|
||||
============================================================================ */
|
||||
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(1) {
|
||||
fill: hsl(285, 100%, 50%) !important;
|
||||
filter: drop-shadow(0 0 8px rgba(189, 0, 255, 0.4));
|
||||
}
|
||||
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(2) {
|
||||
fill: hsl(186, 100%, 50%) !important;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 229, 255, 0.4));
|
||||
}
|
||||
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(3) {
|
||||
fill: hsl(51, 100%, 50%) !important;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.4));
|
||||
}
|
||||
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(4) {
|
||||
fill: hsl(110, 100%, 50%) !important;
|
||||
filter: drop-shadow(0 0 8px rgba(57, 255, 20, 0.4));
|
||||
}
|
||||
.light.theme-cyberpunk svg path[stroke="#000000"]:nth-of-type(5) {
|
||||
fill: hsl(340, 100%, 50%) !important;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 0, 85, 0.3));
|
||||
}
|
||||
|
||||
/* Legend dots — match dark mode colors */
|
||||
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(1) .rounded-full {
|
||||
background: hsl(285, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(189, 0, 255, 0.5) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(2) .rounded-full {
|
||||
background: hsl(186, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(0, 229, 255, 0.5) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(3) .rounded-full {
|
||||
background: hsl(51, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.5) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(4) .rounded-full {
|
||||
background: hsl(110, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(57, 255, 20, 0.5) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .grid.grid-cols-2.gap-2 > div:nth-child(5) .rounded-full {
|
||||
background: hsl(340, 100%, 50%) !important;
|
||||
box-shadow: 0 0 8px rgba(255, 0, 85, 0.4) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
LIGHT THEME — Bar Chart (mirrors dark mode)
|
||||
============================================================================ */
|
||||
.light.theme-cyberpunk svg rect[fill*="newLeadsGrad"] {
|
||||
fill: hsl(330, 100%, 50%) !important;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 0, 127, 0.5)) !important;
|
||||
rx: 4;
|
||||
}
|
||||
.light.theme-cyberpunk svg rect[fill*="closedGrad"] {
|
||||
fill: hsl(186, 100%, 50%) !important;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 229, 255, 0.4)) !important;
|
||||
rx: 4;
|
||||
}
|
||||
.light.theme-cyberpunk .items-start .gap-4 > :first-child .rounded-sm {
|
||||
background: hsl(330, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 127, 0.7), 0 0 20px rgba(255, 0, 127, 0.25) !important;
|
||||
border: 1px solid hsl(330, 100%, 60%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .items-start .gap-4 > :nth-child(2) .rounded-sm {
|
||||
background: hsl(186, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(0, 229, 255, 0.6), 0 0 20px rgba(0, 229, 255, 0.2) !important;
|
||||
border: 1px solid hsl(186, 100%, 55%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .items-start .gap-4 > :first-child .text-xs {
|
||||
color: hsl(330, 100%, 65%) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.light.theme-cyberpunk .items-start .gap-4 > :nth-child(2) .text-xs {
|
||||
color: hsl(186, 100%, 60%) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
LIGHT THEME — Status Badges (mirrors dark mode neon pills)
|
||||
============================================================================ */
|
||||
.light.theme-cyberpunk [class*="bg-blue-500/10"] {
|
||||
background: hsla(186, 100%, 50%, 0.12) !important;
|
||||
color: hsl(186, 100%, 40%) !important;
|
||||
border-color: hsla(186, 100%, 50%, 0.25) !important;
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
.light.theme-cyberpunk [class*="bg-amber-500/10"] {
|
||||
background: hsla(285, 100%, 50%, 0.12) !important;
|
||||
color: hsl(285, 100%, 45%) !important;
|
||||
border-color: hsla(285, 100%, 50%, 0.25) !important;
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
.light.theme-cyberpunk [class*="bg-purple-500/10"] {
|
||||
background: hsla(40, 100%, 55%, 0.12) !important;
|
||||
color: hsl(40, 90%, 40%) !important;
|
||||
border-color: hsla(40, 100%, 55%, 0.25) !important;
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
.light.theme-cyberpunk [class*="bg-emerald-500/10"] {
|
||||
background: hsla(350, 75%, 45%, 0.12) !important;
|
||||
color: hsl(350, 75%, 40%) !important;
|
||||
border-color: hsla(350, 75%, 45%, 0.25) !important;
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
.light.theme-cyberpunk [class*="bg-zinc-500/10"] {
|
||||
background: hsla(0, 0%, 40%, 0.08) !important;
|
||||
color: hsl(0, 0%, 40%) !important;
|
||||
border-color: hsla(0, 0%, 40%, 0.15) !important;
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
.light.theme-cyberpunk .text-blue-600.dark\:text-blue-400 {
|
||||
color: hsl(186, 100%, 40%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .text-amber-600.dark\:text-amber-400 {
|
||||
color: hsl(285, 100%, 45%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .text-purple-600.dark\:text-purple-400 {
|
||||
color: hsl(40, 90%, 40%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .text-emerald-600.dark\:text-emerald-400 {
|
||||
color: hsl(350, 75%, 40%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk .text-zinc-600.dark\:text-zinc-400 {
|
||||
color: hsl(0, 0%, 40%) !important;
|
||||
}
|
||||
|
||||
/* Status dots — light theme */
|
||||
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-blue-500 {
|
||||
background: hsl(186, 100%, 50%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-amber-500 {
|
||||
background: hsl(285, 100%, 50%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-purple-500 {
|
||||
background: hsl(40, 100%, 55%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-emerald-500 {
|
||||
background: hsl(350, 75%, 45%) !important;
|
||||
}
|
||||
.light.theme-cyberpunk span.h-1\.5.w-1\.5.rounded-full.bg-zinc-500 {
|
||||
background: hsl(0, 0%, 40%) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Client Portal Overrides — Cyberpunk background for portal pages
|
||||
============================================================================ */
|
||||
|
||||
/* Make the outer portal container transparent so body::before background shows */
|
||||
.dark.theme-cyberpunk .min-h-screen.bg-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar — dark glass */
|
||||
.dark.theme-cyberpunk aside.bg-card.border-r.border-border {
|
||||
background: hsl(240, 15%, 3%) !important;
|
||||
border-right-color: hsl(270, 25%, 10%) !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar nav items — muted text */
|
||||
.dark.theme-cyberpunk aside .text-muted-foreground {
|
||||
color: hsl(240, 8%, 50%) !important;
|
||||
}
|
||||
.dark.theme-cyberpunk aside button:hover {
|
||||
background: hsla(330, 100%, 50%, 0.08) !important;
|
||||
color: hsl(0, 0%, 90%) !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar active nav item — neon pink pill */
|
||||
.dark.theme-cyberpunk aside .bg-primary\/10.text-primary {
|
||||
background: hsl(330, 100%, 50%) !important;
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.1);
|
||||
border-color: hsl(330, 100%, 50%) !important;
|
||||
}
|
||||
.dark.theme-cyberpunk aside .bg-primary\/10.text-primary svg {
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
}
|
||||
|
||||
/* Portal header — dark glass */
|
||||
.dark.theme-cyberpunk header.bg-card\/80 {
|
||||
background: hsl(240, 15%, 3%) !important;
|
||||
border-bottom-color: hsl(270, 25%, 10%) !important;
|
||||
}
|
||||
|
||||
/* Portal card backgrounds — semi-transparent glass */
|
||||
.dark.theme-cyberpunk .bg-card {
|
||||
background: hsla(240, 15%, 7%, 0.65) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-color: hsla(270, 30%, 20%, 0.35) !important;
|
||||
}
|
||||
|
||||
/* Portal borders */
|
||||
.dark.theme-cyberpunk .border-border {
|
||||
border-color: hsl(270, 25%, 12%) !important;
|
||||
}
|
||||
|
||||
/* Portal input fields */
|
||||
.dark.theme-cyberpunk .bg-muted {
|
||||
background: hsl(240, 12%, 9%) !important;
|
||||
}
|
||||
.dark.theme-cyberpunk .border-border:focus {
|
||||
border-color: hsl(330, 100%, 50%) !important;
|
||||
}
|
||||
|
||||
/* Portal primary buttons */
|
||||
.dark.theme-cyberpunk .bg-primary {
|
||||
background: hsl(330, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
|
||||
}
|
||||
.dark.theme-cyberpunk .bg-primary:hover {
|
||||
background: hsl(330, 100%, 55%) !important;
|
||||
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
|
||||
}
|
||||
|
||||
/* Portal primary text highlights */
|
||||
.dark.theme-cyberpunk .text-primary {
|
||||
color: hsl(330, 100%, 55%) !important;
|
||||
}
|
||||
|
||||
/* Portal muted text */
|
||||
.dark.theme-cyberpunk .text-muted-foreground {
|
||||
color: hsl(240, 8%, 50%) !important;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"theme": "forest-minimal",
|
||||
"name": "Forest Minimal",
|
||||
"colors": {
|
||||
"background": "84 25% 97%",
|
||||
"surface": "0 0% 100%",
|
||||
"surface-2": "84 20% 96%",
|
||||
"primary": "84 50% 53%",
|
||||
"primary-hover": "84 50% 46%",
|
||||
"primary-light": "84 53% 92%",
|
||||
"success": "122 39% 49%",
|
||||
"text-primary": "#1F2937",
|
||||
"text-secondary": "#6B7280",
|
||||
"card-border": "#EEF1EC",
|
||||
"background-dark": "#EAF3E5",
|
||||
"background-light": "#F5F9F2",
|
||||
"sage-light": "#E8F0E2",
|
||||
"mint-pale": "#EEF5EA",
|
||||
"foliage-dark": "#5A7D5F",
|
||||
"foliage-mid": "#65896A",
|
||||
"foliage-light": "#7B9E7D",
|
||||
"foliage-pale": "#90B090",
|
||||
"scandi-sage": "#C8DCC8"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"theme": {
|
||||
"id": "luminous",
|
||||
"name": "Luminous",
|
||||
"description": "Deep Glass / Glassmorphism 2.0 — blue-teal-amber palette with specular highlights, 3D bevel/emboss borders, refraction effects, and ultra-frosted translucent surfaces inspired by iOS & HarmonyOS",
|
||||
"type": "dual",
|
||||
"default": false
|
||||
},
|
||||
"modes": {
|
||||
"dark": {
|
||||
"colors": {
|
||||
"background": "hsl(215, 30%, 6%)",
|
||||
"foreground": "hsl(210, 30%, 96%)",
|
||||
"card": "hsl(215, 25%, 10%)",
|
||||
"cardForeground": "hsl(210, 30%, 96%)",
|
||||
"popover": "hsl(215, 25%, 10%)",
|
||||
"popoverForeground": "hsl(210, 30%, 96%)",
|
||||
"primary": "hsl(210, 75%, 55%)",
|
||||
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||
"secondary": "hsl(185, 70%, 45%)",
|
||||
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||
"muted": "hsl(215, 15%, 14%)",
|
||||
"mutedForeground": "hsl(215, 12%, 55%)",
|
||||
"accent": "hsl(35, 80%, 55%)",
|
||||
"accentForeground": "hsl(0, 0%, 0%)",
|
||||
"destructive": "hsl(0, 75%, 50%)",
|
||||
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||
"border": "hsl(215, 18%, 20%)",
|
||||
"input": "hsl(215, 15%, 16%)",
|
||||
"ring": "hsl(210, 75%, 55%)",
|
||||
"sidebar": "hsl(215, 30%, 6%)",
|
||||
"sidebarForeground": "hsl(210, 30%, 96%)",
|
||||
"sidebarPrimary": "hsl(210, 75%, 55%)",
|
||||
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||
"sidebarAccent": "hsl(210, 35%, 16%)",
|
||||
"sidebarAccentForeground": "hsl(210, 30%, 96%)",
|
||||
"sidebarBorder": "hsl(215, 18%, 16%)",
|
||||
"sidebarRing": "hsl(210, 75%, 55%)"
|
||||
},
|
||||
"textColors": {
|
||||
"heading": "linear-gradient(135deg, #60a5fa, #2dd4bf)",
|
||||
"body": "#e2e8f0",
|
||||
"muted": "#7a8a9a",
|
||||
"link": "#60a5fa",
|
||||
"linkHover": "#f59e0b",
|
||||
"code": "#2dd4bf",
|
||||
"placeholder": "#5a6a7a"
|
||||
},
|
||||
"backgroundImage": null,
|
||||
"backgroundGradients": [
|
||||
"rgba(50, 140, 240, 0.18) at 10% 20%",
|
||||
"rgba(0, 200, 210, 0.10) at 85% 75%",
|
||||
"rgba(200, 160, 40, 0.06) at 50% 15%",
|
||||
"rgba(50, 140, 240, 0.07) at 35% 85%"
|
||||
]
|
||||
},
|
||||
"light": {
|
||||
"colors": {
|
||||
"background": "hsl(205, 38%, 86%)",
|
||||
"foreground": "hsl(210, 30%, 15%)",
|
||||
"card": "hsl(0, 0%, 100%)",
|
||||
"cardForeground": "hsl(210, 30%, 15%)",
|
||||
"popover": "hsl(0, 0%, 100%)",
|
||||
"popoverForeground": "hsl(210, 30%, 15%)",
|
||||
"primary": "hsl(210, 65%, 48%)",
|
||||
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||
"secondary": "hsl(185, 65%, 38%)",
|
||||
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||
"muted": "hsl(205, 20%, 90%)",
|
||||
"mutedForeground": "hsl(210, 12%, 48%)",
|
||||
"accent": "hsl(35, 75%, 50%)",
|
||||
"accentForeground": "hsl(0, 0%, 0%)",
|
||||
"destructive": "hsl(0, 75%, 45%)",
|
||||
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||
"border": "hsl(210, 18%, 78%)",
|
||||
"input": "hsl(210, 15%, 84%)",
|
||||
"ring": "hsl(210, 65%, 48%)",
|
||||
"sidebar": "hsl(0, 0%, 100%)",
|
||||
"sidebarForeground": "hsl(210, 30%, 15%)",
|
||||
"sidebarPrimary": "hsl(210, 65%, 48%)",
|
||||
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||
"sidebarAccent": "hsl(205, 18%, 90%)",
|
||||
"sidebarAccentForeground": "hsl(210, 30%, 15%)",
|
||||
"sidebarBorder": "hsl(210, 18%, 78%)",
|
||||
"sidebarRing": "hsl(210, 65%, 48%)"
|
||||
},
|
||||
"textColors": {
|
||||
"heading": "linear-gradient(135deg, #3b82f6, #0d9488)",
|
||||
"body": "#1e293b",
|
||||
"muted": "#6b7280",
|
||||
"link": "#3b82f6",
|
||||
"linkHover": "#d97706",
|
||||
"code": "#3b82f6",
|
||||
"placeholder": "#9ca3af"
|
||||
},
|
||||
"backgroundImage": null,
|
||||
"backgroundGradients": [
|
||||
"rgba(50, 140, 240, 0.06) at 15% 20%",
|
||||
"rgba(0, 200, 210, 0.04) at 85% 75%",
|
||||
"rgba(200, 160, 40, 0.02) at 50% 50%"
|
||||
]
|
||||
}
|
||||
},
|
||||
"borderRadius": "0.75rem",
|
||||
"typography": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"headingFont": "Inter, sans-serif"
|
||||
},
|
||||
"keyColors": {
|
||||
"primary": "#60a5fa (dark) / #3b82f6 (light)",
|
||||
"background": "#0b111e (dark) / #d2e2f2 (light)",
|
||||
"card": "#121a28 (dark) / #ffffff (light)",
|
||||
"sidebar": "#0b111e (dark) / #ffffff (light)",
|
||||
"border": "#283040 (dark) / #d4d4dc (light)",
|
||||
"text": "#e2e8f0 (dark) / #1e293b (light)",
|
||||
"mutedText": "#7a8a9a (dark) / #6b7280 (light)",
|
||||
"headingGradient": "#60a5fa → #2dd4bf (dark) / #3b82f6 → #0d9488 (light)"
|
||||
}
|
||||
}
|
||||
+269
-168
@@ -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,198 +79,298 @@
|
||||
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;
|
||||
color: #1BB0CE !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Client Portal Overrides
|
||||
============================================================ */
|
||||
.dark.theme-pumpkin .min-h-screen.bg-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.dark.theme-pumpkin aside.bg-card.border-r.border-border {
|
||||
background: hsl(25, 35%, 4%) !important;
|
||||
border-right-color: hsl(25, 20%, 14%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-pumpkin aside .text-muted-foreground {
|
||||
color: hsl(30, 10%, 50%) !important;
|
||||
}
|
||||
.dark.theme-pumpkin aside button:hover {
|
||||
background: hsla(25, 95%, 50%, 0.08) !important;
|
||||
color: hsl(0, 0%, 90%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-pumpkin aside .bg-primary\/10.text-primary {
|
||||
background: hsl(25, 95%, 50%) !important;
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
box-shadow: 0 0 10px hsla(25, 95%, 50%, 0.3), 0 0 20px hsla(25, 95%, 50%, 0.1);
|
||||
border-color: hsl(25, 95%, 50%) !important;
|
||||
}
|
||||
.dark.theme-pumpkin aside .bg-primary\/10.text-primary svg {
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-pumpkin header.bg-card\/80 {
|
||||
background: hsl(25, 35%, 4%) !important;
|
||||
border-bottom-color: hsl(25, 20%, 14%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-pumpkin .bg-card {
|
||||
background: hsla(25, 30%, 7%, 0.65) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-color: hsla(25, 20%, 16%, 0.35) !important;
|
||||
}
|
||||
|
||||
.dark.theme-pumpkin .border-border {
|
||||
border-color: hsl(25, 18%, 14%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-pumpkin .bg-muted {
|
||||
background: hsl(25, 15%, 10%) !important;
|
||||
}
|
||||
|
||||
.dark.theme-pumpkin .bg-primary {
|
||||
background: hsl(25, 95%, 50%) !important;
|
||||
box-shadow: 0 0 10px hsla(25, 95%, 50%, 0.2);
|
||||
}
|
||||
.dark.theme-pumpkin .bg-primary:hover {
|
||||
background: hsl(25, 95%, 55%) !important;
|
||||
box-shadow: 0 0 15px hsla(25, 95%, 50%, 0.35);
|
||||
}
|
||||
|
||||
/* Light mode portal */
|
||||
.light.theme-pumpkin .min-h-screen.bg-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.light.theme-pumpkin aside.bg-card.border-r.border-border {
|
||||
background: hsl(0, 0%, 100%) !important;
|
||||
border-right-color: hsl(25, 15%, 82%) !important;
|
||||
}
|
||||
|
||||
.light.theme-pumpkin header.bg-card\/80 {
|
||||
background: hsl(0, 0%, 98%) !important;
|
||||
border-bottom-color: hsl(25, 15%, 82%) !important;
|
||||
}
|
||||
|
||||
.light.theme-pumpkin .bg-card {
|
||||
background: hsla(0, 0%, 100%, 0.65) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-color: hsla(25, 15%, 80%, 0.5) !important;
|
||||
}
|
||||
|
||||
.light.theme-pumpkin .bg-primary {
|
||||
background: hsl(25, 90%, 42%) !important;
|
||||
}
|
||||
.light.theme-pumpkin .bg-primary:hover {
|
||||
background: hsl(25, 90%, 48%) !important;
|
||||
}
|
||||
|
||||
@@ -183,3 +183,71 @@
|
||||
color: #1BB0CE !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Client Portal Overrides — Background for portal pages
|
||||
============================================================================ */
|
||||
|
||||
/* Make the outer portal container transparent so body::before background shows */
|
||||
.dark.theme-spidey .min-h-screen.bg-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar — dark glass */
|
||||
.dark.theme-spidey aside.bg-card.border-r.border-border {
|
||||
background: hsl(240, 15%, 3%) !important;
|
||||
border-right-color: hsl(270, 25%, 10%) !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar nav items — muted text */
|
||||
.dark.theme-spidey aside .text-muted-foreground {
|
||||
color: hsl(240, 8%, 50%) !important;
|
||||
}
|
||||
.dark.theme-spidey aside button:hover {
|
||||
background: hsla(0, 100%, 50%, 0.08) !important;
|
||||
color: hsl(0, 0%, 90%) !important;
|
||||
}
|
||||
|
||||
/* Portal sidebar active nav item */
|
||||
.dark.theme-spidey aside .bg-primary\/10.text-primary {
|
||||
background: hsl(0, 100%, 50%) !important;
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 0, 0.3), 0 0 20px rgba(255, 0, 0, 0.1);
|
||||
border-color: hsl(0, 100%, 50%) !important;
|
||||
}
|
||||
.dark.theme-spidey aside .bg-primary\/10.text-primary svg {
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
}
|
||||
|
||||
/* Portal header — dark glass */
|
||||
.dark.theme-spidey header.bg-card\/80 {
|
||||
background: hsl(240, 15%, 3%) !important;
|
||||
border-bottom-color: hsl(270, 25%, 10%) !important;
|
||||
}
|
||||
|
||||
/* Portal card backgrounds — semi-transparent glass */
|
||||
.dark.theme-spidey .bg-card {
|
||||
background: hsla(240, 15%, 7%, 0.65) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-color: hsla(270, 30%, 20%, 0.35) !important;
|
||||
}
|
||||
|
||||
/* Portal borders */
|
||||
.dark.theme-spidey .border-border {
|
||||
border-color: hsl(270, 25%, 12%) !important;
|
||||
}
|
||||
|
||||
/* Portal input fields */
|
||||
.dark.theme-spidey .bg-muted {
|
||||
background: hsl(240, 12%, 9%) !important;
|
||||
}
|
||||
|
||||
/* Portal primary buttons */
|
||||
.dark.theme-spidey .bg-primary {
|
||||
background: hsl(0, 100%, 50%) !important;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 0, 0.2);
|
||||
}
|
||||
.dark.theme-spidey .bg-primary:hover {
|
||||
background: hsl(0, 100%, 55%) !important;
|
||||
box-shadow: 0 0 15px rgba(255, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
+20
-20
@@ -40,33 +40,33 @@
|
||||
},
|
||||
"light": {
|
||||
"colors": {
|
||||
"background": "hsl(220, 15%, 96%)",
|
||||
"foreground": "hsl(240, 30%, 10%)",
|
||||
"background": "hsl(0, 0%, 98%)",
|
||||
"foreground": "hsl(240, 10%, 15%)",
|
||||
"card": "hsl(0, 0%, 100%)",
|
||||
"cardForeground": "hsl(240, 30%, 10%)",
|
||||
"cardForeground": "hsl(240, 10%, 15%)",
|
||||
"popover": "hsl(0, 0%, 100%)",
|
||||
"popoverForeground": "hsl(240, 30%, 10%)",
|
||||
"primary": "hsl(330, 80%, 48%)",
|
||||
"popoverForeground": "hsl(240, 10%, 15%)",
|
||||
"primary": "hsl(330, 100%, 50%)",
|
||||
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||
"secondary": "hsl(180, 80%, 38%)",
|
||||
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||
"muted": "hsl(240, 8%, 92%)",
|
||||
"mutedForeground": "hsl(240, 8%, 48%)",
|
||||
"accent": "hsl(260, 65%, 52%)",
|
||||
"secondary": "hsl(180, 100%, 50%)",
|
||||
"secondaryForeground": "hsl(0, 0%, 0%)",
|
||||
"muted": "hsl(240, 5%, 94%)",
|
||||
"mutedForeground": "hsl(240, 4%, 48%)",
|
||||
"accent": "hsl(260, 80%, 60%)",
|
||||
"accentForeground": "hsl(0, 0%, 100%)",
|
||||
"destructive": "hsl(0, 80%, 55%)",
|
||||
"destructive": "hsl(0, 84%, 55%)",
|
||||
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||
"border": "hsl(270, 15%, 84%)",
|
||||
"input": "hsl(240, 8%, 92%)",
|
||||
"ring": "hsl(330, 80%, 48%)",
|
||||
"border": "hsl(270, 10%, 88%)",
|
||||
"input": "hsl(240, 5%, 92%)",
|
||||
"ring": "hsl(330, 100%, 50%)",
|
||||
"sidebar": "hsl(0, 0%, 100%)",
|
||||
"sidebarForeground": "hsl(240, 30%, 10%)",
|
||||
"sidebarPrimary": "hsl(330, 80%, 48%)",
|
||||
"sidebarForeground": "hsl(240, 10%, 15%)",
|
||||
"sidebarPrimary": "hsl(330, 100%, 50%)",
|
||||
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||
"sidebarAccent": "hsl(240, 8%, 92%)",
|
||||
"sidebarAccentForeground": "hsl(240, 30%, 10%)",
|
||||
"sidebarBorder": "hsl(270, 15%, 84%)",
|
||||
"sidebarRing": "hsl(330, 80%, 48%)"
|
||||
"sidebarAccent": "hsl(240, 5%, 94%)",
|
||||
"sidebarAccentForeground": "hsl(240, 10%, 15%)",
|
||||
"sidebarBorder": "hsl(270, 10%, 88%)",
|
||||
"sidebarRing": "hsl(330, 100%, 50%)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+24
-19
@@ -269,6 +269,13 @@ function parseBody(req) {
|
||||
})
|
||||
}
|
||||
|
||||
function checkPort(http, host, port, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = http.get(`http://${host}:${port}`, { timeout }, (res) => { res.resume(); resolve() })
|
||||
r.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function parseURL(req) {
|
||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||
@@ -309,27 +316,23 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
// GET /status — combined health of all services
|
||||
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
|
||||
// Polls each service internally to avoid cross-origin CORS issues.
|
||||
// Used by the splash page to check if AI and Frontend are ready.
|
||||
// Scraper is no longer part of dev:start so it's always reported ready.
|
||||
if (req.method === "GET" && pathname === "/status") {
|
||||
const { default: http } = await import("http")
|
||||
const results = { ai: true }
|
||||
// Check scraper (port 3008)
|
||||
const results = { ai: true, scraper: true }
|
||||
// Check frontend (port 3006) — try localhost, fallback to 127.0.0.1
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||
r.on("error", reject)
|
||||
})
|
||||
results.scraper = true
|
||||
} catch { results.scraper = false }
|
||||
// Check frontend (port 3006)
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||
r.on("error", reject)
|
||||
})
|
||||
await checkPort(http, "localhost", 3006, 2000)
|
||||
results.frontend = true
|
||||
} catch { results.frontend = false }
|
||||
} catch {
|
||||
try {
|
||||
await checkPort(http, "127.0.0.1", 3006, 2000)
|
||||
results.frontend = true
|
||||
} catch {
|
||||
results.frontend = false
|
||||
}
|
||||
}
|
||||
return sendJSON(res, 200, results)
|
||||
}
|
||||
|
||||
@@ -367,9 +370,11 @@ const server = http.createServer(async (req, res) => {
|
||||
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
|
||||
let facebookLoggedIn = false
|
||||
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
||||
let scraperAvailable = false
|
||||
|
||||
try {
|
||||
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
|
||||
scraperAvailable = true
|
||||
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
|
||||
for (const [b, p] of Object.entries(profiles)) {
|
||||
if (p) browsers[b] = { path: p }
|
||||
@@ -396,8 +401,8 @@ const server = http.createServer(async (req, res) => {
|
||||
} catch {}
|
||||
|
||||
const anyDetected = Object.values(browsers).some(v => v.path)
|
||||
// first_run = any setup step is incomplete
|
||||
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
|
||||
// first_run = env or Ollama incomplete, or scraper is available but detection incomplete
|
||||
const firstRun = !envExists || !ollamaRunning || (scraperAvailable && (!anyDetected || !facebookLoggedIn))
|
||||
|
||||
return sendJSON(res, 200, {
|
||||
first_run: firstRun,
|
||||
|
||||
+432
-71
@@ -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);
|
||||
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 ---
|
||||
const isGroup = h.includes('/groups/');
|
||||
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> ---
|
||||
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 = '';
|
||||
const timeEl = el.querySelector('time');
|
||||
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(); }
|
||||
}
|
||||
|
||||
results.push({ text, author, url: postUrl, date });
|
||||
}
|
||||
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)
|
||||
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 = []
|
||||
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, query in enumerate(searches):
|
||||
page, posts = await search_facebook(page, context, query)
|
||||
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 = []
|
||||
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, query in enumerate(searches):
|
||||
page, posts = await search_facebook(page, context, query)
|
||||
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,7 +1833,12 @@ 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):
|
||||
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:
|
||||
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- ============================================================================
|
||||
-- Resource Planning — Team capacity, utilisation rates, availability
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS capacity_hours DECIMAL(5,2) NOT NULL DEFAULT 40;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT;
|
||||
|
||||
-- Set default capacities for existing users
|
||||
UPDATE users SET capacity_hours = 40 WHERE capacity_hours IS NULL;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ============================================================================
|
||||
-- Lead Scoring ML — Configurable scoring engine with historical training
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lead_scoring_config (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
feature_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
feature_type VARCHAR(50) NOT NULL DEFAULT 'keyword',
|
||||
field_ref VARCHAR(100) NOT NULL,
|
||||
weight DECIMAL(5,2) NOT NULL DEFAULT 1.00,
|
||||
value_map JSONB,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lead_scoring_active ON lead_scoring_config(is_active);
|
||||
|
||||
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score INT DEFAULT 0;
|
||||
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score_details JSONB;
|
||||
|
||||
-- Seed default scoring features
|
||||
INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map) VALUES
|
||||
('Has Email', 'boolean', 'email', 5, '{"true": 5, "false": 0}'),
|
||||
('Has Phone', 'boolean', 'phone', 5, '{"true": 5, "false": 0}'),
|
||||
('Source: Referral', 'keyword', 'source', 15, '{"referral": 15, "friend": 15, "word_of_mouth": 15}'),
|
||||
('Source: Website', 'keyword', 'source', 10, '{"website": 10, "organic": 8, "direct": 8}'),
|
||||
('Source: Social', 'keyword', 'source', 8, '{"facebook": 8, "linkedin": 8, "twitter": 5, "instagram": 5}'),
|
||||
('Source: Paid', 'keyword', 'source', 6, '{"google_ads": 6, "paid": 6, "ppc": 6}'),
|
||||
('Company Name Present', 'boolean', 'company_name', 10, '{"true": 10, "false": 0}'),
|
||||
('Description Length', 'range', 'description', 10, '{"0": 0, "50": 5, "200": 10, "500": 15}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Update ml_score for all existing leads
|
||||
UPDATE leads SET ml_score = 0 WHERE ml_score IS NULL;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- ============================================================================
|
||||
-- Email Sequences / Drip Campaigns — Automated follow-up sequences
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_campaigns (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'draft',
|
||||
trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual',
|
||||
trigger_config JSONB,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_campaign_steps (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||
step_order INT NOT NULL DEFAULT 0,
|
||||
delay_days INT NOT NULL DEFAULT 0,
|
||||
subject VARCHAR(500) NOT NULL,
|
||||
body_text TEXT,
|
||||
body_html TEXT,
|
||||
variables JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_campaign_subscribers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
contact_name VARCHAR(255),
|
||||
subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
unsubscribed_at TIMESTAMPTZ,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
current_step INT NOT NULL DEFAULT 0,
|
||||
last_sent_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_campaign_lead UNIQUE (campaign_id, lead_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_campaign_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||
subscriber_id UUID REFERENCES email_campaign_subscribers(id) ON DELETE CASCADE,
|
||||
step_id UUID REFERENCES email_campaign_steps(id) ON DELETE SET NULL,
|
||||
recipient VARCHAR(255) NOT NULL,
|
||||
subject VARCHAR(500),
|
||||
body_text TEXT,
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'sent',
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- Already defined inline in CREATE TABLE; IF NOT EXISTS not supported on this PostgreSQL version
|
||||
-- ALTER TABLE email_campaign_subscribers ADD CONSTRAINT IF NOT EXISTS uq_campaign_lead UNIQUE (campaign_id, lead_id);
|
||||
|
||||
CREATE INDEX idx_campaign_steps_order ON email_campaign_steps(campaign_id, step_order);
|
||||
CREATE INDEX idx_campaign_subscribers ON email_campaign_subscribers(campaign_id, status);
|
||||
CREATE INDEX idx_campaign_logs ON email_campaign_logs(campaign_id, sent_at);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add pinned_at column to conversation_participants for pinning chats
|
||||
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
|
||||
@@ -85,5 +85,17 @@ BEGIN;
|
||||
\echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ==='
|
||||
\i 022_custom_fields.sql
|
||||
|
||||
\echo '=== Running 023_client_portal.sql (Client Portal) ==='
|
||||
\i ../../src/app/client-portal/database/020_client_portal.sql
|
||||
|
||||
\echo '=== Running 024_resource_planning.sql (Resource Planning) ==='
|
||||
\i 024_resource_planning.sql
|
||||
|
||||
\echo '=== Running 025_lead_scoring.sql (Lead Scoring ML) ==='
|
||||
\i 025_lead_scoring.sql
|
||||
|
||||
\echo '=== Running 026_email_campaigns.sql (Email Campaigns) ==='
|
||||
\i 026_email_campaigns.sql
|
||||
|
||||
\echo '=== Migration Complete ==='
|
||||
COMMIT;
|
||||
|
||||
+4
-1
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: false,
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@@ -12,6 +12,9 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "framer-motion", "date-fns", "@radix-ui/react-icons", "recharts"],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
Generated
-3
@@ -248,7 +248,6 @@
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -263,7 +262,6 @@
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
@@ -277,7 +275,6 @@
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
|
||||
+3
-3
@@ -7,9 +7,9 @@
|
||||
"dev:signaling": "node signaling-server.mjs",
|
||||
"dev:open": "node scripts/open-browser.mjs",
|
||||
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
|
||||
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||
"dev:start": "concurrently -n AI,SIGNAL,NEXT,OPEN -c cyan,yellow,green,white \"npm run dev:rust\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||
"dev:next": "next dev -p 3006",
|
||||
"dev:precheck": "node scripts/precheck.mjs",
|
||||
"dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs",
|
||||
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
||||
"dev:rust": "node ai-server/index.mjs",
|
||||
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
||||
@@ -20,7 +20,7 @@
|
||||
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
|
||||
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3006",
|
||||
"start": "node scripts/ensure-ollama.mjs && concurrently -n AI,NEXT -c cyan,green \"npm run dev:rust\" \"next start -p 3006\"",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ if (!isRunning()) {
|
||||
}
|
||||
// Give it a moment to start listening
|
||||
if (platform() === "win32") {
|
||||
execSync("timeout /t 3 /nobreak >nul", { stdio: "ignore", timeout: 10000, shell: true })
|
||||
execSync("powershell -Command \"Start-Sleep -Seconds 3\"", { stdio: "ignore", timeout: 10000 })
|
||||
} else {
|
||||
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { use } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
ArrowLeft, Plus, Trash2, Send, Play, Pause, Users, Mail, Clock,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const [campaign, setCampaign] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [newStep, setNewStep] = useState({ subject: "", bodyText: "", delayDays: 3 })
|
||||
const [addingStep, setAddingStep] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [leadSearch, setLeadSearch] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [subscribing, setSubscribing] = useState(false)
|
||||
|
||||
const fetchCampaign = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${id}`)
|
||||
if (res.ok) setCampaign(await res.json())
|
||||
} catch (e) { console.error("Failed to load campaign", e); toast.error("Failed to load campaign") }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCampaign() }, [id])
|
||||
|
||||
const searchLeads = async (q: string) => {
|
||||
setLeadSearch(q)
|
||||
if (q.length < 2) { setSearchResults([]); return }
|
||||
try {
|
||||
const res = await fetch(`/api/leads?search=${encodeURIComponent(q)}&limit=10`)
|
||||
if (res.ok) setSearchResults(await res.json())
|
||||
else console.error("Failed to search leads:", res.status, res.statusText)
|
||||
} catch (err) { console.error("Failed to search leads:", err) }
|
||||
}
|
||||
|
||||
const addStep = async () => {
|
||||
if (!newStep.subject.trim()) return
|
||||
setAddingStep(true)
|
||||
try {
|
||||
await fetch(`/api/campaigns/${id}/steps`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newStep),
|
||||
})
|
||||
setNewStep({ subject: "", bodyText: "", delayDays: 3 })
|
||||
fetchCampaign()
|
||||
} catch (e) { console.error("Failed to add step", e); toast.error("Failed to add step") }
|
||||
setAddingStep(false)
|
||||
}
|
||||
|
||||
const subscribeLeads = async (leadIds: string[]) => {
|
||||
setSubscribing(true)
|
||||
try {
|
||||
await fetch(`/api/campaigns/${id}/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ leadIds }),
|
||||
})
|
||||
setLeadSearch("")
|
||||
setSearchResults([])
|
||||
fetchCampaign()
|
||||
} catch (e) { console.error("Failed to subscribe leads", e); toast.error("Failed to subscribe leads") }
|
||||
setSubscribing(false)
|
||||
}
|
||||
|
||||
const sendCampaign = async () => {
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${id}/send`, { method: "POST" })
|
||||
const data = await res.json()
|
||||
if (data.sent > 0) fetchCampaign()
|
||||
} catch (e) { console.error("Failed to send campaign", e); toast.error("Failed to send campaign") }
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
const updateStatus = async (status: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) console.error("Failed to update campaign status:", res.status, res.statusText)
|
||||
fetchCampaign()
|
||||
} catch (err) { console.error("Failed to update campaign status:", err) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
if (!campaign) return <div className="p-8 text-center text-muted-foreground">Campaign not found</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={campaign.name}
|
||||
description={campaign.description || "Email campaign"}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{campaign.status}</Badge>
|
||||
{campaign.status === "draft" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Activate</Button>}
|
||||
{campaign.status === "active" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("paused")}><Pause className="h-3 w-3 mr-1" />Pause</Button>}
|
||||
{campaign.status === "paused" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Resume</Button>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Subscribers</p>
|
||||
<p className="text-2xl font-bold flex items-center gap-2"><Users className="h-5 w-5 text-muted-foreground" />{campaign.subscribers?.length || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Steps</p>
|
||||
<p className="text-2xl font-bold flex items-center gap-2"><Layers className="h-5 w-5 text-muted-foreground" />{campaign.steps?.length || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Created</p>
|
||||
<p className="text-sm font-medium">{new Date(campaign.created_at).toLocaleDateString()}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="steps">
|
||||
<TabsList>
|
||||
<TabsTrigger value="steps">Steps ({campaign.steps?.length || 0})</TabsTrigger>
|
||||
<TabsTrigger value="subscribers">Subscribers ({campaign.subscribers?.length || 0})</TabsTrigger>
|
||||
<TabsTrigger value="send">Send</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="steps" className="space-y-4">
|
||||
<Card className="p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Add Step</p>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Subject *</label>
|
||||
<Input value={newStep.subject} onChange={e => setNewStep(p => ({ ...p, subject: e.target.value }))} placeholder="Follow-up: {{name}}" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Delay (days)</label>
|
||||
<Input type="number" min="0" value={newStep.delayDays} onChange={e => setNewStep(p => ({ ...p, delayDays: parseInt(e.target.value) || 0 }))} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button size="sm" onClick={addStep} disabled={addingStep || !newStep.subject.trim()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Add Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Email Body (use {{name}} for recipient name)</label>
|
||||
<textarea value={newStep.bodyText} onChange={e => setNewStep(p => ({ ...p, bodyText: e.target.value }))} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="Hi {{name}}, We'd love to help with..." />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{campaign.steps?.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<p>No steps defined yet. Add your first email above.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{campaign.steps.map((step: any, i: number) => (
|
||||
<Card key={step.id} className="p-4 flex items-start gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{step.subject}</p>
|
||||
{step.body_text && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{step.body_text}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
|
||||
<Clock className="h-3 w-3" />
|
||||
{step.delay_days > 0 ? `Wait ${step.delay_days}d` : "Immediate"}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscribers" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<p className="text-sm font-medium mb-2">Add Leads to Campaign</p>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="Search leads by name or company..." value={leadSearch} onChange={e => searchLeads(e.target.value)} className="flex-1" />
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 border rounded-lg divide-y max-h-48 overflow-y-auto">
|
||||
{searchResults.map((l: any) => (
|
||||
<div key={l.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
|
||||
<div>
|
||||
<p className="text-sm">{l.companyName || l.contactName}</p>
|
||||
<p className="text-xs text-muted-foreground">{l.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={subscribing || campaign.subscribers?.some((s: any) => s.lead_id === l.id)}
|
||||
onClick={() => subscribeLeads([l.id])}
|
||||
>
|
||||
{campaign.subscribers?.some((s: any) => s.lead_id === l.id) ? "Added" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{campaign.subscribers?.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<p>No subscribers yet. Search and add leads above.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{campaign.subscribers.map((s: any) => (
|
||||
<div key={s.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{s.contact_name || s.lead_email || s.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.email} · Step {s.current_step + 1}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">{s.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="send" className="space-y-4">
|
||||
<Card className="p-6 text-center space-y-4">
|
||||
<Mail className="h-10 w-10 text-muted-foreground mx-auto" />
|
||||
<div>
|
||||
<p className="font-medium">Send Next Emails</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{campaign.subscribers?.filter((s: any) => s.status === "pending").length || 0} subscribers ready for the next step
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={sendCampaign} disabled={sending || campaign.steps?.length === 0}>
|
||||
<Send className="mr-1.5 h-4 w-4" />
|
||||
{sending ? "Sending..." : "Send Next Batch"}
|
||||
</Button>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Layers({ className }: { className?: string }) {
|
||||
return <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.57 3.91a2 2 0 0 0 1.66 0l8.57-3.91a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function NewCampaignPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/campaigns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.id) router.push(`/campaigns/${data.id}`)
|
||||
} catch (e) { console.error("Campaign create error:", e); toast.error("Failed to create campaign") }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<PageHeader title="New Campaign" description="Create an automated email sequence">
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<Card className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Campaign Name *</label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. New Lead Follow-up" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="What is this campaign for?" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => router.push("/campaigns")}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving || !name.trim()}>{saving ? "Creating..." : "Create Campaign"}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Plus, Mail, Users, Layers, Calendar } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: "bg-zinc-100 text-zinc-700 border-zinc-300",
|
||||
active: "bg-emerald-100 text-emerald-700 border-emerald-300",
|
||||
paused: "bg-amber-100 text-amber-700 border-amber-300",
|
||||
completed: "bg-blue-100 text-blue-700 border-blue-300",
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const router = useRouter()
|
||||
const [campaigns, setCampaigns] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/campaigns")
|
||||
.then(r => r.json())
|
||||
.then(d => { if (Array.isArray(d)) setCampaigns(d); else setCampaigns([]) })
|
||||
.catch((e) => { console.error("Failed to load campaigns", e); toast.error("Failed to load campaigns"); setCampaigns([]) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Email Campaigns" description="Automated drip sequences for lead follow-ups">
|
||||
<Button size="sm" onClick={() => router.push("/campaigns/new")}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New Campaign
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1,2,3].map(i => <Card key={i} className="h-32 animate-pulse p-4"><div className="h-4 w-32 rounded bg-muted" /></Card>)}
|
||||
</div>
|
||||
) : campaigns.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<Mail className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">No campaigns yet</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => router.push("/campaigns/new")}>
|
||||
Create your first campaign
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{campaigns.map((c) => (
|
||||
<Card key={c.id} className="p-4 cursor-pointer hover:border-primary/50 transition-colors" onClick={() => router.push(`/campaigns/${c.id}`)}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{c.name}</p>
|
||||
{c.description && <p className="text-xs text-muted-foreground truncate mt-0.5">{c.description}</p>}
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] ml-2 ${statusColors[c.status] || ""}`}>{c.status}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.subscriber_count || 0}</span>
|
||||
<span className="flex items-center gap-1"><Layers className="h-3 w-3" />{c.step_count || 0} steps</span>
|
||||
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(c.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||
Pin, PinOff,
|
||||
} from "lucide-react"
|
||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||
import {
|
||||
@@ -211,8 +212,15 @@ const formatPreviewContent = (content: string) => {
|
||||
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
||||
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (!searchQuery.trim()) return conversations
|
||||
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
let list = conversations
|
||||
if (searchQuery.trim()) {
|
||||
list = conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
}
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1
|
||||
if (!a.pinned && b.pinned) return 1
|
||||
return 0
|
||||
})
|
||||
}, [conversations, searchQuery])
|
||||
|
||||
// Fetch conversations from API
|
||||
@@ -227,8 +235,8 @@ const formatPreviewContent = (content: string) => {
|
||||
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
|
||||
if (convs.length > 0) setActiveChat(convs[0].id)
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch conversations in chats page")
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch conversations:", err)
|
||||
} finally {
|
||||
setLoadingChats(false)
|
||||
}
|
||||
@@ -289,11 +297,11 @@ const formatPreviewContent = (content: string) => {
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch messages:", err)
|
||||
}
|
||||
// Mark conversation as read
|
||||
fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch(() => {})
|
||||
fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch((err) => console.error("Failed to mark read:", err))
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||
}
|
||||
fetchMsgs()
|
||||
@@ -312,7 +320,7 @@ const formatPreviewContent = (content: string) => {
|
||||
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
||||
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
|
||||
}
|
||||
} catch { console.warn("Failed to search users in chats page") }
|
||||
} catch (err) { console.error("Failed to search users:", err) }
|
||||
setSearchingUsers(false)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
@@ -502,11 +510,14 @@ const formatPreviewContent = (content: string) => {
|
||||
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
||||
: conv
|
||||
)
|
||||
const conv = updated.find((c) => c.id === activeChat)
|
||||
if (conv && !conv.pinned) {
|
||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||
if (idx > 0) {
|
||||
const [item] = updated.splice(idx, 1)
|
||||
updated.unshift(item)
|
||||
}
|
||||
}
|
||||
return updated
|
||||
})
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||
@@ -776,6 +787,23 @@ const formatPreviewContent = (content: string) => {
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith("image/")
|
||||
|
||||
const handleTogglePin = async (convId: string, pinned: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${convId}/pin`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pin: !pinned }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === convId ? { ...c, pinned: !pinned } : c))
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle pin:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const getDisplayContent = (content: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
@@ -817,7 +845,7 @@ const formatPreviewContent = (content: string) => {
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
||||
setConversations((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === conv.id)
|
||||
if (idx > 0) {
|
||||
if (idx > 0 && !conv.pinned) {
|
||||
const u = [...prev]
|
||||
const [item] = u.splice(idx, 1)
|
||||
u.unshift(item)
|
||||
@@ -827,7 +855,7 @@ const formatPreviewContent = (content: string) => {
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
|
||||
"w-full group flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
|
||||
isActive && "bg-muted"
|
||||
)}
|
||||
>
|
||||
@@ -837,8 +865,21 @@ const formatPreviewContent = (content: string) => {
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{conv.pinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground rotate-45" />}
|
||||
<span className="text-sm font-medium truncate">{person.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); handleTogglePin(conv.id, conv.pinned) }}
|
||||
className="h-5 w-5 flex items-center justify-center rounded hover:bg-muted-foreground/20 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title={conv.pinned ? "Unpin" : "Pin"}
|
||||
>
|
||||
{conv.pinned ? <PinOff className="h-3 w-3 text-muted-foreground" /> : <Pin className="h-3 w-3 text-muted-foreground rotate-45" />}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">{conv.lastMessageTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
|
||||
</div>
|
||||
@@ -871,7 +912,7 @@ const formatPreviewContent = (content: string) => {
|
||||
setSearchQuery("")
|
||||
}
|
||||
}
|
||||
} catch { console.warn("Failed to create conversation in chats page") }
|
||||
} catch (err) { console.error("Failed to create conversation:", err) }
|
||||
}}
|
||||
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { GripVertical, Plus, User, Building2, Mail, Phone, Hash } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface KanbanLead {
|
||||
id: string
|
||||
@@ -57,6 +58,7 @@ export default function KanbanPage() {
|
||||
const data = await res.json()
|
||||
setColumns(data)
|
||||
} catch (e) {
|
||||
toast.error("Failed to load kanban data")
|
||||
console.error("Failed to load kanban", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -98,6 +100,7 @@ export default function KanbanPage() {
|
||||
body: JSON.stringify({ status: toStatus }),
|
||||
})
|
||||
} catch (e) {
|
||||
toast.error("Failed to update lead status")
|
||||
console.error("Failed to update lead status", e)
|
||||
fetchKanban()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ArrowLeft } from "lucide-react"
|
||||
import { User } from "@/types"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const leadFormSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
@@ -53,7 +54,7 @@ export default function CreateLeadPage() {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
.catch((e) => { console.error("Failed to load users", e); toast.error("Failed to load users") })
|
||||
}, [])
|
||||
|
||||
const form = useForm<LeadFormValues>({
|
||||
@@ -87,6 +88,7 @@ export default function CreateLeadPage() {
|
||||
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
||||
router.push("/leads")
|
||||
} catch (e) {
|
||||
toast.error("Failed to create lead")
|
||||
console.error("Create lead error:", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LeadsTable } from "@/components/leads/leads-table"
|
||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Columns3 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Lead } from "@/types"
|
||||
|
||||
export default function LeadsPage() {
|
||||
@@ -30,7 +31,7 @@ export default function LeadsPage() {
|
||||
setLeadsData(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
.catch((e) => { console.error("Failed to load leads", e); toast.error("Failed to load leads"); setLoading(false) })
|
||||
}, [search, statusFilter, periodFilter])
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Milestone {
|
||||
id: string
|
||||
@@ -82,6 +83,7 @@ export default function ProjectDetailPage() {
|
||||
setMilestones(await mRes.json())
|
||||
setStatuses(await sRes.json())
|
||||
} catch (e) {
|
||||
toast.error("Failed to load project")
|
||||
console.error("Failed to load project", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -91,21 +93,31 @@ export default function ProjectDetailPage() {
|
||||
useEffect(() => { fetchProject() }, [fetchProject])
|
||||
|
||||
const updateProjectStatus = async (statusId: string) => {
|
||||
try {
|
||||
await fetch(`/api/projects/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ statusId }),
|
||||
})
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
console.error("Failed to update project status", e)
|
||||
toast.error("Failed to update project status")
|
||||
}
|
||||
}
|
||||
|
||||
const updateMilestoneStatus = async (milestoneId: string, status: string) => {
|
||||
try {
|
||||
await fetch(`/api/milestones/${milestoneId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
console.error("Failed to update milestone status", e)
|
||||
toast.error("Failed to update milestone status")
|
||||
}
|
||||
}
|
||||
|
||||
const addMilestone = async () => {
|
||||
@@ -119,6 +131,9 @@ export default function ProjectDetailPage() {
|
||||
})
|
||||
setNewMilestoneName("")
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
console.error("Failed to add milestone", e)
|
||||
toast.error("Failed to add milestone")
|
||||
} finally {
|
||||
setAddingMilestone(false)
|
||||
}
|
||||
@@ -140,6 +155,7 @@ export default function ProjectDetailPage() {
|
||||
setNewTaskInputs((prev) => ({ ...prev, [milestoneId]: "" }))
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
toast.error("Failed to add task")
|
||||
console.error("Failed to add task", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -62,8 +63,10 @@ export default function ProjectsPage() {
|
||||
if (search) params.set("search", search)
|
||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||
const res = await fetch(`/api/projects?${params}`)
|
||||
if (res.ok) setProjects(await res.json()); else setProjects([])
|
||||
const data = res.ok ? await res.json() : []
|
||||
setProjects(Array.isArray(data) ? data : [])
|
||||
} catch (e) {
|
||||
toast.error("Failed to load projects")
|
||||
console.error("Failed to load projects", e)
|
||||
setProjects([])
|
||||
} finally {
|
||||
@@ -74,7 +77,7 @@ export default function ProjectsPage() {
|
||||
useEffect(() => { fetchProjects() }, [search, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/project-statuses").then(r => r.json()).then(setStatuses).catch(() => {})
|
||||
fetch("/api/project-statuses").then(r => r.json()).then((d) => setStatuses(Array.isArray(d) ? d : [])).catch((e) => { console.error("Failed to load project statuses", e); toast.error("Failed to load project statuses") })
|
||||
}, [])
|
||||
|
||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—"
|
||||
@@ -167,8 +170,8 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
fetch("/api/customers?limit=100").then(r => r.json()).then(setCustomers).catch(() => {})
|
||||
fetch("/api/users").then(r => r.json()).then(d => setUsers(d.users || [])).catch(() => {})
|
||||
fetch("/api/customers?limit=100").then(r => r.json()).then(d => setCustomers(Array.isArray(d) ? d : [])).catch((e) => { console.error("Failed to load customers", e); toast.error("Failed to load customers") })
|
||||
fetch("/api/users").then(r => r.json()).then(d => setUsers(d.users || [])).catch((e) => { console.error("Failed to load users", e); toast.error("Failed to load users") })
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -193,6 +196,9 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
|
||||
setName(""); setDescription(""); setCustomerId(""); setOwnerId(""); setStartDate(""); setTargetEndDate(""); setBudget("")
|
||||
onCreated()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to create project", e)
|
||||
toast.error("Failed to create project")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -214,29 +220,29 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Customer *</label>
|
||||
<select
|
||||
value={customerId}
|
||||
onChange={(e) => setCustomerId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Select customer...</option>
|
||||
<Select value={customerId} onValueChange={setCustomerId}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder="Select customer..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</option>
|
||||
<SelectItem key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Project Owner</label>
|
||||
<select
|
||||
value={ownerId}
|
||||
onChange={(e) => setOwnerId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Select owner...</option>
|
||||
<Select value={ownerId} onValueChange={setOwnerId}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder="Select owner..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((u: any) => (
|
||||
<option key={u.id} value={u.id}>{u.first_name} {u.last_name}</option>
|
||||
<SelectItem key={u.id} value={u.id}>{u.first_name} {u.last_name}</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function ProposalDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -35,6 +36,7 @@ export default function ProposalDetailPage() {
|
||||
setProposal(await pRes.json())
|
||||
setStatuses(await sRes.json())
|
||||
} catch (e) {
|
||||
toast.error("Failed to load proposal")
|
||||
console.error("Failed to load proposal", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -44,12 +46,17 @@ export default function ProposalDetailPage() {
|
||||
useEffect(() => { fetchProposal() }, [fetchProposal])
|
||||
|
||||
const updateStatus = async (statusId: string) => {
|
||||
try {
|
||||
await fetch(`/api/proposals/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ statusId }),
|
||||
})
|
||||
fetchProposal()
|
||||
} catch (e) {
|
||||
console.error("Failed to update proposal status", e)
|
||||
toast.error("Failed to update proposal status")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSign = async () => {
|
||||
@@ -62,6 +69,9 @@ export default function ProposalDetailPage() {
|
||||
body: JSON.stringify({ name: signName, email: signEmail }),
|
||||
})
|
||||
if (res.ok) fetchProposal()
|
||||
} catch (e) {
|
||||
console.error("Failed to sign proposal", e)
|
||||
toast.error("Failed to sign proposal")
|
||||
} finally {
|
||||
setSigning(false)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface LineItem {
|
||||
id: string
|
||||
@@ -39,8 +40,8 @@ export default function NewProposalPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/leads?limit=100").then((r) => r.json()).then(setLeads).catch(() => {})
|
||||
fetch("/api/customers?limit=100").then((r) => r.json()).then(setCustomers).catch(() => {})
|
||||
fetch("/api/leads?limit=100").then((r) => r.json()).then(setLeads).catch((e) => { console.error("Failed to load leads", e); toast.error("Failed to load leads") })
|
||||
fetch("/api/customers?limit=100").then((r) => r.json()).then(setCustomers).catch((e) => { console.error("Failed to load customers", e); toast.error("Failed to load customers") })
|
||||
}, [])
|
||||
|
||||
const addItem = () => {
|
||||
@@ -107,6 +108,7 @@ export default function NewProposalPage() {
|
||||
|
||||
router.push(`/proposals/${data.id}`)
|
||||
} catch (e) {
|
||||
toast.error("Failed to create proposal")
|
||||
console.error("Failed to create proposal", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -135,21 +137,29 @@ export default function NewProposalPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Lead (optional)</label>
|
||||
<select value={leadId} onChange={(e) => setLeadId(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||
<option value="">Select lead...</option>
|
||||
<Select value={leadId} onValueChange={setLeadId}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder="Select lead..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{leads.map((l: any) => (
|
||||
<option key={l.id} value={l.id}>{l.contact_name || l.company_name}</option>
|
||||
<SelectItem key={l.id} value={l.id}>{l.contact_name || l.company_name}</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Customer (optional)</label>
|
||||
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||
<option value="">Select customer...</option>
|
||||
<Select value={customerId} onValueChange={setCustomerId}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder="Select customer..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</option>
|
||||
<SelectItem key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">VAT %</label>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Search, Plus, ArrowRight, FileText, Calendar, User } from "lucide-react"
|
||||
import { Search, Plus, ArrowRight, FileText, Calendar, User, Trash2 } from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function ProposalsPage() {
|
||||
const router = useRouter()
|
||||
@@ -31,16 +32,29 @@ export default function ProposalsPage() {
|
||||
fetch(`/api/proposals?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then(setProposals)
|
||||
.catch(() => {})
|
||||
.catch((e) => { console.error("Failed to load proposals", e); toast.error("Failed to load proposals") })
|
||||
.finally(() => setLoading(false))
|
||||
}, [search, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/proposal-statuses").then((r) => r.json()).then(setStatuses).catch(() => {})
|
||||
fetch("/api/proposal-statuses").then((r) => r.json()).then(setStatuses).catch((e) => { console.error("Failed to load proposal statuses", e); toast.error("Failed to load proposal statuses") })
|
||||
}, [])
|
||||
|
||||
const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString()}` : "R0"
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm("Delete this proposal?")) return
|
||||
try {
|
||||
const res = await fetch(`/api/proposals/${id}`, { method: "DELETE" })
|
||||
if (!res.ok) throw new Error()
|
||||
setProposals((prev) => prev.filter((p) => p.id !== id))
|
||||
toast.success("Proposal deleted")
|
||||
} catch {
|
||||
toast.error("Failed to delete proposal")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Proposals & Quotes" description="Create and send professional quotes to clients">
|
||||
@@ -104,6 +118,13 @@ export default function ProposalsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDelete(p.id, e)}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title="Delete proposal"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Users, Clock, Briefcase, AlertTriangle, TrendingUp } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Member {
|
||||
id: string
|
||||
name: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
avatar: string
|
||||
role: string
|
||||
isActive: boolean
|
||||
capacityHours: number
|
||||
totalHours: number
|
||||
billableHours: number
|
||||
utilization: number
|
||||
billableUtilization: number
|
||||
openTasks: number
|
||||
activeProjects: number
|
||||
}
|
||||
|
||||
interface Totals {
|
||||
totalCapacity: number
|
||||
totalHours: number
|
||||
totalBillable: number
|
||||
totalOpenTasks: number
|
||||
totalActiveProjects: number
|
||||
teamUtilization: number
|
||||
}
|
||||
|
||||
function statusKey(pct: number): string {
|
||||
if (pct > 100) return "overloaded"
|
||||
if (pct > 90) return "near-capacity"
|
||||
if (pct > 70) return "busy"
|
||||
if (pct > 50) return "available"
|
||||
return "underutilized"
|
||||
}
|
||||
|
||||
function statusLabel(pct: number): { label: string; color: string } {
|
||||
const key = statusKey(pct)
|
||||
const labels: Record<string, string> = {
|
||||
overloaded: "Overloaded",
|
||||
"near-capacity": "Near Capacity",
|
||||
busy: "Busy",
|
||||
available: "Available",
|
||||
underutilized: "Underutilized",
|
||||
}
|
||||
return { label: labels[key], color: `util-badge-${key}` }
|
||||
}
|
||||
|
||||
export default function ResourcePlanningPage() {
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [totals, setTotals] = useState<Totals | null>(null)
|
||||
const [period, setPeriod] = useState("week")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch(`/api/resource-planning?period=${period}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setMembers(d.members || []); setTotals(d.totals || null) })
|
||||
.catch((e) => { console.error("Failed to load resource data", e); toast.error("Failed to load resource data") })
|
||||
.finally(() => setLoading(false))
|
||||
}, [period])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Resource Planning" description="Team capacity, utilisation, and workload overview">
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="week">This Week</SelectItem>
|
||||
<SelectItem value="month">This Month</SelectItem>
|
||||
<SelectItem value="all">All Time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PageHeader>
|
||||
|
||||
{/* Overview cards */}
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" /> Team Size
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{members.filter(m => m.isActive).length}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" /> Total Hours
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{totals?.totalHours ?? 0}h</p>
|
||||
<p className="text-xs text-muted-foreground">of {totals?.totalCapacity ?? 0}h capacity</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<TrendingUp className="h-4 w-4" /> Utilisation
|
||||
</div>
|
||||
<p className={`text-2xl font-bold util-text-${statusKey(totals?.teamUtilization ?? 0)}`}>
|
||||
{totals?.teamUtilization ?? 0}%
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Briefcase className="h-4 w-4" /> Active Projects
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{totals?.totalActiveProjects ?? 0}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4" /> Open Tasks
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{totals?.totalOpenTasks ?? 0}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Team grid */}
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<Card key={i} className="p-4 space-y-3 animate-pulse">
|
||||
<div className="h-10 w-10 rounded-full bg-muted" />
|
||||
<div className="h-4 w-32 rounded bg-muted" />
|
||||
<div className="h-3 w-24 rounded bg-muted" />
|
||||
<div className="h-2 w-full rounded bg-muted" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">No team members found</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{members.map((m) => {
|
||||
const status = statusLabel(m.utilization)
|
||||
return (
|
||||
<Card key={m.id} className={`p-4 space-y-3 border util-card-${statusKey(m.utilization)}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={m.avatar} />
|
||||
<AvatarFallback>{m.firstName[0]}{m.lastName[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{m.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{m.role?.replace("_", " ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] ${status.color}`}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Utilisation bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Capacity: {m.capacityHours}h</span>
|
||||
<span>Logged: {m.totalHours}h</span>
|
||||
<span className={`util-text-${statusKey(m.utilization)}`}>{m.utilization}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all util-bar-${statusKey(m.utilization)}`}
|
||||
style={{ width: `${Math.min(m.utilization, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{m.utilization > 100 && (
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-500"
|
||||
style={{ width: `${Math.min(m.utilization - 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
{m.activeProjects} projects
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{m.openTasks} tasks
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{m.billableUtilization}% billable
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { UserPreferencesForm } from "@/components/settings/user-preferences-form
|
||||
import { ThemeSettings } from "@/components/settings/theme-settings"
|
||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager"
|
||||
import { Building2, User, Palette, Bell, ListChecks } from "lucide-react"
|
||||
import { LeadScoringSettings } from "@/components/settings/lead-scoring-settings"
|
||||
import { Building2, User, Palette, Bell, ListChecks, Gauge } from "lucide-react"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const tabs = [
|
||||
@@ -16,6 +17,7 @@ export default function SettingsPage() {
|
||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
|
||||
{ value: "lead-scoring", label: "Lead Scoring", icon: Gauge, component: LeadScoringSettings },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,15 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Play, Square, Plus, Trash2, Clock, Briefcase, User, TrendingUp, DollarSign } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface TimeEntry {
|
||||
id: string
|
||||
@@ -28,7 +36,7 @@ export default function TimeTrackingPage() {
|
||||
const [showManual, setShowManual] = useState(false)
|
||||
|
||||
// Manual entry form
|
||||
const [manualProject, setManualProject] = useState("")
|
||||
const [manualProject, setManualProject] = useState("none")
|
||||
const [manualDesc, setManualDesc] = useState("")
|
||||
const [manualDate, setManualDate] = useState(new Date().toISOString().split("T")[0])
|
||||
const [manualHours, setManualHours] = useState("")
|
||||
@@ -40,7 +48,7 @@ export default function TimeTrackingPage() {
|
||||
// Timer
|
||||
const [timerRunning, setTimerRunning] = useState(false)
|
||||
const [timerSeconds, setTimerSeconds] = useState(0)
|
||||
const [timerProject, setTimerProject] = useState("")
|
||||
const [timerProject, setTimerProject] = useState("none")
|
||||
const [timerDesc, setTimerDesc] = useState("")
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const startTimeRef = useRef<Date | null>(null)
|
||||
@@ -52,10 +60,14 @@ export default function TimeTrackingPage() {
|
||||
fetch("/api/time-entries/summary"),
|
||||
fetch("/api/projects"),
|
||||
])
|
||||
if (eRes.ok) setEntries(await eRes.json()); else setEntries([])
|
||||
if (sRes.ok) setSummary(await sRes.json()); else setSummary(null)
|
||||
if (pRes.ok) setProjects(await pRes.json()); else setProjects([])
|
||||
const eData = eRes.ok ? await eRes.json() : []
|
||||
const sData = sRes.ok ? await sRes.json() : null
|
||||
const pData = pRes.ok ? await pRes.json() : []
|
||||
setEntries(Array.isArray(eData) ? eData : [])
|
||||
setSummary(sData?.totals ? sData : null)
|
||||
setProjects(Array.isArray(pData) ? pData : [])
|
||||
} catch (e) {
|
||||
toast.error("Failed to load time data")
|
||||
console.error("Failed to load time data", e)
|
||||
setEntries([])
|
||||
setProjects([])
|
||||
@@ -87,7 +99,7 @@ export default function TimeTrackingPage() {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId: timerProject || null,
|
||||
projectId: timerProject === "none" ? null : timerProject,
|
||||
description: timerDesc,
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
startTime: startTimeRef.current?.toISOString(),
|
||||
@@ -98,10 +110,11 @@ export default function TimeTrackingPage() {
|
||||
}),
|
||||
})
|
||||
setTimerDesc("")
|
||||
setTimerProject("")
|
||||
setTimerProject("none")
|
||||
setTimerSeconds(0)
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
toast.error("Failed to save time entry")
|
||||
console.error("Failed to save time entry", e)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +131,7 @@ export default function TimeTrackingPage() {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId: manualProject || null,
|
||||
projectId: manualProject === "none" ? null : manualProject,
|
||||
description: manualDesc,
|
||||
date: manualDate,
|
||||
durationMinutes: totalMins,
|
||||
@@ -132,14 +145,22 @@ export default function TimeTrackingPage() {
|
||||
setManualRate("")
|
||||
setShowManual(false)
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
console.error("Failed to save time entry", e)
|
||||
toast.error("Failed to save time entry")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEntry = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/time-entries?id=${id}`, { method: "DELETE" })
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
console.error("Failed to delete time entry", e)
|
||||
toast.error("Failed to delete time entry")
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (mins: number) => {
|
||||
@@ -198,10 +219,15 @@ export default function TimeTrackingPage() {
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input placeholder="What are you working on?" value={timerDesc} onChange={(e) => setTimerDesc(e.target.value)} className="h-9" disabled={timerRunning} />
|
||||
<select value={timerProject} onChange={(e) => setTimerProject(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm" disabled={timerRunning}>
|
||||
<option value="">No project</option>
|
||||
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<Select value={timerProject} onValueChange={setTimerProject} disabled={timerRunning}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder="No project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project</SelectItem>
|
||||
{projects.map((p: any) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-3xl font-mono font-bold tabular-nums px-4 py-1">{formatTimer(timerSeconds)}</div>
|
||||
{!timerRunning ? (
|
||||
@@ -224,10 +250,15 @@ export default function TimeTrackingPage() {
|
||||
<div className="space-y-3 p-3 rounded-lg border bg-muted/20">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input placeholder="Description *" value={manualDesc} onChange={(e) => setManualDesc(e.target.value)} className="h-8 text-sm" />
|
||||
<select value={manualProject} onChange={(e) => setManualProject(e.target.value)} className="flex h-8 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-sm">
|
||||
<option value="">No project</option>
|
||||
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<Select value={manualProject} onValueChange={setManualProject}>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue placeholder="No project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project</SelectItem>
|
||||
{projects.map((p: any) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Input type="date" value={manualDate} onChange={(e) => setManualDate(e.target.value)} className="h-8 w-40 text-sm" />
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextRequest } from "next/server"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import {
|
||||
comparePassword,
|
||||
getUserByEmail,
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
import { checkRateLimit } from "@/lib/rate-limit"
|
||||
|
||||
function jsonResponse(data: unknown, status: number, headers?: Record<string, string>) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
})
|
||||
return NextResponse.json(data, { status, headers })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -132,20 +129,22 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const user = mapDbUserToSessionUser(dbUser)
|
||||
|
||||
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
|
||||
|
||||
return new Response(JSON.stringify({ user }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": cookieStr,
|
||||
},
|
||||
const isHttps = request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https"
|
||||
const response = NextResponse.json({ user })
|
||||
response.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 86400,
|
||||
secure: isHttps,
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Authentication service unavailable." }),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
const message = error instanceof Error ? error.message : "Authentication service unavailable."
|
||||
return NextResponse.json(
|
||||
{ error: message },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ export async function POST() {
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error)
|
||||
const message = error instanceof Error ? error.message : "Logout failed."
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Logout failed." }),
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ export async function GET() {
|
||||
return NextResponse.json({ user }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Auth me error:", error)
|
||||
const message = error instanceof Error ? error.message : "Authentication service unavailable."
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable." },
|
||||
{ error: message },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function POST(request: NextRequest) {
|
||||
}, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Password recovery error:", error)
|
||||
return NextResponse.json({ error: "Recovery service unavailable." }, { status: 503 })
|
||||
const message = error instanceof Error ? error.message : "Recovery service unavailable."
|
||||
return NextResponse.json({ error: message }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export async function PATCH(request: NextRequest, { params: routeParams }: { par
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error updating bug report:", error)
|
||||
return NextResponse.json({ error: "Failed to update bug report." }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to update bug report."
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ export async function POST(request: NextRequest) {
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Error creating bug report:", error)
|
||||
return NextResponse.json({ error: "Failed to submit bug report." }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to submit bug report."
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ reports }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error fetching bug reports:", error)
|
||||
return NextResponse.json({ error: "Failed to fetch bug reports." }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch bug reports."
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const result = await query(
|
||||
`SELECT l.*, s.contact_name, s.email AS subscriber_email
|
||||
FROM email_campaign_logs l
|
||||
LEFT JOIN email_campaign_subscribers s ON s.id = l.subscriber_id
|
||||
WHERE l.campaign_id = $1 ORDER BY l.sent_at DESC LIMIT 100`,
|
||||
[id]
|
||||
)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Campaign logs GET error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
|
||||
const campaign = await query(
|
||||
`SELECT c.*, u.first_name || ' ' || u.last_name AS creator_name
|
||||
FROM email_campaigns c LEFT JOIN users u ON u.id = c.created_by
|
||||
WHERE c.id = $1 AND c.deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
if (campaign.rows.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 })
|
||||
|
||||
const steps = await query(
|
||||
"SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order",
|
||||
[id]
|
||||
)
|
||||
|
||||
const subscribers = await query(
|
||||
`SELECT s.*, l.company_name, l.contact_name, l.email AS lead_email
|
||||
FROM email_campaign_subscribers s
|
||||
LEFT JOIN leads l ON l.id = s.lead_id
|
||||
WHERE s.campaign_id = $1 ORDER BY s.subscribed_at DESC`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return NextResponse.json({ ...campaign.rows[0], steps: steps.rows, subscribers: subscribers.rows })
|
||||
} catch (error) {
|
||||
console.error("Campaign GET error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
if (body.name !== undefined) { fields.push(`name = $${idx++}`); values.push(body.name) }
|
||||
if (body.description !== undefined) { fields.push(`description = $${idx++}`); values.push(body.description) }
|
||||
if (body.status !== undefined) { fields.push(`status = $${idx++}`); values.push(body.status) }
|
||||
if (fields.length === 0) return NextResponse.json({ error: "No fields" }, { status: 400 })
|
||||
fields.push("updated_at = NOW()")
|
||||
values.push(id)
|
||||
|
||||
await query(`UPDATE email_campaigns SET ${fields.join(", ")} WHERE id = $${idx}`, values)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Campaign PATCH error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
await query("UPDATE email_campaigns SET deleted_at = NOW() WHERE id = $1", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Campaign DELETE error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
|
||||
const steps = await query(
|
||||
"SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order",
|
||||
[id]
|
||||
)
|
||||
if (steps.rows.length === 0) {
|
||||
return NextResponse.json({ error: "No steps defined" }, { status: 400 })
|
||||
}
|
||||
|
||||
const subscribers = await query(
|
||||
"SELECT * FROM email_campaign_subscribers WHERE campaign_id = $1 AND status = 'pending' AND unsubscribed_at IS NULL",
|
||||
[id]
|
||||
)
|
||||
|
||||
let sent = 0
|
||||
for (const sub of subscribers.rows) {
|
||||
const stepIdx = sub.current_step
|
||||
if (stepIdx >= steps.rows.length) continue
|
||||
const step = steps.rows[stepIdx]
|
||||
|
||||
const body = replaceVariables(step.body_text || step.subject, { name: sub.contact_name || "Valued Client", email: sub.email })
|
||||
|
||||
await query(
|
||||
`INSERT INTO email_campaign_logs (campaign_id, subscriber_id, step_id, recipient, subject, body_text, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'sent')`,
|
||||
[id, sub.id, step.id, sub.email, step.subject, body]
|
||||
)
|
||||
|
||||
await query(
|
||||
`UPDATE email_campaign_subscribers SET current_step = current_step + 1, last_sent_at = NOW(), status = CASE WHEN $1 + 1 >= $2 THEN 'completed' ELSE 'pending' END
|
||||
WHERE id = $3`,
|
||||
[stepIdx, steps.rows.length, sub.id]
|
||||
)
|
||||
sent++
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, sent })
|
||||
} catch (error) {
|
||||
console.error("Campaign send error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to send"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function replaceVariables(text: string, vars: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] || `{{${key}}}`)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const result = await query("SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order", [id])
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Steps GET error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const maxOrder = await query("SELECT COALESCE(MAX(step_order), -1) + 1 AS next FROM email_campaign_steps WHERE campaign_id = $1", [id])
|
||||
const nextOrder = maxOrder.rows[0]?.next || 0
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO email_campaign_steps (campaign_id, step_order, delay_days, subject, body_text, body_html, variables)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[id, nextOrder, body.delayDays || 0, body.subject, body.bodyText || null, body.bodyHtml || null, body.variables ? JSON.stringify(body.variables) : null]
|
||||
)
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Steps POST error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
if (body.leadIds && Array.isArray(body.leadIds)) {
|
||||
let added = 0
|
||||
for (const leadId of body.leadIds) {
|
||||
const lead = await query("SELECT id, email, contact_name FROM leads WHERE id = $1 AND deleted_at IS NULL", [leadId])
|
||||
if (lead.rows.length === 0) continue
|
||||
const l = lead.rows[0]
|
||||
if (!l.email) continue
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO email_campaign_subscribers (campaign_id, lead_id, email, contact_name)
|
||||
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||
[id, l.id, l.email, l.contact_name]
|
||||
)
|
||||
added++
|
||||
} catch { /* skip duplicates */ }
|
||||
}
|
||||
return NextResponse.json({ success: true, added })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "leadIds array required" }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error("Campaign subscribe POST error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const result = await query(
|
||||
`SELECT c.*, u.first_name || ' ' || u.last_name AS creator_name,
|
||||
(SELECT COUNT(*) FROM email_campaign_subscribers WHERE campaign_id = c.id) AS subscriber_count,
|
||||
(SELECT COUNT(*) FROM email_campaign_steps WHERE campaign_id = c.id) AS step_count
|
||||
FROM email_campaigns c
|
||||
LEFT JOIN users u ON u.id = c.created_by
|
||||
WHERE c.deleted_at IS NULL
|
||||
ORDER BY c.created_at DESC`
|
||||
)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Campaigns GET error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to load campaigns"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const body = await request.json()
|
||||
const result = await query(
|
||||
`INSERT INTO email_campaigns (name, description, status, trigger_type, trigger_config, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[body.name, body.description || null, body.status || "draft", body.triggerType || "manual", body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, user.id]
|
||||
)
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Campaigns POST error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to create campaign"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ export async function DELETE(
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[voice-delete] Error:", error)
|
||||
return NextResponse.json({ error: "Unable to delete voice note. Please try again." }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Unable to delete voice note. Please try again."
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,8 @@ export async function GET(
|
||||
return NextResponse.json({ messages })
|
||||
} catch (error) {
|
||||
console.error("Messages error:", error)
|
||||
return NextResponse.json({ error: "Failed to load messages" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load messages"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +161,8 @@ export async function POST(
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Send message error:", error)
|
||||
return NextResponse.json({ error: "Failed to send message" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to send message"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +200,8 @@ export async function DELETE(
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Delete message error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete message" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to delete message"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +232,7 @@ export async function PATCH(
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Edit message error:", error)
|
||||
return NextResponse.json({ error: "Failed to edit message" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to edit message"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const pin = body.pin === true
|
||||
|
||||
await query(
|
||||
`UPDATE conversation_participants
|
||||
SET pinned_at = $1
|
||||
WHERE conversation_id = $2 AND user_id = $3`,
|
||||
[pin ? new Date().toISOString() : null, id, user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ pinned: pin })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to toggle pin"
|
||||
console.error("Pin conversation error:", message)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export async function POST(
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Mark read error:", error)
|
||||
return NextResponse.json({ error: "Failed to mark as read" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to mark as read"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function GET() {
|
||||
c.id,
|
||||
c.updated_at,
|
||||
cp_me.last_read_at,
|
||||
cp_me.pinned_at,
|
||||
u.id AS other_user_id,
|
||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||
u.email AS other_user_email,
|
||||
@@ -28,7 +29,7 @@ export async function GET() {
|
||||
WHERE c.id IN (
|
||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||
)
|
||||
ORDER BY c.updated_at DESC
|
||||
ORDER BY cp_me.pinned_at DESC NULLS LAST, CASE WHEN cp_me.pinned_at IS NULL THEN c.updated_at END DESC
|
||||
LIMIT 50`,
|
||||
[user.id],
|
||||
)
|
||||
@@ -36,6 +37,7 @@ export async function GET() {
|
||||
const conversations = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
pinned: row.pinned_at !== null,
|
||||
otherUser: {
|
||||
id: row.other_user_id,
|
||||
name: row.other_user_name,
|
||||
@@ -50,7 +52,8 @@ export async function GET() {
|
||||
return NextResponse.json({ conversations })
|
||||
} catch (error) {
|
||||
console.error("Conversations error:", error)
|
||||
return NextResponse.json({ error: "Failed to load conversations" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load conversations"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +118,8 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Create conversation error:", error)
|
||||
return NextResponse.json({ error: "Failed to create conversation" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to create conversation"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Custom fields API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load custom fields" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load custom fields"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +63,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "A field with this key already exists for this entity" }, { status: 409 })
|
||||
}
|
||||
console.error("Custom fields POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create custom field" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to create custom field"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +108,8 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Custom fields PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update custom field" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to update custom field"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +123,7 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Custom fields DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete custom field" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to delete custom field"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Customers API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load customers" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load customers"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(stats)
|
||||
} catch (error) {
|
||||
console.error("Dashboard API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load dashboard stats"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function GET() {
|
||||
})) })
|
||||
} catch (error) {
|
||||
console.error("Emails GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load sent emails" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load sent emails"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function GET() {
|
||||
return NextResponse.json({ users })
|
||||
} catch (error) {
|
||||
console.error("Event users error:", error)
|
||||
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load users"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("ICS GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to generate calendar file"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Events PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to update event"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +190,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Events DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to delete event"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ events })
|
||||
} catch (error) {
|
||||
console.error("Events GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load events"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +242,7 @@ export async function POST(request: NextRequest) {
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Events POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to create event"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ export async function POST(request: NextRequest) {
|
||||
const inviteUrl = `${origin}/join/${token}`
|
||||
|
||||
return NextResponse.json({ token, inviteUrl })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||
} catch (error) {
|
||||
console.error("Invite generate error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to generate invite"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Create note error:", error)
|
||||
return NextResponse.json({ error: "Failed to create note" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to create note"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json(notes)
|
||||
} catch (error) {
|
||||
console.error("Lead notes API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load notes" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load notes"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const result = await query(
|
||||
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
l.custom_fields,
|
||||
l.custom_fields, l.ml_score,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
@@ -61,6 +61,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
customFields: r.custom_fields || {},
|
||||
mlScore: r.ml_score || 0,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
@@ -68,7 +69,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json(lead)
|
||||
} catch (error) {
|
||||
console.error("Lead detail API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load lead"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +161,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id })
|
||||
} catch (error) {
|
||||
console.error("Lead PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to update lead"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(columns)
|
||||
} catch (error) {
|
||||
console.error("Kanban API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load kanban data" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load kanban data"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function GET(request: NextRequest) {
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, l.ml_score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
@@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
mlScore: r.ml_score || 0,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
@@ -113,7 +114,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(leads)
|
||||
} catch (error) {
|
||||
console.error("Leads API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to load leads"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +161,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Leads POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to create lead"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +189,7 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Leads DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : "Failed to delete lead"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const result = await query("SELECT * FROM lead_scoring_config WHERE is_active = true ORDER BY weight DESC")
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Scoring config GET error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to load scoring config"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
const body = await request.json()
|
||||
const result = await query(
|
||||
`INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[body.featureName, body.featureType || "keyword", body.fieldRef, body.weight || 1, body.valueMap ? JSON.stringify(body.valueMap) : null]
|
||||
)
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
if (error?.constraint === "lead_scoring_config_feature_name_key") {
|
||||
return NextResponse.json({ error: "Feature name already exists" }, { status: 409 })
|
||||
}
|
||||
console.error("Scoring config POST error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to create feature"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
const body = await request.json()
|
||||
if (!body.id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
if (body.weight !== undefined) { fields.push(`weight = $${idx++}`); values.push(body.weight) }
|
||||
if (body.isActive !== undefined) { fields.push(`is_active = $${idx++}`); values.push(body.isActive) }
|
||||
if (body.valueMap !== undefined) { fields.push(`value_map = $${idx++}`); values.push(JSON.stringify(body.valueMap)) }
|
||||
if (fields.length === 0) return NextResponse.json({ error: "No fields" }, { status: 400 })
|
||||
fields.push("updated_at = NOW()")
|
||||
values.push(body.id)
|
||||
|
||||
await query(`UPDATE lead_scoring_config SET ${fields.join(", ")} WHERE id = $${idx}`, values)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Scoring config PATCH error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to update"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||
await query("DELETE FROM lead_scoring_config WHERE id = $1", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Scoring config DELETE error:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to delete"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user