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
|
# or
|
||||||
bun dev
|
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.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ cd frontend
|
|||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
npm install devenv
|
||||||
|
npm install build
|
||||||
|
|
||||||
# Start dev server
|
# Start dev server
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -24,6 +26,7 @@ The app runs at **http://localhost:3000**
|
|||||||
| Route | Description |
|
| Route | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `/login` | Login page |
|
| `/login` | Login page |
|
||||||
|
| `client-portal/login` | Client Login page |
|
||||||
| `/` | Dashboard |
|
| `/` | Dashboard |
|
||||||
| `/leads` | Lead management table |
|
| `/leads` | Lead management table |
|
||||||
| `/leads/:id` | Lead detail view |
|
| `/leads/:id` | Lead detail view |
|
||||||
|
|||||||
+491
-45
@@ -1,63 +1,64 @@
|
|||||||
.dark.theme-bw {
|
.dark.theme-bw {
|
||||||
--background: 0 0% 4%;
|
--background: 0 0% 5%;
|
||||||
--foreground: 0 0% 95%;
|
--foreground: 0 0% 97%;
|
||||||
--card: 0 0% 8%;
|
--card: 0 0% 10%;
|
||||||
--card-foreground: 0 0% 95%;
|
|
||||||
--popover: 0 0% 8%;
|
--card-foreground: 0 0% 97%;
|
||||||
--popover-foreground: 0 0% 95%;
|
--popover: 0 0% 10%;
|
||||||
--primary: 0 0% 95%;
|
--popover-foreground: 0 0% 97%;
|
||||||
|
--primary: 0 0% 97%;
|
||||||
--primary-foreground: 0 0% 0%;
|
--primary-foreground: 0 0% 0%;
|
||||||
--secondary: 0 0% 50%;
|
--secondary: 0 0% 68%;
|
||||||
--secondary-foreground: 0 0% 100%;
|
--secondary-foreground: 0 0% 0%;
|
||||||
--muted: 0 0% 12%;
|
--muted: 0 0% 15%;
|
||||||
--muted-foreground: 0 0% 55%;
|
--muted-foreground: 0 0% 72%;
|
||||||
--accent: 0 0% 95%;
|
--accent: 0 0% 25%;
|
||||||
--accent-foreground: 0 0% 0%;
|
--accent-foreground: 0 0% 97%;
|
||||||
--destructive: 0 70% 50%;
|
--destructive: 0 0% 40%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 97%;
|
||||||
--border: 0 0% 20%;
|
--border: 0 0% 25%;
|
||||||
--input: 0 0% 20%;
|
--input: 0 0% 25%;
|
||||||
--ring: 0 0% 70%;
|
--ring: 0 0% 70%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: 0 0% 4%;
|
--sidebar: 0 0% 4%;
|
||||||
--sidebar-foreground: 0 0% 95%;
|
--sidebar-foreground: 0 0% 97%;
|
||||||
--sidebar-primary: 0 0% 95%;
|
--sidebar-primary: 0 0% 97%;
|
||||||
--sidebar-primary-foreground: 0 0% 0%;
|
--sidebar-primary-foreground: 0 0% 0%;
|
||||||
--sidebar-accent: 0 0% 95%;
|
--sidebar-accent: 0 0% 15%;
|
||||||
--sidebar-accent-foreground: 0 0% 0%;
|
--sidebar-accent-foreground: 0 0% 97%;
|
||||||
--sidebar-border: 0 0% 15%;
|
--sidebar-border: 0 0% 18%;
|
||||||
--sidebar-ring: 0 0% 70%;
|
--sidebar-ring: 0 0% 70%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light.theme-bw {
|
.light.theme-bw {
|
||||||
--background: 0 0% 97%;
|
--background: 0 0% 97%;
|
||||||
--foreground: 0 0% 10%;
|
--foreground: 0 0% 8%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 0 0% 10%;
|
--card-foreground: 0 0% 8%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 0 0% 10%;
|
--popover-foreground: 0 0% 8%;
|
||||||
--primary: 0 0% 10%;
|
--primary: 0 0% 8%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 0 0% 40%;
|
--secondary: 0 0% 55%;
|
||||||
--secondary-foreground: 0 0% 100%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
--muted: 0 0% 90%;
|
--muted: 0 0% 93%;
|
||||||
--muted-foreground: 0 0% 45%;
|
--muted-foreground: 0 0% 38%;
|
||||||
--accent: 0 0% 10%;
|
--accent: 0 0% 20%;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 80% 50%;
|
--destructive: 0 0% 60%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 10%;
|
||||||
--border: 0 0% 82%;
|
--border: 0 0% 78%;
|
||||||
--input: 0 0% 82%;
|
--input: 0 0% 78%;
|
||||||
--ring: 0 0% 50%;
|
--ring: 0 0% 45%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: 0 0% 100%;
|
--sidebar: 0 0% 100%;
|
||||||
--sidebar-foreground: 0 0% 10%;
|
--sidebar-foreground: 0 0% 8%;
|
||||||
--sidebar-primary: 0 0% 10%;
|
--sidebar-primary: 0 0% 8%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 0 0% 10%;
|
--sidebar-accent: 0 0% 90%;
|
||||||
--sidebar-accent-foreground: 0 0% 100%;
|
--sidebar-accent-foreground: 0 0% 8%;
|
||||||
--sidebar-border: 0 0% 82%;
|
--sidebar-border: 0 0% 80%;
|
||||||
--sidebar-ring: 0 0% 50%;
|
--sidebar-ring: 0 0% 45%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark.theme-bw body {
|
.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;
|
background: linear-gradient(to right, hsl(0, 0%, 50%), hsl(0, 0%, 65%), hsl(0, 0%, 50%)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark.theme-bw .growth-word,
|
.dark.theme-bw .growth-word {
|
||||||
.light.theme-bw .growth-word {
|
-webkit-text-fill-color: hsl(0, 0%, 70%) !important;
|
||||||
-webkit-text-fill-color: #1BB0CE !important;
|
color: hsl(0, 0%, 70%) !important;
|
||||||
color: #1BB0CE !important;
|
|
||||||
background: none !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;
|
color: #1BB0CE !important;
|
||||||
background: none !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;
|
color: #1BB0CE !important;
|
||||||
background: none !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%;
|
--sidebar-ring: 330 100% 50%;
|
||||||
}
|
}
|
||||||
.light.theme-cyberpunk {
|
.light.theme-cyberpunk {
|
||||||
--background: 220 15% 96%;
|
--background: 0 0% 98%;
|
||||||
--foreground: 240 30% 10%;
|
--foreground: 240 10% 15%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 240 30% 10%;
|
--card-foreground: 240 10% 15%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 240 30% 10%;
|
--popover-foreground: 240 10% 15%;
|
||||||
--primary: 330 80% 48%;
|
--primary: 330 100% 50%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 180 80% 38%;
|
--secondary: 180 100% 50%;
|
||||||
--secondary-foreground: 0 0% 100%;
|
--secondary-foreground: 0 0% 0%;
|
||||||
--muted: 240 8% 92%;
|
--muted: 240 5% 94%;
|
||||||
--muted-foreground: 240 8% 48%;
|
--muted-foreground: 240 4% 48%;
|
||||||
--accent: 260 65% 52%;
|
--accent: 260 80% 60%;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 80% 55%;
|
--destructive: 0 84% 55%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 270 15% 84%;
|
--success: 110 100% 50%;
|
||||||
--input: 240 8% 92%;
|
--success-foreground: 0 0% 0%;
|
||||||
--ring: 330 80% 48%;
|
--warning: 30 100% 55%;
|
||||||
|
--warning-foreground: 0 0% 0%;
|
||||||
|
--border: 270 10% 88%;
|
||||||
|
--input: 240 5% 92%;
|
||||||
|
--ring: 330 100% 50%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: 0 0% 100%;
|
--sidebar: 0 0% 100%;
|
||||||
--sidebar-foreground: 240 30% 10%;
|
--sidebar-foreground: 240 10% 15%;
|
||||||
--sidebar-primary: 330 80% 48%;
|
--sidebar-primary: 330 100% 50%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 240 8% 92%;
|
--sidebar-accent: 240 5% 94%;
|
||||||
--sidebar-accent-foreground: 240 30% 10%;
|
--sidebar-accent-foreground: 240 10% 15%;
|
||||||
--sidebar-border: 270 15% 84%;
|
--sidebar-border: 270 10% 88%;
|
||||||
--sidebar-ring: 330 80% 48%;
|
--sidebar-ring: 330 100% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
@@ -89,11 +93,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background:
|
background: hsl(0, 0%, 98%);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cyberpunk-bg {
|
@keyframes cyberpunk-bg {
|
||||||
@@ -106,12 +106,17 @@
|
|||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
App Shell � Make the background transparent so body::before shows through
|
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;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
.dark.theme-cyberpunk .dark\:bg-\[\#0A0A0A\] {
|
.dark.theme-cyberpunk .dark\:bg-\[\#0A0A0A\],
|
||||||
|
.light.theme-cyberpunk .dark\:bg-\[\#0A0A0A\] {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
.light.theme-cyberpunk .min-h-screen {
|
||||||
|
background: hsl(0, 0%, 98%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Top Navigation & Header Bar
|
Top Navigation & Header Bar
|
||||||
@@ -152,24 +157,32 @@
|
|||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Page Header � Dashboard title
|
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;
|
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;
|
border-left-color: hsl(330, 100%, 50%) !important;
|
||||||
}
|
}
|
||||||
.dark.theme-cyberpunk .dark\:text-white.text-2xl.font-bold {
|
.dark.theme-cyberpunk .dark\:text-white.text-2xl.font-bold {
|
||||||
color: hsl(0, 0%, 97%) !important;
|
color: hsl(0, 0%, 97%) !important;
|
||||||
text-shadow: 0 0 6px rgba(255, 0, 127, 0.15);
|
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;
|
color: hsl(240, 8%, 50%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Section Subtitles � "Pipeline Overview" / "Analytics"
|
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;
|
color: hsl(240, 8%, 38%) !important;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: 0.18em;
|
||||||
}
|
}
|
||||||
@@ -257,7 +270,8 @@
|
|||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
/* Pipeline Overview metric cards � semi-transparent glass + neon-pink glow border */
|
/* 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;
|
background: rgba(20, 20, 25, 0.75) !important;
|
||||||
backdrop-filter: blur(10px) !important;
|
backdrop-filter: blur(10px) !important;
|
||||||
border: 2px solid hsl(330, 100%, 50%) !important;
|
border: 2px solid hsl(330, 100%, 50%) !important;
|
||||||
@@ -267,7 +281,8 @@
|
|||||||
0 -2px 8px rgba(255, 0, 127, 0.15),
|
0 -2px 8px rgba(255, 0, 127, 0.15),
|
||||||
0 4px 24px rgba(0, 0, 0, 0.5) !important;
|
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;
|
border-color: hsl(330, 100%, 55%) !important;
|
||||||
box-shadow: 0 0 0 2px hsla(330, 100%, 50%, 0.5),
|
box-shadow: 0 0 0 2px hsla(330, 100%, 50%, 0.5),
|
||||||
0 0 20px rgba(255, 0, 127, 0.3),
|
0 0 20px rgba(255, 0, 127, 0.3),
|
||||||
@@ -277,13 +292,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Neon-pink top border strip on every card */
|
/* 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;
|
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;
|
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 */
|
/* 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;
|
color: hsl(0, 0%, 97%) !important;
|
||||||
text-shadow: 0 0 8px rgba(255, 0, 127, 0.1);
|
text-shadow: 0 0 8px rgba(255, 0, 127, 0.1);
|
||||||
}
|
}
|
||||||
@@ -336,7 +353,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom glow bar on cards */
|
/* 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;
|
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 .grid.grid-cols-1.md\:grid-cols-2.xl\:grid-cols-3.gap-4 > div,
|
||||||
.dark.theme-cyberpunk [class*="bg-card"],
|
.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-top: 2px solid hsl(330, 100%, 50%) !important;
|
||||||
border-bottom: 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;
|
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 {
|
.dark.theme-cyberpunk .dark\:text-\[\#666666\].text-xs.font-semibold.tracking-widest.uppercase {
|
||||||
color: hsl(210, 20%, 88%) !important;
|
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
|
Pipeline Color Bars — Cyberpunk neon pink/cyan
|
||||||
@@ -969,3 +995,322 @@
|
|||||||
color: #1BB0CE !important;
|
color: #1BB0CE !important;
|
||||||
background: none !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 {
|
.dark.theme-pumpkin {
|
||||||
--background: 0 30% 4%;
|
--background: 30 40% 3%;
|
||||||
--foreground: 20 50% 95%;
|
--foreground: 30 30% 92%;
|
||||||
--card: 0 25% 8%;
|
--card: 25 30% 7%;
|
||||||
--card-foreground: 20 50% 95%;
|
--card-foreground: 30 30% 92%;
|
||||||
--popover: 0 25% 8%;
|
--popover: 25 30% 7%;
|
||||||
--popover-foreground: 20 50% 95%;
|
--popover-foreground: 30 30% 92%;
|
||||||
--primary: 10 100% 50%;
|
--primary: 25 95% 50%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 30 20% 5%;
|
||||||
--secondary: 30 100% 50%;
|
--secondary: 35 100% 45%;
|
||||||
--secondary-foreground: 0 0% 0%;
|
--secondary-foreground: 30 20% 5%;
|
||||||
--muted: 0 12% 15%;
|
--muted: 25 15% 12%;
|
||||||
--muted-foreground: 20 10% 55%;
|
--muted-foreground: 30 12% 60%;
|
||||||
--accent: 10 100% 50%;
|
--accent: 15 100% 45%;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 70% 50%;
|
--destructive: 0 70% 50%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 10 30% 18%;
|
--border: 25 20% 16%;
|
||||||
--input: 10 30% 18%;
|
--input: 25 20% 16%;
|
||||||
--ring: 10 100% 50%;
|
--ring: 25 95% 50%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: 0 30% 4%;
|
--sidebar: 25 35% 4%;
|
||||||
--sidebar-foreground: 20 50% 95%;
|
--sidebar-foreground: 30 30% 92%;
|
||||||
--sidebar-primary: 10 100% 50%;
|
--sidebar-primary: 25 95% 50%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 30 20% 5%;
|
||||||
--sidebar-accent: 10 100% 50%;
|
--sidebar-accent: 15 100% 45%;
|
||||||
--sidebar-accent-foreground: 0 0% 100%;
|
--sidebar-accent-foreground: 0 0% 100%;
|
||||||
--sidebar-border: 10 30% 15%;
|
--sidebar-border: 25 18% 14%;
|
||||||
--sidebar-ring: 10 100% 50%;
|
--sidebar-ring: 25 95% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light.theme-pumpkin {
|
.light.theme-pumpkin {
|
||||||
--background: 20 30% 96%;
|
--background: 30 30% 94%;
|
||||||
--foreground: 10 50% 10%;
|
--foreground: 25 40% 12%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 10 50% 10%;
|
--card-foreground: 25 40% 12%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 10 50% 10%;
|
--popover-foreground: 25 40% 12%;
|
||||||
--primary: 10 90% 45%;
|
--primary: 25 90% 42%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 30 90% 45%;
|
--secondary: 35 90% 42%;
|
||||||
--secondary-foreground: 0 0% 100%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
--muted: 20 15% 88%;
|
--muted: 30 15% 86%;
|
||||||
--muted-foreground: 10 10% 45%;
|
--muted-foreground: 25 10% 42%;
|
||||||
--accent: 10 90% 45%;
|
--accent: 15 90% 42%;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 80% 50%;
|
--destructive: 0 80% 50%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 20 15% 82%;
|
--border: 25 15% 80%;
|
||||||
--input: 20 15% 82%;
|
--input: 25 15% 80%;
|
||||||
--ring: 10 90% 45%;
|
--ring: 25 90% 42%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: 0 0% 100%;
|
--sidebar: 0 0% 100%;
|
||||||
--sidebar-foreground: 10 50% 10%;
|
--sidebar-foreground: 25 40% 12%;
|
||||||
--sidebar-primary: 10 90% 45%;
|
--sidebar-primary: 25 90% 42%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 10 90% 45%;
|
--sidebar-accent: 15 90% 42%;
|
||||||
--sidebar-accent-foreground: 0 0% 100%;
|
--sidebar-accent-foreground: 0 0% 100%;
|
||||||
--sidebar-border: 20 15% 82%;
|
--sidebar-border: 25 15% 80%;
|
||||||
--sidebar-ring: 10 90% 45%;
|
--sidebar-ring: 25 90% 42%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Warm ambient glow */
|
||||||
.dark.theme-pumpkin body {
|
.dark.theme-pumpkin body {
|
||||||
background: transparent
|
background: transparent
|
||||||
radial-gradient(ellipse 120% 60% at 20% 30%, rgba(200, 0, 0, 0.2) 0%, transparent 60%),
|
radial-gradient(ellipse 100% 55% at 20% 25%, rgba(200, 80, 0, 0.18) 0%, transparent 55%),
|
||||||
radial-gradient(ellipse 80% 50% at 80% 70%, rgba(255, 100, 0, 0.15) 0%, transparent 50%),
|
radial-gradient(ellipse 80% 45% at 80% 75%, rgba(255, 140, 0, 0.12) 0%, transparent 45%),
|
||||||
radial-gradient(ellipse 60% 40% at 50% 50%, rgba(150, 0, 0, 0.1) 0%, transparent 50%);
|
radial-gradient(ellipse 60% 35% at 50% 50%, rgba(180, 60, 0, 0.08) 0%, transparent 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark.theme-pumpkin body > div {
|
.dark.theme-pumpkin body > div {
|
||||||
@@ -78,198 +79,298 @@
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 15% 20%, rgba(200, 50, 0, 0.04) 0%, transparent 60%),
|
radial-gradient(ellipse 70% 45% at 15% 20%, rgba(200, 80, 0, 0.05) 0%, transparent 55%),
|
||||||
radial-gradient(ellipse 80% 50% at 85% 80%, rgba(255, 100, 0, 0.03) 0%, transparent 60%);
|
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 {
|
.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 {
|
.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 {
|
/* Gradient headings — warm orange to amber */
|
||||||
background: linear-gradient(135deg, hsl(10, 100%, 50%), hsl(30, 100%, 50%));
|
.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-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light.theme-pumpkin h1, .light.theme-pumpkin h2, .light.theme-pumpkin h3 {
|
.light.theme-pumpkin h1:not(.text-primary):not(.text-foreground),
|
||||||
background: linear-gradient(135deg, hsl(10, 90%, 40%), hsl(30, 90%, 40%));
|
.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-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark.theme-pumpkin a {
|
/* Links */
|
||||||
color: hsl(10, 100%, 55%);
|
.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 {
|
.light.theme-pumpkin a:hover { color: hsl(35, 90%, 45%); }
|
||||||
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%);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Blockquotes */
|
||||||
.dark.theme-pumpkin blockquote {
|
.dark.theme-pumpkin blockquote {
|
||||||
border-left: 3px solid hsl(10, 100%, 50%);
|
border-left: 3px solid hsl(25, 95%, 50%);
|
||||||
color: hsl(20, 15%, 70%);
|
color: hsl(30, 15%, 70%);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light.theme-pumpkin blockquote {
|
.light.theme-pumpkin blockquote {
|
||||||
border-left: 3px solid hsl(10, 90%, 45%);
|
border-left: 3px solid hsl(25, 90%, 42%);
|
||||||
color: hsl(10, 10%, 45%);
|
color: hsl(25, 10%, 42%);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Code */
|
||||||
.dark.theme-pumpkin code {
|
.dark.theme-pumpkin code {
|
||||||
color: hsl(10, 100%, 55%);
|
color: hsl(25, 95%, 55%);
|
||||||
background: rgba(200, 0, 0, 0.12);
|
background: rgba(200, 80, 0, 0.1);
|
||||||
border: 1px solid rgba(200, 0, 0, 0.15);
|
border: 1px solid rgba(200, 80, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light.theme-pumpkin code {
|
.light.theme-pumpkin code {
|
||||||
color: hsl(10, 90%, 40%);
|
color: hsl(25, 90%, 40%);
|
||||||
background: rgba(200, 50, 0, 0.05);
|
background: rgba(200, 80, 0, 0.04);
|
||||||
border: 1px solid rgba(200, 50, 0, 0.1);
|
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 {
|
/* HR */
|
||||||
background: rgba(20, 0, 0, 0.9);
|
.dark.theme-pumpkin hr { border-color: rgba(200, 80, 0, 0.12); }
|
||||||
border: 1px solid rgba(200, 0, 0, 0.1);
|
.light.theme-pumpkin hr { border-color: rgba(200, 80, 0, 0.08); }
|
||||||
}
|
|
||||||
|
|
||||||
.light.theme-pumpkin pre {
|
/* Tables */
|
||||||
background: rgba(200, 50, 0, 0.03);
|
.dark.theme-pumpkin table th { color: hsl(25, 95%, 55%); }
|
||||||
border: 1px solid rgba(200, 50, 0, 0.08);
|
.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 {
|
/* Selection */
|
||||||
border-color: rgba(200, 0, 0, 0.15);
|
.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 {
|
/* Placeholders */
|
||||||
border-color: rgba(200, 50, 0, 0.1);
|
.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 {
|
/* Page header / stat card / badge */
|
||||||
color: hsl(10, 100%, 55%);
|
.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%); }
|
||||||
|
|
||||||
.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%);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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,
|
.dark.theme-pumpkin .growth-word,
|
||||||
.light.theme-pumpkin .growth-word {
|
.light.theme-pumpkin .growth-word {
|
||||||
-webkit-text-fill-color: #1BB0CE !important;
|
-webkit-text-fill-color: #1BB0CE !important;
|
||||||
color: #1BB0CE !important;
|
color: #1BB0CE !important;
|
||||||
background: none !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;
|
color: #1BB0CE !important;
|
||||||
background: none !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": {
|
"light": {
|
||||||
"colors": {
|
"colors": {
|
||||||
"background": "hsl(220, 15%, 96%)",
|
"background": "hsl(0, 0%, 98%)",
|
||||||
"foreground": "hsl(240, 30%, 10%)",
|
"foreground": "hsl(240, 10%, 15%)",
|
||||||
"card": "hsl(0, 0%, 100%)",
|
"card": "hsl(0, 0%, 100%)",
|
||||||
"cardForeground": "hsl(240, 30%, 10%)",
|
"cardForeground": "hsl(240, 10%, 15%)",
|
||||||
"popover": "hsl(0, 0%, 100%)",
|
"popover": "hsl(0, 0%, 100%)",
|
||||||
"popoverForeground": "hsl(240, 30%, 10%)",
|
"popoverForeground": "hsl(240, 10%, 15%)",
|
||||||
"primary": "hsl(330, 80%, 48%)",
|
"primary": "hsl(330, 100%, 50%)",
|
||||||
"primaryForeground": "hsl(0, 0%, 100%)",
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
"secondary": "hsl(180, 80%, 38%)",
|
"secondary": "hsl(180, 100%, 50%)",
|
||||||
"secondaryForeground": "hsl(0, 0%, 100%)",
|
"secondaryForeground": "hsl(0, 0%, 0%)",
|
||||||
"muted": "hsl(240, 8%, 92%)",
|
"muted": "hsl(240, 5%, 94%)",
|
||||||
"mutedForeground": "hsl(240, 8%, 48%)",
|
"mutedForeground": "hsl(240, 4%, 48%)",
|
||||||
"accent": "hsl(260, 65%, 52%)",
|
"accent": "hsl(260, 80%, 60%)",
|
||||||
"accentForeground": "hsl(0, 0%, 100%)",
|
"accentForeground": "hsl(0, 0%, 100%)",
|
||||||
"destructive": "hsl(0, 80%, 55%)",
|
"destructive": "hsl(0, 84%, 55%)",
|
||||||
"destructiveForeground": "hsl(0, 0%, 100%)",
|
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||||
"border": "hsl(270, 15%, 84%)",
|
"border": "hsl(270, 10%, 88%)",
|
||||||
"input": "hsl(240, 8%, 92%)",
|
"input": "hsl(240, 5%, 92%)",
|
||||||
"ring": "hsl(330, 80%, 48%)",
|
"ring": "hsl(330, 100%, 50%)",
|
||||||
"sidebar": "hsl(0, 0%, 100%)",
|
"sidebar": "hsl(0, 0%, 100%)",
|
||||||
"sidebarForeground": "hsl(240, 30%, 10%)",
|
"sidebarForeground": "hsl(240, 10%, 15%)",
|
||||||
"sidebarPrimary": "hsl(330, 80%, 48%)",
|
"sidebarPrimary": "hsl(330, 100%, 50%)",
|
||||||
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
"sidebarAccent": "hsl(240, 8%, 92%)",
|
"sidebarAccent": "hsl(240, 5%, 94%)",
|
||||||
"sidebarAccentForeground": "hsl(240, 30%, 10%)",
|
"sidebarAccentForeground": "hsl(240, 10%, 15%)",
|
||||||
"sidebarBorder": "hsl(270, 15%, 84%)",
|
"sidebarBorder": "hsl(270, 10%, 88%)",
|
||||||
"sidebarRing": "hsl(330, 80%, 48%)"
|
"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) {
|
function parseURL(req) {
|
||||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
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
|
// GET /status — combined health of all services
|
||||||
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
|
// Used by the splash page to check if AI and Frontend are ready.
|
||||||
// Polls each service internally to avoid cross-origin CORS issues.
|
// Scraper is no longer part of dev:start so it's always reported ready.
|
||||||
if (req.method === "GET" && pathname === "/status") {
|
if (req.method === "GET" && pathname === "/status") {
|
||||||
const { default: http } = await import("http")
|
const { default: http } = await import("http")
|
||||||
const results = { ai: true }
|
const results = { ai: true, scraper: true }
|
||||||
// Check scraper (port 3008)
|
// Check frontend (port 3006) — try localhost, fallback to 127.0.0.1
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await checkPort(http, "localhost", 3006, 2000)
|
||||||
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)
|
|
||||||
})
|
|
||||||
results.frontend = true
|
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)
|
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 browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
|
||||||
let facebookLoggedIn = false
|
let facebookLoggedIn = false
|
||||||
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
||||||
|
let scraperAvailable = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
|
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()
|
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)) {
|
for (const [b, p] of Object.entries(profiles)) {
|
||||||
if (p) browsers[b] = { path: p }
|
if (p) browsers[b] = { path: p }
|
||||||
@@ -396,8 +401,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const anyDetected = Object.values(browsers).some(v => v.path)
|
const anyDetected = Object.values(browsers).some(v => v.path)
|
||||||
// first_run = any setup step is incomplete
|
// first_run = env or Ollama incomplete, or scraper is available but detection incomplete
|
||||||
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
|
const firstRun = !envExists || !ollamaRunning || (scraperAvailable && (!anyDetected || !facebookLoggedIn))
|
||||||
|
|
||||||
return sendJSON(res, 200, {
|
return sendJSON(res, 200, {
|
||||||
first_run: firstRun,
|
first_run: firstRun,
|
||||||
|
|||||||
+445
-84
@@ -341,6 +341,7 @@ OFFER_PATTERNS = [
|
|||||||
r"\bfree\s+domain\b",
|
r"\bfree\s+domain\b",
|
||||||
r"\bweb\s+hosting\b",
|
r"\bweb\s+hosting\b",
|
||||||
r"\bfree\s+website\b",
|
r"\bfree\s+website\b",
|
||||||
|
r"\bfree\s+\w+\s+website\b",
|
||||||
r"\bprofessional\s+website",
|
r"\bprofessional\s+website",
|
||||||
r"\baffordable\s+web\b",
|
r"\baffordable\s+web\b",
|
||||||
r"\bget\s+a\s+free\b",
|
r"\bget\s+a\s+free\b",
|
||||||
@@ -365,6 +366,88 @@ OFFER_PATTERNS = [
|
|||||||
r"\bsponsored\b",
|
r"\bsponsored\b",
|
||||||
r"\bpromoted\b",
|
r"\bpromoted\b",
|
||||||
r"\badvertisement\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 = [
|
REQUEST_PATTERNS = [
|
||||||
@@ -549,6 +632,48 @@ FB_SEARCHES = [
|
|||||||
"want to build a website for my",
|
"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 = [
|
VIEWPORTS = [
|
||||||
{'width': 1280, 'height': 800},
|
{'width': 1280, 'height': 800},
|
||||||
{'width': 1366, 'height': 768},
|
{'width': 1366, 'height': 768},
|
||||||
@@ -663,16 +788,31 @@ def _clean_fb_text(text: str) -> str:
|
|||||||
cleaned_lines.append(stripped)
|
cleaned_lines.append(stripped)
|
||||||
return '\n'.join(cleaned_lines)
|
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 ──────────────────────────────────────────────────
|
# ── Post Extraction ──────────────────────────────────────────────────
|
||||||
# Two strategies for extracting posts from page content:
|
# Two strategies for extracting posts from page content:
|
||||||
# 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements()
|
# 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
|
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
|
||||||
# Both apply dedup (seen_texts), offer filtering, and request scoring.
|
# 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]:
|
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
|
||||||
posts = []
|
posts = []
|
||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
|
now = datetime.utcnow()
|
||||||
for el in elements:
|
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', '')
|
raw_text = el.get('text', '')
|
||||||
if len(raw_text) < 40:
|
if len(raw_text) < 40:
|
||||||
continue
|
continue
|
||||||
@@ -686,13 +826,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
|
|||||||
if dekey in seen_texts:
|
if dekey in seen_texts:
|
||||||
continue
|
continue
|
||||||
seen_texts.add(dekey)
|
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
|
request_score = 2 if is_request(text) else 0
|
||||||
post_url = el.get('url') or base_url
|
post_url = el.get('url') or base_url
|
||||||
|
|
||||||
# Prefer JS-extracted date, fall back to text parsing
|
# Prefer JS-extracted date, fall back to text parsing
|
||||||
js_date = (el.get('date') or '').strip()
|
js_date = (el.get('date') or '').strip()
|
||||||
if js_date:
|
if js_date:
|
||||||
# Try ISO datetime from <time datetime>
|
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
|
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
|
||||||
date_str = dt.strftime('%Y-%m-%d')
|
date_str = dt.strftime('%Y-%m-%d')
|
||||||
@@ -720,6 +870,25 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
posts = []
|
posts = []
|
||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
cur = []
|
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:
|
for l in lines:
|
||||||
words = re.findall(r'[A-Za-z]{2,}', l)
|
words = re.findall(r'[A-Za-z]{2,}', l)
|
||||||
if len(words) < 2:
|
if len(words) < 2:
|
||||||
@@ -735,7 +904,7 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
posts.append({
|
posts.append({
|
||||||
"title": snippet[:300],
|
"title": snippet[:300],
|
||||||
"content": snippet[:1000],
|
"content": snippet[:1000],
|
||||||
"author": '',
|
"author": _find_author(cur[-3:]),
|
||||||
"url": url,
|
"url": url,
|
||||||
"source": "facebook",
|
"source": "facebook",
|
||||||
"date": _parse_fb_date(cur + [l]),
|
"date": _parse_fb_date(cur + [l]),
|
||||||
@@ -759,7 +928,61 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
cur.append(l)
|
cur.append(l)
|
||||||
if len(cur) > 10:
|
if len(cur) > 10:
|
||||||
cur.pop(0)
|
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 ──────────────────────────────────
|
# ── Human-like Behavior Simulation ──────────────────────────────────
|
||||||
# These functions add random delays, mouse movements, and scroll patterns
|
# 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('''() => {
|
return await page.evaluate('''() => {
|
||||||
const results = [];
|
const results = [];
|
||||||
const seenTexts = new Set();
|
const seenTexts = new Set();
|
||||||
const selectors = [
|
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"];
|
||||||
'div[role="article"]',
|
const now = Date.now();
|
||||||
'div[role="feed"] > div',
|
const DAY_MS = 86400000;
|
||||||
'div.x1yztbdb',
|
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
|
||||||
'div[data-pagelet]',
|
for (const a of anchors) {
|
||||||
];
|
const h = a.getAttribute('href') || '';
|
||||||
for (const sel of selectors) {
|
if (!h) continue;
|
||||||
const els = document.querySelectorAll(sel);
|
const cell = a.closest('div[style]') || a.parentElement;
|
||||||
for (const el of els) {
|
if (!cell) continue;
|
||||||
const text = (el.innerText || '').trim();
|
const txt = (cell.innerText || '').trim();
|
||||||
if (text.length < 40) continue;
|
if (txt.length < 40) continue;
|
||||||
const key = text.substring(0, 80);
|
const lower = txt.toLowerCase();
|
||||||
if (seenTexts.has(key)) continue;
|
let matched = false;
|
||||||
seenTexts.add(key);
|
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 = '';
|
let author = '';
|
||||||
const nameEl = el.querySelector('h4, strong, a[role="link"]');
|
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
|
||||||
if (nameEl) {
|
if (nameEl) author = (nameEl.innerText || '').trim();
|
||||||
author = (nameEl.innerText || '').trim();
|
if (!author || author.length > 60 || author.length < 2) author = '';
|
||||||
}
|
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
|
||||||
if (!author) {
|
let date = '';
|
||||||
const links = el.querySelectorAll('a');
|
let ts = 0;
|
||||||
for (const a of links) {
|
const timeEl = cell.querySelector('time');
|
||||||
const t = (a.innerText || '').trim();
|
if (timeEl) {
|
||||||
if (t && t.length > 1 && t.length < 60 && /^[A-Za-zÀ-ÿ]/.test(t) && !t.includes('facebook') && !t.includes('/')) {
|
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
|
||||||
author = t;
|
const dtVal = timeEl.getAttribute('datetime');
|
||||||
break;
|
if (dtVal) { ts = new Date(dtVal).getTime(); }
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Post URL ---
|
|
||||||
let postUrl = '';
|
|
||||||
for (const a of el.querySelectorAll('a')) {
|
|
||||||
const h = (a.getAttribute('href') || '');
|
|
||||||
if (h.includes('/posts/') || h.includes('/photo/') || h.includes('/video/') || h.includes('/groups/') || h.includes('/permalink/')) {
|
|
||||||
postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Date from <time datetime> ---
|
|
||||||
let date = '';
|
|
||||||
const timeEl = el.querySelector('time');
|
|
||||||
if (timeEl) {
|
|
||||||
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({ text, author, url: postUrl, date });
|
|
||||||
}
|
}
|
||||||
if (results.length > 0) break;
|
results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
|
||||||
}
|
}
|
||||||
return results;
|
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)}'
|
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||||
try:
|
await page.wait_for_timeout(random.randint(5000, 10000))
|
||||||
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 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.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||||
await page.evaluate("window.scrollBy(0, -300)")
|
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)
|
raw_articles = await _get_article_elements(page)
|
||||||
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
|
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
|
||||||
if not posts:
|
raw = await page.evaluate('document.body.innerText')
|
||||||
raw = await page.evaluate('document.body.innerText')
|
text_posts = _extract_posts_from_text(raw, url)
|
||||||
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:
|
except Exception as e:
|
||||||
logger.warning("Facebook search '%s' failed: %s", query, e)
|
logger.warning("Facebook search '%s' failed: %s", query, e)
|
||||||
return page, []
|
return page, []
|
||||||
@@ -971,7 +1265,7 @@ def cleanup_chrome():
|
|||||||
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
|
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
|
||||||
# - If no profile found → Agent (browser-use + ChatOllama) fallback
|
# - 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."""
|
"""Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
|
||||||
effective_path = profile_path or ""
|
effective_path = profile_path or ""
|
||||||
browser_type = ""
|
browser_type = ""
|
||||||
@@ -1013,14 +1307,14 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
|
|||||||
|
|
||||||
# Firefox path
|
# Firefox path
|
||||||
if browser_type == "firefox":
|
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"):
|
if result.get("success") or not result.get("flagged"):
|
||||||
return result
|
return result
|
||||||
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
|
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
|
||||||
return await _scrape_with_agent(force)
|
return await _scrape_with_agent(force)
|
||||||
|
|
||||||
# Chromium-based (chrome / opera / edge)
|
# 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"):
|
if result.get("success") or not result.get("flagged"):
|
||||||
return result
|
return result
|
||||||
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
|
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
|
# human browsing). If flagged by Facebook, returns flagged=True for the
|
||||||
# dispatcher to fall through to the Agent.
|
# 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)."""
|
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
|
||||||
if not profile_path:
|
if not profile_path:
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No 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}
|
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
||||||
|
|
||||||
all_posts = []
|
all_posts = []
|
||||||
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
if query:
|
||||||
for i, query in enumerate(searches):
|
query_pool = _search_list_for_query(query)
|
||||||
page, posts = await search_facebook(page, context, query)
|
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
|
||||||
|
else:
|
||||||
|
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
||||||
|
for i, sq in enumerate(searches):
|
||||||
|
page, posts = await search_facebook(page, context, sq)
|
||||||
all_posts.extend(posts)
|
all_posts.extend(posts)
|
||||||
if not posts:
|
if not posts:
|
||||||
continue
|
continue
|
||||||
@@ -1142,6 +1440,12 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chromium Scraper ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chromium Scraper ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
# ── Chromium Scraper ─────────────────────────────────────────────────
|
# ── Chromium Scraper ─────────────────────────────────────────────────
|
||||||
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
|
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
|
||||||
# Firefox scraper but with Chromium-specific launch config:
|
# 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.
|
# Anti-detection script differs slightly from Firefox variant.
|
||||||
# Same decoy-skip and flagged-fallback behavior as Firefox.
|
# 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)."""
|
"""Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera)."""
|
||||||
if not profile_path:
|
if not profile_path:
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No 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}
|
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
||||||
|
|
||||||
all_posts = []
|
all_posts = []
|
||||||
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
if query:
|
||||||
for i, query in enumerate(searches):
|
query_pool = _search_list_for_query(query)
|
||||||
page, posts = await search_facebook(page, context, query)
|
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
|
||||||
|
else:
|
||||||
|
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
||||||
|
for i, sq in enumerate(searches):
|
||||||
|
page, posts = await search_facebook(page, context, sq)
|
||||||
all_posts.extend(posts)
|
all_posts.extend(posts)
|
||||||
if not posts:
|
if not posts:
|
||||||
continue
|
continue
|
||||||
@@ -1387,6 +1695,8 @@ NOT LEAD:
|
|||||||
- Recruiting employees, hiring staff, looking for business partners
|
- Recruiting employees, hiring staff, looking for business partners
|
||||||
- Selling products, promoting services, affiliate offers
|
- Selling products, promoting services, affiliate offers
|
||||||
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
- "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):
|
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))}
|
{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",
|
"price for",
|
||||||
]
|
]
|
||||||
keyword_leads: list[dict] = []
|
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:
|
for r in results:
|
||||||
t = r['title'].lower()
|
t = r['title'].lower()
|
||||||
has_web = any(kw in t for kw in web_terms)
|
has_web = any(kw in t for kw in web_terms)
|
||||||
has_request = any(kw in t for kw in request_terms)
|
has_request = any(kw in t for kw in request_terms)
|
||||||
if not has_web or not has_request:
|
if not has_web or not has_request:
|
||||||
continue
|
continue
|
||||||
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
|
if any(kw in t for kw in offer_reject):
|
||||||
'affordable web design package', 'web hosting',
|
|
||||||
'i do web design', 'i develop websites']):
|
|
||||||
continue
|
continue
|
||||||
keyword_leads.append(r)
|
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:
|
if key not in seen_titles:
|
||||||
seen_titles.add(key)
|
seen_titles.add(key)
|
||||||
merged.append(r)
|
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)
|
# Fill to 5 with loose keyword matches (at least web OR request term)
|
||||||
if len(merged) < 5:
|
if len(merged) < 5:
|
||||||
@@ -1477,11 +1833,16 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
if key in seen_titles:
|
if key in seen_titles:
|
||||||
continue
|
continue
|
||||||
t = r['title'].lower()
|
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)):
|
||||||
seen_titles.add(key)
|
continue
|
||||||
merged.append(r)
|
if any(kw in t for kw in offer_reject):
|
||||||
if len(merged) >= 5:
|
continue
|
||||||
break
|
if any(gw in t for gw in group_words):
|
||||||
|
continue
|
||||||
|
seen_titles.add(key)
|
||||||
|
merged.append(r)
|
||||||
|
if len(merged) >= 5:
|
||||||
|
break
|
||||||
|
|
||||||
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
|
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
|
||||||
return merged[:10]
|
return merged[:10]
|
||||||
@@ -1601,8 +1962,8 @@ async def agent_run(task: str = Body(..., embed=True)):
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
@app.post("/scrape/facebook")
|
@app.post("/scrape/facebook")
|
||||||
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
|
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)
|
result = await scrape_facebook(profile_path, force, query)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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":"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":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
|
{"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"}
|
||||||
{"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"}
|
|
||||||
|
|||||||
@@ -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) ==='
|
\echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ==='
|
||||||
\i 022_custom_fields.sql
|
\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 ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: false,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -12,6 +12,9 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ["lucide-react", "framer-motion", "date-fns", "@radix-ui/react-icons", "recharts"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
Generated
-3
@@ -248,7 +248,6 @@
|
|||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -263,7 +262,6 @@
|
|||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"tslib": "^2.0.0"
|
"tslib": "^2.0.0"
|
||||||
@@ -277,7 +275,6 @@
|
|||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.0.0"
|
"tslib": "^2.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-3
@@ -7,9 +7,9 @@
|
|||||||
"dev:signaling": "node signaling-server.mjs",
|
"dev:signaling": "node signaling-server.mjs",
|
||||||
"dev:open": "node scripts/open-browser.mjs",
|
"dev:open": "node scripts/open-browser.mjs",
|
||||||
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
|
"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: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:ollama": "node scripts/ensure-ollama.mjs",
|
||||||
"dev:rust": "node ai-server/index.mjs",
|
"dev:rust": "node ai-server/index.mjs",
|
||||||
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
"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",
|
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
|
||||||
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
|
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
|
||||||
"build": "next build",
|
"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"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ const { chromium } = require('playwright');
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
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;
|
const PORT = process.env.PORT || 3007;
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
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.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
const items = await page.evaluate(() => {
|
const items = await page.evaluate(() => {
|
||||||
const cards = document.querySelectorAll('.job_seen_beacon');
|
const cards = document.querySelectorAll('.job_seen_beacon, [class*="card"], [class*="result"], li');
|
||||||
return Array.from(cards).slice(0, 8).map(card => {
|
return Array.from(cards).slice(0, 10).map(card => {
|
||||||
const titleEl = card.querySelector('.jcs-JobTitle');
|
const titleEl = card.querySelector('.jcs-JobTitle, a[class*="title"], a[class*="job"], h2 a');
|
||||||
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
|
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"], [class*="company"], [class*="comp"]');
|
||||||
const snippetEl = card.querySelector('.job-snippet');
|
const snippetEl = card.querySelector('.job-snippet, [class*="snippet"], [class*="summary"]');
|
||||||
return {
|
return {
|
||||||
title: titleEl?.textContent?.trim() || '',
|
title: titleEl?.textContent?.trim() || '',
|
||||||
url: titleEl?.href || '',
|
url: titleEl?.href || '',
|
||||||
company: companyEl?.textContent?.trim() || '',
|
company: companyEl?.textContent?.trim() || '',
|
||||||
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
|
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
|
||||||
};
|
};
|
||||||
});
|
}).filter(j => j.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|||||||
+7
-9
@@ -558,12 +558,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.61.0"
|
"playwright-core": "1.61.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -576,10 +575,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-16
@@ -23831,6 +23831,7 @@ var init_harTracer = __esm({
|
|||||||
this._pageEntries = [];
|
this._pageEntries = [];
|
||||||
this._eventListeners = [];
|
this._eventListeners = [];
|
||||||
this._started = false;
|
this._started = false;
|
||||||
|
this._omitWebSocketFrames = true;
|
||||||
this._context = context2;
|
this._context = context2;
|
||||||
this._page = page;
|
this._page = page;
|
||||||
this._delegate = delegate;
|
this._delegate = delegate;
|
||||||
@@ -23847,6 +23848,9 @@ var init_harTracer = __esm({
|
|||||||
this._pageEntrySymbol = Symbol("pageHarEntry");
|
this._pageEntrySymbol = Symbol("pageHarEntry");
|
||||||
this._baseURL = context2 instanceof APIRequestContext ? context2._defaultOptions().baseURL : context2._options.baseURL;
|
this._baseURL = context2 instanceof APIRequestContext ? context2._defaultOptions().baseURL : context2._options.baseURL;
|
||||||
}
|
}
|
||||||
|
setOmitWebSocketFrames(omitWebSocketFrames) {
|
||||||
|
this._omitWebSocketFrames = omitWebSocketFrames;
|
||||||
|
}
|
||||||
start(options) {
|
start(options) {
|
||||||
if (this._started)
|
if (this._started)
|
||||||
return;
|
return;
|
||||||
@@ -24157,7 +24161,7 @@ var init_harTracer = __esm({
|
|||||||
harEntry._resourceType = "websocket";
|
harEntry._resourceType = "websocket";
|
||||||
let sha1 = void 0;
|
let sha1 = void 0;
|
||||||
const recordMessage = (type3, opcode, data, wallTimeMs) => {
|
const recordMessage = (type3, opcode, data, wallTimeMs) => {
|
||||||
if (this._options.omitWebSocketFrames)
|
if (this._omitWebSocketFrames)
|
||||||
return;
|
return;
|
||||||
const message = { type: type3, time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data };
|
const message = { type: type3, time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data };
|
||||||
if (this._options.content === "embed") {
|
if (this._options.content === "embed") {
|
||||||
@@ -24441,9 +24445,9 @@ var init_harRecorder = __esm({
|
|||||||
includeTraceInfo: false,
|
includeTraceInfo: false,
|
||||||
recordRequestOverrides: true,
|
recordRequestOverrides: true,
|
||||||
waitForContentOnStop: true,
|
waitForContentOnStop: true,
|
||||||
urlFilter: urlFilterRe ?? options.urlGlob,
|
urlFilter: urlFilterRe ?? options.urlGlob
|
||||||
omitWebSocketFrames: !!process.env.PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES
|
|
||||||
});
|
});
|
||||||
|
this._tracer.setOmitWebSocketFrames(!!process.env.PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES);
|
||||||
this._tracer.start({ omitScripts: false });
|
this._tracer.start({ omitScripts: false });
|
||||||
}
|
}
|
||||||
onEntryStarted(entry) {
|
onEntryStarted(entry) {
|
||||||
@@ -24652,8 +24656,7 @@ var init_tracing = __esm({
|
|||||||
content: "attach",
|
content: "attach",
|
||||||
includeTraceInfo: true,
|
includeTraceInfo: true,
|
||||||
recordRequestOverrides: false,
|
recordRequestOverrides: false,
|
||||||
waitForContentOnStop: false,
|
waitForContentOnStop: false
|
||||||
omitWebSocketFrames: !!process.env.PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES
|
|
||||||
});
|
});
|
||||||
const testIdAttributeName2 = "selectors" in context2 ? context2.selectors().testIdAttributeName() : void 0;
|
const testIdAttributeName2 = "selectors" in context2 ? context2.selectors().testIdAttributeName() : void 0;
|
||||||
this._contextCreatedEvent = {
|
this._contextCreatedEvent = {
|
||||||
@@ -24747,6 +24750,7 @@ var init_tracing = __esm({
|
|||||||
);
|
);
|
||||||
if (this._state.options.screenshots)
|
if (this._state.options.screenshots)
|
||||||
this._startScreencast();
|
this._startScreencast();
|
||||||
|
this._harTracer.setOmitWebSocketFrames(!!process.env.PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES);
|
||||||
if (this._state.options.snapshots)
|
if (this._state.options.snapshots)
|
||||||
await this._snapshotter?.start(progress2);
|
await this._snapshotter?.start(progress2);
|
||||||
return { traceName: this._state.traceName };
|
return { traceName: this._state.traceName };
|
||||||
@@ -24896,6 +24900,7 @@ var init_tracing = __esm({
|
|||||||
eventsHelper.removeEventListeners(this._eventListeners);
|
eventsHelper.removeEventListeners(this._eventListeners);
|
||||||
if (this._state.options.screenshots)
|
if (this._state.options.screenshots)
|
||||||
this._stopScreencast();
|
this._stopScreencast();
|
||||||
|
this._harTracer.setOmitWebSocketFrames(true);
|
||||||
if (this._state.options.snapshots)
|
if (this._state.options.snapshots)
|
||||||
this._snapshotter?.stop();
|
this._snapshotter?.stop();
|
||||||
this.flushHarEntries();
|
this.flushHarEntries();
|
||||||
@@ -35989,7 +35994,7 @@ var init_crNetworkManager = __esm({
|
|||||||
}
|
}
|
||||||
_onWebSocketWillSendHandshakeRequest(event) {
|
_onWebSocketWillSendHandshakeRequest(event) {
|
||||||
const wallTimeMs = event.wallTime * 1e3;
|
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);
|
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers, "\n"), wallTimeMs);
|
||||||
}
|
}
|
||||||
_onWebSocketClosed(event) {
|
_onWebSocketClosed(event) {
|
||||||
@@ -35997,7 +36002,7 @@ var init_crNetworkManager = __esm({
|
|||||||
this._page.frameManager.webSocketClosed(event.requestId);
|
this._page.frameManager.webSocketClosed(event.requestId);
|
||||||
}
|
}
|
||||||
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
||||||
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
|
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
|
||||||
}
|
}
|
||||||
_maybeUpdateRequestSession(sessionInfo, request2) {
|
_maybeUpdateRequestSession(sessionInfo, request2) {
|
||||||
if (request2.session !== sessionInfo.session && !sessionInfo.isMain && (request2._documentId === request2._requestId || sessionInfo.workerFrame))
|
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);
|
this._page.frameManager.frameAbortedNavigation(params2.frameId, params2.errorText, params2.navigationId);
|
||||||
}
|
}
|
||||||
_onNavigationCommitted(params2) {
|
_onNavigationCommitted(params2) {
|
||||||
if (!params2.navigationId) {
|
|
||||||
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [workerId, worker] of this._workers) {
|
for (const [workerId, worker] of this._workers) {
|
||||||
if (worker.frameId === params2.frameId)
|
if (worker.frameId === params2.frameId)
|
||||||
this._onWorkerDestroyed({ workerId });
|
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) {
|
_onSameDocumentNavigation(params2) {
|
||||||
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
|
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
|
||||||
@@ -46941,7 +46942,7 @@ var init_wkPage = __esm({
|
|||||||
}
|
}
|
||||||
_onWebSocketWillSendHandshakeRequest(event) {
|
_onWebSocketWillSendHandshakeRequest(event) {
|
||||||
const wallTimeMs = event.walltime * 1e3;
|
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);
|
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs);
|
||||||
}
|
}
|
||||||
_onWebSocketClosed(event) {
|
_onWebSocketClosed(event) {
|
||||||
@@ -46949,7 +46950,7 @@ var init_wkPage = __esm({
|
|||||||
this._page.frameManager.webSocketClosed(event.requestId);
|
this._page.frameManager.webSocketClosed(event.requestId);
|
||||||
}
|
}
|
||||||
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
||||||
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
|
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
|
||||||
}
|
}
|
||||||
async _grantPermissions(origin, permissions) {
|
async _grantPermissions(origin, permissions) {
|
||||||
const webPermissionToProtocol = /* @__PURE__ */ new Map([
|
const webPermissionToProtocol = /* @__PURE__ */ new Map([
|
||||||
@@ -49287,7 +49288,7 @@ var init_wvPage = __esm({
|
|||||||
}
|
}
|
||||||
_onWebSocketWillSendHandshakeRequest(event) {
|
_onWebSocketWillSendHandshakeRequest(event) {
|
||||||
const wallTimeMs = event.walltime * 1e3;
|
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);
|
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs);
|
||||||
}
|
}
|
||||||
_onWebSocketClosed(event) {
|
_onWebSocketClosed(event) {
|
||||||
@@ -49295,7 +49296,7 @@ var init_wvPage = __esm({
|
|||||||
this._page.frameManager.webSocketClosed(event.requestId);
|
this._page.frameManager.webSocketClosed(event.requestId);
|
||||||
}
|
}
|
||||||
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
||||||
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
|
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
|
||||||
}
|
}
|
||||||
shouldToggleStyleSheetToSyncAnimations() {
|
shouldToggleStyleSheetToSyncAnimations() {
|
||||||
return true;
|
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="icon" href="./playwright-logo.svg" type="image/svg+xml">
|
||||||
<link rel="manifest" href="./manifest.webmanifest">
|
<link rel="manifest" href="./manifest.webmanifest">
|
||||||
<title>Playwright Trace Viewer</title>
|
<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/urlMatch-BYQrIQwR.js">
|
||||||
<link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-BNmKHKpQ.js">
|
<link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-BNmKHKpQ.js">
|
||||||
<link rel="stylesheet" crossorigin href="./defaultSettingsView.CjdS-WJx.css">
|
<link rel="stylesheet" crossorigin href="./defaultSettingsView.CjdS-WJx.css">
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "playwright-core",
|
"name": "playwright-core",
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"description": "A high-level API to automate web browsers",
|
"description": "A high-level API to automate web browsers",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+3
-2
@@ -869,7 +869,7 @@ function resolve(specifier, context, nextResolve) {
|
|||||||
const filename = import_url.default.fileURLToPath(context.parentURL);
|
const filename = import_url.default.fileURLToPath(context.parentURL);
|
||||||
const resolved = resolveHook(filename, specifier);
|
const resolved = resolveHook(filename, specifier);
|
||||||
if (resolved !== void 0) {
|
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);
|
const result2 = nextResolve(specifier, context);
|
||||||
@@ -894,7 +894,8 @@ function load(moduleUrl, context, nextLoad) {
|
|||||||
const filename = import_url.default.fileURLToPath(moduleUrl);
|
const filename = import_url.default.fileURLToPath(moduleUrl);
|
||||||
if (!shouldTransform(filename))
|
if (!shouldTransform(filename))
|
||||||
return nextLoad(moduleUrl, context);
|
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 code = import_fs5.default.readFileSync(filename, "utf-8");
|
||||||
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
|
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
|
||||||
return {
|
return {
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
1.2 KB packages/playwright/src/common/validators.ts
|
1.2 KB packages/playwright/src/common/validators.ts
|
||||||
1.3 KB packages/playwright/src/isomorphic/teleReceiver.ts
|
1.3 KB packages/playwright/src/isomorphic/teleReceiver.ts
|
||||||
9.6 KB packages/playwright/src/transform/compilationCache.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/pirates.ts
|
||||||
1.1 KB packages/playwright/src/transform/portTransport.ts
|
1.1 KB packages/playwright/src/transform/portTransport.ts
|
||||||
12.3 KB packages/playwright/src/transform/transform.ts
|
12.3 KB packages/playwright/src/transform/transform.ts
|
||||||
|
|||||||
+3
-1
@@ -12832,8 +12832,10 @@ function createExpect(info) {
|
|||||||
if (typeof m !== "function")
|
if (typeof m !== "function")
|
||||||
throw new TypeError(`expect.extend: \`${name}\` is not a valid matcher. Must be a function, is "${typeof m}"`);
|
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)) {
|
for (const [name, matcher] of Object.entries(matchers2)) {
|
||||||
|
if (name in allBuiltinMatchers)
|
||||||
|
continue;
|
||||||
|
info.userMatchers[name] = matcher;
|
||||||
const { positive, inverse } = buildCustomAsymmetricMatcher(name, matcher);
|
const { positive, inverse } = buildCustomAsymmetricMatcher(name, matcher);
|
||||||
expectFn[name] = positive;
|
expectFn[name] = positive;
|
||||||
notAsymmetric[name] = inverse;
|
notAsymmetric[name] = inverse;
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# packages/playwright/lib/matchers/expect.js
|
# packages/playwright/lib/matchers/expect.js
|
||||||
# total: 513.8 KB
|
# total: 513.9 KB
|
||||||
|
|
||||||
## Inlined (64)
|
## Inlined (64)
|
||||||
7.9 KB node_modules/@babel/code-frame/lib/index.js
|
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 filename = import_url.default.fileURLToPath(context.parentURL);
|
||||||
const resolved = resolveHook(filename, specifier);
|
const resolved = resolveHook(filename, specifier);
|
||||||
if (resolved !== void 0) {
|
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);
|
const result2 = nextResolve(specifier, context);
|
||||||
@@ -5654,7 +5654,8 @@ function load(moduleUrl, context, nextLoad) {
|
|||||||
const filename = import_url.default.fileURLToPath(moduleUrl);
|
const filename = import_url.default.fileURLToPath(moduleUrl);
|
||||||
if (!shouldTransform(filename))
|
if (!shouldTransform(filename))
|
||||||
return nextLoad(moduleUrl, context);
|
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 code = import_fs4.default.readFileSync(filename, "utf-8");
|
||||||
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
|
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# packages/playwright/lib/transform/esmLoader.js
|
# packages/playwright/lib/transform/esmLoader.js
|
||||||
# total: 249.0 KB
|
# total: 249.1 KB
|
||||||
|
|
||||||
## Inlined (47)
|
## Inlined (47)
|
||||||
1.5 KB node_modules/balanced-match/index.js
|
1.5 KB node_modules/balanced-match/index.js
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
0.2 KB packages/isomorphic/stringUtils.ts
|
0.2 KB packages/isomorphic/stringUtils.ts
|
||||||
6.3 KB packages/playwright/src/transform/compilationCache.ts
|
6.3 KB packages/playwright/src/transform/compilationCache.ts
|
||||||
3.1 KB packages/playwright/src/transform/esmLoader.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/pirates.ts
|
||||||
1.1 KB packages/playwright/src/transform/portTransport.ts
|
1.1 KB packages/playwright/src/transform/portTransport.ts
|
||||||
11.7 KB packages/playwright/src/transform/transform.ts
|
11.7 KB packages/playwright/src/transform/transform.ts
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "playwright",
|
"name": "playwright",
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"description": "A high-level API to automate web browsers",
|
"description": "A high-level API to automate web browsers",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
},
|
},
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.61.0"
|
"playwright-core": "1.61.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"fsevents": "2.3.2"
|
"fsevents": "2.3.2"
|
||||||
|
|||||||
Generated
+8
-10
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"playwright": "^1.48.0"
|
"playwright": "^1.61.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -580,12 +580,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.61.0"
|
"playwright-core": "1.61.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -598,10 +597,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"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
|
// Give it a moment to start listening
|
||||||
if (platform() === "win32") {
|
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 {
|
} else {
|
||||||
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
|
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 ──")
|
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) {
|
if (doSelfHeal) {
|
||||||
console.log("\n── Self-Healing Build Check ──")
|
console.log("\n── Self-Healing Build Check ──")
|
||||||
const { generateModule } = loadRegistry()
|
const { generateModule } = loadRegistry()
|
||||||
@@ -252,7 +261,7 @@ if (doSelfHeal) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Final summary
|
// 7. Final summary
|
||||||
console.log("\n── Final Steps ──")
|
console.log("\n── Final Steps ──")
|
||||||
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
||||||
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react"
|
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 { JobSelector } from "@/components/ai/job-selector"
|
||||||
import { Bot, ChevronRight, Wifi, WifiOff } from "lucide-react"
|
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 [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
||||||
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
|
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
|
||||||
const [aiOnline, setAiOnline] = useState<boolean | null>(null)
|
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) => {
|
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||||
setSelectedJob(job)
|
setSelectedJob(job)
|
||||||
@@ -38,6 +63,103 @@ export default function AIAssistantPage() {
|
|||||||
return () => { cancelled = true; clearInterval(id) }
|
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) => {
|
const handleTipClick = useCallback((tip: string) => {
|
||||||
aiChatRef.current?.fillInput(tip)
|
aiChatRef.current?.fillInput(tip)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -51,6 +173,12 @@ export default function AIAssistantPage() {
|
|||||||
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
|
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
|
||||||
return next
|
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 (
|
return (
|
||||||
@@ -95,7 +223,7 @@ export default function AIAssistantPage() {
|
|||||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
<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>
|
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
|
||||||
</div>
|
</div>
|
||||||
<JobSelector onSelect={handleJobSelect} />
|
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
|
||||||
{selectedJob && (
|
{selectedJob && (
|
||||||
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
|
<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>
|
<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 gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Messages today</div>
|
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Messages today</div>
|
||||||
<div className="text-foreground text-sm font-semibold mt-0.5">24</div>
|
<div className="text-foreground text-sm font-semibold mt-0.5">{msgCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Tokens used</div>
|
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Tokens used</div>
|
||||||
<div className="text-foreground text-sm font-semibold mt-0.5">12.4k</div>
|
<div className="text-foreground text-sm font-semibold mt-0.5">{tokenEstimate}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -126,11 +126,11 @@ export default function CalendarPage() {
|
|||||||
fetch("/api/event-users")
|
fetch("/api/event-users")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setUsers(data.users || []))
|
.then((data) => setUsers(data.users || []))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch event users:", err))
|
||||||
fetch("/api/leads?limit=200")
|
fetch("/api/leads?limit=200")
|
||||||
.then((r) => r.json())
|
.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 }))))
|
.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])
|
}, [user, router])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -139,13 +139,13 @@ export default function CalendarPage() {
|
|||||||
}, [fetchMonthEvents])
|
}, [fetchMonthEvents])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUpcomingEvents().catch(() => {})
|
fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
|
||||||
}, [fetchUpcomingEvents])
|
}, [fetchUpcomingEvents])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
fetchMonthEvents().catch(() => {})
|
fetchMonthEvents().catch((err) => console.error("Failed to fetch month events:", err))
|
||||||
fetchUpcomingEvents().catch(() => {})
|
fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
|
||||||
}, 15000)
|
}, 15000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [fetchMonthEvents, fetchUpcomingEvents])
|
}, [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,
|
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||||
|
Pin, PinOff,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||||
import {
|
import {
|
||||||
@@ -211,8 +212,15 @@ const formatPreviewContent = (content: string) => {
|
|||||||
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
||||||
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return conversations
|
let list = conversations
|
||||||
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
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])
|
}, [conversations, searchQuery])
|
||||||
|
|
||||||
// Fetch conversations from API
|
// Fetch conversations from API
|
||||||
@@ -227,8 +235,8 @@ const formatPreviewContent = (content: string) => {
|
|||||||
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
|
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
|
||||||
if (convs.length > 0) setActiveChat(convs[0].id)
|
if (convs.length > 0) setActiveChat(convs[0].id)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch conversations in chats page")
|
console.error("Failed to fetch conversations:", err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingChats(false)
|
setLoadingChats(false)
|
||||||
}
|
}
|
||||||
@@ -289,11 +297,11 @@ const formatPreviewContent = (content: string) => {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
console.error("Failed to fetch messages:", err)
|
||||||
}
|
}
|
||||||
// Mark conversation as read
|
// 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 })
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||||
}
|
}
|
||||||
fetchMsgs()
|
fetchMsgs()
|
||||||
@@ -312,7 +320,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
||||||
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.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)
|
setSearchingUsers(false)
|
||||||
}, 300)
|
}, 300)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
@@ -502,10 +510,13 @@ const formatPreviewContent = (content: string) => {
|
|||||||
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
||||||
: conv
|
: conv
|
||||||
)
|
)
|
||||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
const conv = updated.find((c) => c.id === activeChat)
|
||||||
if (idx > 0) {
|
if (conv && !conv.pinned) {
|
||||||
const [item] = updated.splice(idx, 1)
|
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||||
updated.unshift(item)
|
if (idx > 0) {
|
||||||
|
const [item] = updated.splice(idx, 1)
|
||||||
|
updated.unshift(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
@@ -776,6 +787,23 @@ const formatPreviewContent = (content: string) => {
|
|||||||
|
|
||||||
const isImageFile = (file: File) => file.type.startsWith("image/")
|
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) => {
|
const getDisplayContent = (content: string) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content)
|
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 })
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
const idx = prev.findIndex((c) => c.id === conv.id)
|
const idx = prev.findIndex((c) => c.id === conv.id)
|
||||||
if (idx > 0) {
|
if (idx > 0 && !conv.pinned) {
|
||||||
const u = [...prev]
|
const u = [...prev]
|
||||||
const [item] = u.splice(idx, 1)
|
const [item] = u.splice(idx, 1)
|
||||||
u.unshift(item)
|
u.unshift(item)
|
||||||
@@ -827,7 +855,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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"
|
isActive && "bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -837,8 +865,21 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-medium truncate">{person.name}</span>
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
{conv.pinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground rotate-45" />}
|
||||||
|
<span className="text-sm font-medium truncate">{person.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleTogglePin(conv.id, conv.pinned) }}
|
||||||
|
className="h-5 w-5 flex items-center justify-center rounded hover:bg-muted-foreground/20 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title={conv.pinned ? "Unpin" : "Pin"}
|
||||||
|
>
|
||||||
|
{conv.pinned ? <PinOff className="h-3 w-3 text-muted-foreground" /> : <Pin className="h-3 w-3 text-muted-foreground rotate-45" />}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">{conv.lastMessageTime}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
|
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -871,7 +912,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
setSearchQuery("")
|
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"
|
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()
|
const data = await res.json()
|
||||||
setStats(data)
|
setStats(data)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch dashboard stats")
|
console.error("Failed to fetch dashboard stats:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function EmailsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) { router.push("/login"); return }
|
if (!user) { router.push("/login"); return }
|
||||||
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); 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])
|
}, [user, router])
|
||||||
|
|
||||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
|
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(),
|
fetchNotes(),
|
||||||
])
|
])
|
||||||
if (leadRes.ok) setLead(await leadRes.json())
|
if (leadRes.ok) setLead(await leadRes.json())
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch lead details")
|
console.error("Failed to fetch lead details:", err)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { GripVertical, Plus, User, Building2, Mail, Phone, Hash } from "lucide-react"
|
import { GripVertical, Plus, User, Building2, Mail, Phone, Hash } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface KanbanLead {
|
interface KanbanLead {
|
||||||
id: string
|
id: string
|
||||||
@@ -57,6 +58,7 @@ export default function KanbanPage() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setColumns(data)
|
setColumns(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to load kanban data")
|
||||||
console.error("Failed to load kanban", e)
|
console.error("Failed to load kanban", e)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -98,6 +100,7 @@ export default function KanbanPage() {
|
|||||||
body: JSON.stringify({ status: toStatus }),
|
body: JSON.stringify({ status: toStatus }),
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to update lead status")
|
||||||
console.error("Failed to update lead status", e)
|
console.error("Failed to update lead status", e)
|
||||||
fetchKanban()
|
fetchKanban()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ArrowLeft } from "lucide-react"
|
|||||||
import { User } from "@/types"
|
import { User } from "@/types"
|
||||||
import { useNotifications } from "@/providers/notification-provider"
|
import { useNotifications } from "@/providers/notification-provider"
|
||||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const leadFormSchema = z.object({
|
const leadFormSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -53,7 +54,7 @@ export default function CreateLeadPage() {
|
|||||||
fetch("/api/users")
|
fetch("/api/users")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setUsers(data.users || []))
|
.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>({
|
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}`)
|
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
||||||
router.push("/leads")
|
router.push("/leads")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to create lead")
|
||||||
console.error("Create lead error:", e)
|
console.error("Create lead error:", e)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { LeadsTable } from "@/components/leads/leads-table"
|
|||||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Columns3 } from "lucide-react"
|
import { Columns3 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Lead } from "@/types"
|
import { Lead } from "@/types"
|
||||||
|
|
||||||
export default function LeadsPage() {
|
export default function LeadsPage() {
|
||||||
@@ -30,7 +31,7 @@ export default function LeadsPage() {
|
|||||||
setLeadsData(data)
|
setLeadsData(data)
|
||||||
setLoading(false)
|
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])
|
}, [search, statusFilter, periodFilter])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface Milestone {
|
interface Milestone {
|
||||||
id: string
|
id: string
|
||||||
@@ -82,6 +83,7 @@ export default function ProjectDetailPage() {
|
|||||||
setMilestones(await mRes.json())
|
setMilestones(await mRes.json())
|
||||||
setStatuses(await sRes.json())
|
setStatuses(await sRes.json())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to load project")
|
||||||
console.error("Failed to load project", e)
|
console.error("Failed to load project", e)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -91,21 +93,31 @@ export default function ProjectDetailPage() {
|
|||||||
useEffect(() => { fetchProject() }, [fetchProject])
|
useEffect(() => { fetchProject() }, [fetchProject])
|
||||||
|
|
||||||
const updateProjectStatus = async (statusId: string) => {
|
const updateProjectStatus = async (statusId: string) => {
|
||||||
await fetch(`/api/projects/${id}`, {
|
try {
|
||||||
method: "PATCH",
|
await fetch(`/api/projects/${id}`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "PATCH",
|
||||||
body: JSON.stringify({ statusId }),
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
body: JSON.stringify({ statusId }),
|
||||||
fetchProject()
|
})
|
||||||
|
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) => {
|
const updateMilestoneStatus = async (milestoneId: string, status: string) => {
|
||||||
await fetch(`/api/milestones/${milestoneId}`, {
|
try {
|
||||||
method: "PATCH",
|
await fetch(`/api/milestones/${milestoneId}`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "PATCH",
|
||||||
body: JSON.stringify({ status }),
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
body: JSON.stringify({ status }),
|
||||||
fetchProject()
|
})
|
||||||
|
fetchProject()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update milestone status", e)
|
||||||
|
toast.error("Failed to update milestone status")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMilestone = async () => {
|
const addMilestone = async () => {
|
||||||
@@ -119,6 +131,9 @@ export default function ProjectDetailPage() {
|
|||||||
})
|
})
|
||||||
setNewMilestoneName("")
|
setNewMilestoneName("")
|
||||||
fetchProject()
|
fetchProject()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to add milestone", e)
|
||||||
|
toast.error("Failed to add milestone")
|
||||||
} finally {
|
} finally {
|
||||||
setAddingMilestone(false)
|
setAddingMilestone(false)
|
||||||
}
|
}
|
||||||
@@ -140,6 +155,7 @@ export default function ProjectDetailPage() {
|
|||||||
setNewTaskInputs((prev) => ({ ...prev, [milestoneId]: "" }))
|
setNewTaskInputs((prev) => ({ ...prev, [milestoneId]: "" }))
|
||||||
fetchProject()
|
fetchProject()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to add task")
|
||||||
console.error("Failed to add task", e)
|
console.error("Failed to add task", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string
|
id: string
|
||||||
@@ -62,8 +63,10 @@ export default function ProjectsPage() {
|
|||||||
if (search) params.set("search", search)
|
if (search) params.set("search", search)
|
||||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||||
const res = await fetch(`/api/projects?${params}`)
|
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) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to load projects")
|
||||||
console.error("Failed to load projects", e)
|
console.error("Failed to load projects", e)
|
||||||
setProjects([])
|
setProjects([])
|
||||||
} finally {
|
} finally {
|
||||||
@@ -74,7 +77,7 @@ export default function ProjectsPage() {
|
|||||||
useEffect(() => { fetchProjects() }, [search, statusFilter])
|
useEffect(() => { fetchProjects() }, [search, statusFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
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() : "—"
|
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—"
|
||||||
@@ -167,8 +170,8 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
fetch("/api/customers?limit=100").then(r => r.json()).then(setCustomers).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(() => {})
|
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])
|
}, [open])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -193,6 +196,9 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
|
|||||||
setName(""); setDescription(""); setCustomerId(""); setOwnerId(""); setStartDate(""); setTargetEndDate(""); setBudget("")
|
setName(""); setDescription(""); setCustomerId(""); setOwnerId(""); setStartDate(""); setTargetEndDate(""); setBudget("")
|
||||||
onCreated()
|
onCreated()
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create project", e)
|
||||||
|
toast.error("Failed to create project")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -214,29 +220,29 @@ function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[];
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Customer *</label>
|
<label className="text-sm font-medium">Customer *</label>
|
||||||
<select
|
<Select value={customerId} onValueChange={setCustomerId}>
|
||||||
value={customerId}
|
<SelectTrigger className="h-9 w-full">
|
||||||
onChange={(e) => setCustomerId(e.target.value)}
|
<SelectValue placeholder="Select customer..." />
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">Select customer...</option>
|
{customers.map((c: any) => (
|
||||||
{customers.map((c: any) => (
|
<SelectItem key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</SelectItem>
|
||||||
<option key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</option>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Project Owner</label>
|
<label className="text-sm font-medium">Project Owner</label>
|
||||||
<select
|
<Select value={ownerId} onValueChange={setOwnerId}>
|
||||||
value={ownerId}
|
<SelectTrigger className="h-9 w-full">
|
||||||
onChange={(e) => setOwnerId(e.target.value)}
|
<SelectValue placeholder="Select owner..." />
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">Select owner...</option>
|
{users.map((u: any) => (
|
||||||
{users.map((u: any) => (
|
<SelectItem key={u.id} value={u.id}>{u.first_name} {u.last_name}</SelectItem>
|
||||||
<option key={u.id} value={u.id}>{u.first_name} {u.last_name}</option>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function ProposalDetailPage() {
|
export default function ProposalDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -35,6 +36,7 @@ export default function ProposalDetailPage() {
|
|||||||
setProposal(await pRes.json())
|
setProposal(await pRes.json())
|
||||||
setStatuses(await sRes.json())
|
setStatuses(await sRes.json())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to load proposal")
|
||||||
console.error("Failed to load proposal", e)
|
console.error("Failed to load proposal", e)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -44,12 +46,17 @@ export default function ProposalDetailPage() {
|
|||||||
useEffect(() => { fetchProposal() }, [fetchProposal])
|
useEffect(() => { fetchProposal() }, [fetchProposal])
|
||||||
|
|
||||||
const updateStatus = async (statusId: string) => {
|
const updateStatus = async (statusId: string) => {
|
||||||
await fetch(`/api/proposals/${id}`, {
|
try {
|
||||||
method: "PATCH",
|
await fetch(`/api/proposals/${id}`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "PATCH",
|
||||||
body: JSON.stringify({ statusId }),
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
body: JSON.stringify({ statusId }),
|
||||||
fetchProposal()
|
})
|
||||||
|
fetchProposal()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update proposal status", e)
|
||||||
|
toast.error("Failed to update proposal status")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSign = async () => {
|
const handleSign = async () => {
|
||||||
@@ -62,6 +69,9 @@ export default function ProposalDetailPage() {
|
|||||||
body: JSON.stringify({ name: signName, email: signEmail }),
|
body: JSON.stringify({ name: signName, email: signEmail }),
|
||||||
})
|
})
|
||||||
if (res.ok) fetchProposal()
|
if (res.ok) fetchProposal()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to sign proposal", e)
|
||||||
|
toast.error("Failed to sign proposal")
|
||||||
} finally {
|
} finally {
|
||||||
setSigning(false)
|
setSigning(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface LineItem {
|
interface LineItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -39,8 +40,8 @@ export default function NewProposalPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/leads?limit=100").then((r) => r.json()).then(setLeads).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(() => {})
|
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 = () => {
|
const addItem = () => {
|
||||||
@@ -107,6 +108,7 @@ export default function NewProposalPage() {
|
|||||||
|
|
||||||
router.push(`/proposals/${data.id}`)
|
router.push(`/proposals/${data.id}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to create proposal")
|
||||||
console.error("Failed to create proposal", e)
|
console.error("Failed to create proposal", e)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@@ -135,21 +137,29 @@ export default function NewProposalPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Lead (optional)</label>
|
<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">
|
<Select value={leadId} onValueChange={setLeadId}>
|
||||||
<option value="">Select lead...</option>
|
<SelectTrigger className="h-9 w-full">
|
||||||
{leads.map((l: any) => (
|
<SelectValue placeholder="Select lead..." />
|
||||||
<option key={l.id} value={l.id}>{l.contact_name || l.company_name}</option>
|
</SelectTrigger>
|
||||||
))}
|
<SelectContent>
|
||||||
</select>
|
{leads.map((l: any) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>{l.contact_name || l.company_name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Customer (optional)</label>
|
<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">
|
<Select value={customerId} onValueChange={setCustomerId}>
|
||||||
<option value="">Select customer...</option>
|
<SelectTrigger className="h-9 w-full">
|
||||||
{customers.map((c: any) => (
|
<SelectValue placeholder="Select customer..." />
|
||||||
<option key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</option>
|
</SelectTrigger>
|
||||||
))}
|
<SelectContent>
|
||||||
</select>
|
{customers.map((c: any) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">VAT %</label>
|
<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 { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card } from "@/components/ui/card"
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function ProposalsPage() {
|
export default function ProposalsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -31,16 +32,29 @@ export default function ProposalsPage() {
|
|||||||
fetch(`/api/proposals?${params}`)
|
fetch(`/api/proposals?${params}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setProposals)
|
.then(setProposals)
|
||||||
.catch(() => {})
|
.catch((e) => { console.error("Failed to load proposals", e); toast.error("Failed to load proposals") })
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [search, statusFilter])
|
}, [search, statusFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
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 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageHeader title="Proposals & Quotes" description="Create and send professional quotes to clients">
|
<PageHeader title="Proposals & Quotes" description="Create and send professional quotes to clients">
|
||||||
@@ -104,6 +118,13 @@ export default function ProposalsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
</div>
|
</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 { ThemeSettings } from "@/components/settings/theme-settings"
|
||||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||||
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager"
|
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() {
|
export default function SettingsPage() {
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -16,6 +17,7 @@ export default function SettingsPage() {
|
|||||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
||||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||||
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
|
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
|
||||||
|
{ value: "lead-scoring", label: "Lead Scoring", icon: Gauge, component: LeadScoringSettings },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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 { Play, Square, Plus, Trash2, Clock, Briefcase, User, TrendingUp, DollarSign } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface TimeEntry {
|
interface TimeEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -28,7 +36,7 @@ export default function TimeTrackingPage() {
|
|||||||
const [showManual, setShowManual] = useState(false)
|
const [showManual, setShowManual] = useState(false)
|
||||||
|
|
||||||
// Manual entry form
|
// Manual entry form
|
||||||
const [manualProject, setManualProject] = useState("")
|
const [manualProject, setManualProject] = useState("none")
|
||||||
const [manualDesc, setManualDesc] = useState("")
|
const [manualDesc, setManualDesc] = useState("")
|
||||||
const [manualDate, setManualDate] = useState(new Date().toISOString().split("T")[0])
|
const [manualDate, setManualDate] = useState(new Date().toISOString().split("T")[0])
|
||||||
const [manualHours, setManualHours] = useState("")
|
const [manualHours, setManualHours] = useState("")
|
||||||
@@ -40,7 +48,7 @@ export default function TimeTrackingPage() {
|
|||||||
// Timer
|
// Timer
|
||||||
const [timerRunning, setTimerRunning] = useState(false)
|
const [timerRunning, setTimerRunning] = useState(false)
|
||||||
const [timerSeconds, setTimerSeconds] = useState(0)
|
const [timerSeconds, setTimerSeconds] = useState(0)
|
||||||
const [timerProject, setTimerProject] = useState("")
|
const [timerProject, setTimerProject] = useState("none")
|
||||||
const [timerDesc, setTimerDesc] = useState("")
|
const [timerDesc, setTimerDesc] = useState("")
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const startTimeRef = useRef<Date | null>(null)
|
const startTimeRef = useRef<Date | null>(null)
|
||||||
@@ -52,10 +60,14 @@ export default function TimeTrackingPage() {
|
|||||||
fetch("/api/time-entries/summary"),
|
fetch("/api/time-entries/summary"),
|
||||||
fetch("/api/projects"),
|
fetch("/api/projects"),
|
||||||
])
|
])
|
||||||
if (eRes.ok) setEntries(await eRes.json()); else setEntries([])
|
const eData = eRes.ok ? await eRes.json() : []
|
||||||
if (sRes.ok) setSummary(await sRes.json()); else setSummary(null)
|
const sData = sRes.ok ? await sRes.json() : null
|
||||||
if (pRes.ok) setProjects(await pRes.json()); else setProjects([])
|
const pData = pRes.ok ? await pRes.json() : []
|
||||||
|
setEntries(Array.isArray(eData) ? eData : [])
|
||||||
|
setSummary(sData?.totals ? sData : null)
|
||||||
|
setProjects(Array.isArray(pData) ? pData : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to load time data")
|
||||||
console.error("Failed to load time data", e)
|
console.error("Failed to load time data", e)
|
||||||
setEntries([])
|
setEntries([])
|
||||||
setProjects([])
|
setProjects([])
|
||||||
@@ -87,7 +99,7 @@ export default function TimeTrackingPage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
projectId: timerProject || null,
|
projectId: timerProject === "none" ? null : timerProject,
|
||||||
description: timerDesc,
|
description: timerDesc,
|
||||||
date: new Date().toISOString().split("T")[0],
|
date: new Date().toISOString().split("T")[0],
|
||||||
startTime: startTimeRef.current?.toISOString(),
|
startTime: startTimeRef.current?.toISOString(),
|
||||||
@@ -98,10 +110,11 @@ export default function TimeTrackingPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
setTimerDesc("")
|
setTimerDesc("")
|
||||||
setTimerProject("")
|
setTimerProject("none")
|
||||||
setTimerSeconds(0)
|
setTimerSeconds(0)
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toast.error("Failed to save time entry")
|
||||||
console.error("Failed to save time entry", e)
|
console.error("Failed to save time entry", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +131,7 @@ export default function TimeTrackingPage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
projectId: manualProject || null,
|
projectId: manualProject === "none" ? null : manualProject,
|
||||||
description: manualDesc,
|
description: manualDesc,
|
||||||
date: manualDate,
|
date: manualDate,
|
||||||
durationMinutes: totalMins,
|
durationMinutes: totalMins,
|
||||||
@@ -132,14 +145,22 @@ export default function TimeTrackingPage() {
|
|||||||
setManualRate("")
|
setManualRate("")
|
||||||
setShowManual(false)
|
setShowManual(false)
|
||||||
fetchData()
|
fetchData()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save time entry", e)
|
||||||
|
toast.error("Failed to save time entry")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteEntry = async (id: string) => {
|
const deleteEntry = async (id: string) => {
|
||||||
await fetch(`/api/time-entries?id=${id}`, { method: "DELETE" })
|
try {
|
||||||
fetchData()
|
await fetch(`/api/time-entries?id=${id}`, { method: "DELETE" })
|
||||||
|
fetchData()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to delete time entry", e)
|
||||||
|
toast.error("Failed to delete time entry")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDuration = (mins: number) => {
|
const formatDuration = (mins: number) => {
|
||||||
@@ -198,10 +219,15 @@ export default function TimeTrackingPage() {
|
|||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
<div className="flex-1 space-y-2">
|
<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} />
|
<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}>
|
<Select value={timerProject} onValueChange={setTimerProject} disabled={timerRunning}>
|
||||||
<option value="">No project</option>
|
<SelectTrigger className="h-9 w-full">
|
||||||
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
<SelectValue placeholder="No project" />
|
||||||
</select>
|
</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>
|
||||||
<div className="text-3xl font-mono font-bold tabular-nums px-4 py-1">{formatTimer(timerSeconds)}</div>
|
<div className="text-3xl font-mono font-bold tabular-nums px-4 py-1">{formatTimer(timerSeconds)}</div>
|
||||||
{!timerRunning ? (
|
{!timerRunning ? (
|
||||||
@@ -224,10 +250,15 @@ export default function TimeTrackingPage() {
|
|||||||
<div className="space-y-3 p-3 rounded-lg border bg-muted/20">
|
<div className="space-y-3 p-3 rounded-lg border bg-muted/20">
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<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" />
|
<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">
|
<Select value={manualProject} onValueChange={setManualProject}>
|
||||||
<option value="">No project</option>
|
<SelectTrigger className="h-8 w-full">
|
||||||
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
<SelectValue placeholder="No project" />
|
||||||
</select>
|
</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>
|
||||||
<div className="flex gap-3 flex-wrap">
|
<div className="flex gap-3 flex-wrap">
|
||||||
<Input type="date" value={manualDate} onChange={(e) => setManualDate(e.target.value)} className="h-8 w-40 text-sm" />
|
<Input type="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()
|
const data = await res.json()
|
||||||
setUsers(data.users || [])
|
setUsers(data.users || [])
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch users in users page")
|
console.error("Failed to fetch users:", err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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 {
|
import {
|
||||||
comparePassword,
|
comparePassword,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
@@ -15,10 +15,7 @@ import {
|
|||||||
import { checkRateLimit } from "@/lib/rate-limit"
|
import { checkRateLimit } from "@/lib/rate-limit"
|
||||||
|
|
||||||
function jsonResponse(data: unknown, status: number, headers?: Record<string, string>) {
|
function jsonResponse(data: unknown, status: number, headers?: Record<string, string>) {
|
||||||
return new Response(JSON.stringify(data), {
|
return NextResponse.json(data, { status, headers })
|
||||||
status,
|
|
||||||
headers: { "Content-Type": "application/json", ...headers },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -132,20 +129,22 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const user = mapDbUserToSessionUser(dbUser)
|
const user = mapDbUserToSessionUser(dbUser)
|
||||||
|
|
||||||
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
|
const isHttps = request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https"
|
||||||
|
const response = NextResponse.json({ user })
|
||||||
return new Response(JSON.stringify({ user }), {
|
response.cookies.set(SESSION_COOKIE, token, {
|
||||||
status: 200,
|
httpOnly: true,
|
||||||
headers: {
|
sameSite: "strict",
|
||||||
"Content-Type": "application/json",
|
path: "/",
|
||||||
"Set-Cookie": cookieStr,
|
maxAge: 86400,
|
||||||
},
|
secure: isHttps,
|
||||||
})
|
})
|
||||||
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login error:", error)
|
console.error("Login error:", error)
|
||||||
return new Response(
|
const message = error instanceof Error ? error.message : "Authentication service unavailable."
|
||||||
JSON.stringify({ error: "Authentication service unavailable." }),
|
return NextResponse.json(
|
||||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
{ error: message },
|
||||||
|
{ status: 503 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ export async function POST() {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Logout error:", error)
|
console.error("Logout error:", error)
|
||||||
|
const message = error instanceof Error ? error.message : "Logout failed."
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Logout failed." }),
|
JSON.stringify({ error: message }),
|
||||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ export async function GET() {
|
|||||||
return NextResponse.json({ user }, { status: 200 })
|
return NextResponse.json({ user }, { status: 200 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auth me error:", error)
|
console.error("Auth me error:", error)
|
||||||
|
const message = error instanceof Error ? error.message : "Authentication service unavailable."
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Authentication service unavailable." },
|
{ error: message },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}, { status: 200 })
|
}, { status: 200 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Password recovery error:", 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 })
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating bug report:", 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 })
|
}, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating bug report:", 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 })
|
return NextResponse.json({ reports }, { status: 200 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching bug reports:", 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) {
|
} catch (error) {
|
||||||
console.error("[voice-delete] Error:", 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 })
|
return NextResponse.json({ messages })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Messages error:", 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) {
|
} catch (error) {
|
||||||
console.error("Send message error:", 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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Delete message error:", 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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Edit message error:", 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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Mark read error:", 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.id,
|
||||||
c.updated_at,
|
c.updated_at,
|
||||||
cp_me.last_read_at,
|
cp_me.last_read_at,
|
||||||
|
cp_me.pinned_at,
|
||||||
u.id AS other_user_id,
|
u.id AS other_user_id,
|
||||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||||
u.email AS other_user_email,
|
u.email AS other_user_email,
|
||||||
@@ -28,7 +29,7 @@ export async function GET() {
|
|||||||
WHERE c.id IN (
|
WHERE c.id IN (
|
||||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
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`,
|
LIMIT 50`,
|
||||||
[user.id],
|
[user.id],
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,7 @@ export async function GET() {
|
|||||||
const conversations = result.rows.map((row: any) => ({
|
const conversations = result.rows.map((row: any) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
pinned: row.pinned_at !== null,
|
||||||
otherUser: {
|
otherUser: {
|
||||||
id: row.other_user_id,
|
id: row.other_user_id,
|
||||||
name: row.other_user_name,
|
name: row.other_user_name,
|
||||||
@@ -50,7 +52,8 @@ export async function GET() {
|
|||||||
return NextResponse.json({ conversations })
|
return NextResponse.json({ conversations })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Conversations error:", 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) {
|
} catch (error) {
|
||||||
console.error("Create conversation error:", 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)
|
return NextResponse.json(result.rows)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Custom fields API error:", 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 })
|
return NextResponse.json({ error: "A field with this key already exists for this entity" }, { status: 409 })
|
||||||
}
|
}
|
||||||
console.error("Custom fields POST error:", error)
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Custom fields PATCH error:", 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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Custom fields DELETE error:", 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)
|
return NextResponse.json(result.rows)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Customers API error:", 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)
|
return NextResponse.json(stats)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Dashboard API error:", 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) {
|
} catch (error) {
|
||||||
console.error("Emails GET error:", 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 })
|
return NextResponse.json({ users })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Event users error:", 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) {
|
} catch (error) {
|
||||||
console.error("ICS GET error:", 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) {
|
} catch (error) {
|
||||||
console.error("Events PATCH error:", 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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Events DELETE error:", 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 })
|
return NextResponse.json({ events })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Events GET error:", 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 })
|
}, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Events POST error:", 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}`
|
const inviteUrl = `${origin}/join/${token}`
|
||||||
|
|
||||||
return NextResponse.json({ token, inviteUrl })
|
return NextResponse.json({ token, inviteUrl })
|
||||||
} catch {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create note error:", 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)
|
return NextResponse.json(notes)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lead notes API error:", 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(
|
const result = await query(
|
||||||
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
`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.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,
|
ls.name AS stage_name,
|
||||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||||
FROM leads l
|
FROM leads l
|
||||||
@@ -61,6 +61,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||||
} : null,
|
} : null,
|
||||||
customFields: r.custom_fields || {},
|
customFields: r.custom_fields || {},
|
||||||
|
mlScore: r.ml_score || 0,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json(lead)
|
return NextResponse.json(lead)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lead detail API error:", 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 })
|
return NextResponse.json({ success: true, id: result.rows[0].id })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lead PATCH error:", 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)
|
return NextResponse.json(columns)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Kanban API error:", 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 offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
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,
|
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||||
ls.name AS stage_name,
|
ls.name AS stage_name,
|
||||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
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,
|
email: r.user_email,
|
||||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||||
} : null,
|
} : null,
|
||||||
|
mlScore: r.ml_score || 0,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
}
|
}
|
||||||
@@ -113,7 +114,8 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json(leads)
|
return NextResponse.json(leads)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Leads API error:", 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 })
|
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Leads POST error:", 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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Leads DELETE error:", 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