Compare commits

...

4 Commits

Author SHA1 Message Date
JCBSComputer 85d6cdfb01 Setup.md Page fixed
Build & Auto-Repair / build (push) Has been cancelled
2026-07-06 09:18:29 +02:00
JCBSComputer 4035f5fad4 Fixed Resources, Added Error Logging/Messages, Fixed Pumkin Spice Background
Build & Auto-Repair / build (push) Has been cancelled
2026-07-06 09:13:45 +02:00
JCBSComputer dbec6c0851 Target Job AI Fixed. Now works and find leads
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 16:09:50 +02:00
JCBSComputer cfc69d47d3 Fixed AI. Optimized. Colour/Theme fixed. Added Forgot Password. Cuba theme — Added to client portal settings. Topbar search. Search, loading states
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 11:46:37 +02:00
44 changed files with 1071 additions and 380 deletions
+3
View File
@@ -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 |
+231 -198
View File
@@ -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,195 +79,209 @@
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;
@@ -274,70 +289,88 @@
background: none !important; background: none !important;
} }
/* ============================================================================ /* ============================================================
Client Portal Overrides — Background for portal pages Client Portal Overrides
============================================================================ */ ============================================================ */
/* Make the outer portal container transparent so body::before background shows */
.dark.theme-pumpkin .min-h-screen.bg-background { .dark.theme-pumpkin .min-h-screen.bg-background {
background: transparent !important; background: transparent !important;
} }
/* Portal sidebar — dark glass */
.dark.theme-pumpkin aside.bg-card.border-r.border-border { .dark.theme-pumpkin aside.bg-card.border-r.border-border {
background: hsl(240, 15%, 3%) !important; background: hsl(25, 35%, 4%) !important;
border-right-color: hsl(270, 25%, 10%) !important; border-right-color: hsl(25, 20%, 14%) !important;
} }
/* Portal sidebar nav items — muted text */
.dark.theme-pumpkin aside .text-muted-foreground { .dark.theme-pumpkin aside .text-muted-foreground {
color: hsl(240, 8%, 50%) !important; color: hsl(30, 10%, 50%) !important;
} }
.dark.theme-pumpkin aside button:hover { .dark.theme-pumpkin aside button:hover {
background: hsla(330, 100%, 50%, 0.08) !important; background: hsla(25, 95%, 50%, 0.08) !important;
color: hsl(0, 0%, 90%) !important; color: hsl(0, 0%, 90%) !important;
} }
/* Portal sidebar active nav item */
.dark.theme-pumpkin aside .bg-primary\/10.text-primary { .dark.theme-pumpkin aside .bg-primary\/10.text-primary {
background: hsl(330, 100%, 50%) !important; background: hsl(25, 95%, 50%) !important;
color: hsl(0, 0%, 100%) !important; 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); box-shadow: 0 0 10px hsla(25, 95%, 50%, 0.3), 0 0 20px hsla(25, 95%, 50%, 0.1);
border-color: hsl(330, 100%, 50%) !important; border-color: hsl(25, 95%, 50%) !important;
} }
.dark.theme-pumpkin aside .bg-primary\/10.text-primary svg { .dark.theme-pumpkin aside .bg-primary\/10.text-primary svg {
color: hsl(0, 0%, 100%) !important; color: hsl(0, 0%, 100%) !important;
} }
/* Portal header — dark glass */
.dark.theme-pumpkin header.bg-card\/80 { .dark.theme-pumpkin header.bg-card\/80 {
background: hsl(240, 15%, 3%) !important; background: hsl(25, 35%, 4%) !important;
border-bottom-color: hsl(270, 25%, 10%) !important; border-bottom-color: hsl(25, 20%, 14%) !important;
} }
/* Portal card backgrounds — semi-transparent glass */
.dark.theme-pumpkin .bg-card { .dark.theme-pumpkin .bg-card {
background: hsla(240, 15%, 7%, 0.65) !important; background: hsla(25, 30%, 7%, 0.65) !important;
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
border-color: hsla(270, 30%, 20%, 0.35) !important; border-color: hsla(25, 20%, 16%, 0.35) !important;
} }
/* Portal borders */
.dark.theme-pumpkin .border-border { .dark.theme-pumpkin .border-border {
border-color: hsl(270, 25%, 12%) !important; border-color: hsl(25, 18%, 14%) !important;
} }
/* Portal input fields */
.dark.theme-pumpkin .bg-muted { .dark.theme-pumpkin .bg-muted {
background: hsl(240, 12%, 9%) !important; background: hsl(25, 15%, 10%) !important;
} }
/* Portal primary buttons */
.dark.theme-pumpkin .bg-primary { .dark.theme-pumpkin .bg-primary {
background: hsl(330, 100%, 50%) !important; background: hsl(25, 95%, 50%) !important;
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2); box-shadow: 0 0 10px hsla(25, 95%, 50%, 0.2);
} }
.dark.theme-pumpkin .bg-primary:hover { .dark.theme-pumpkin .bg-primary:hover {
background: hsl(330, 100%, 55%) !important; background: hsl(25, 95%, 55%) !important;
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4); 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;
} }
@@ -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);
+14 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@
<link rel="icon" href="./playwright-logo.svg" type="image/svg+xml"> <link rel="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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+8 -10
View File
@@ -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"
}, },
+1 -1
View File
@@ -8,6 +8,6 @@
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
"playwright": "^1.48.0" "playwright": "^1.61.1"
} }
} }
+118 -28
View File
@@ -18,8 +18,32 @@ export default function AIAssistantPage() {
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 [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [msgCount, setMsgCount] = useState(0)
const [tokenEstimate, setTokenEstimate] = useState("0")
const aiChatRef = useRef<AIChatHandle | null>(null) 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)
}, []) }, [])
@@ -41,36 +65,96 @@ export default function AIAssistantPage() {
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => { const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true) setSearching(true)
const keyword = job.keywords[0] 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(", ")}.
aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
const controller = new AbortController() IMPORTANT: Return ONLY a valid JSON array. Do NOT use markdown, code blocks, or any formatting. Just raw JSON.
const timeoutId = setTimeout(() => controller.abort(), 360000)
const statusId = setTimeout(() => { Example format:
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...") [{"companyName":"Acme Corp","contactName":"John Smith","email":"john@acme.com","phone":"555-0100","description":"Brief description"}]
}, 45000)
Use realistic company names, names, and emails for the ${job.industry} industry.`
const addMsg = (text: string) => aiChatRef.current?.addAssistantMessage(text)
try { try {
const res = await fetch(`http://localhost:3008/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal }) addMsg(`🔍 Step 1/3: Asking AI to generate leads for **${job.job_title}**...`)
clearTimeout(timeoutId)
clearTimeout(statusId) const res = await fetch("/api/ai/chat", {
const data = await res.json() method: "POST",
if (data.success && data.leads?.length > 0) { headers: { "Content-Type": "application/json" },
const leadsText = data.leads.map((lead: any, i: number) => body: JSON.stringify({ message: prompt }),
`**${i + 1}.** ${lead.author || "Unknown"}\n> ${(lead.content || "").slice(0, 300)}\n> 🔗 ${lead.url || "(no link available)"}` })
).join("\n\n")
aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`) if (!res.ok) {
} else { const data = await res.json().catch(() => ({}))
const reason = data.error || data.flag_reason || "No leads found this time" const statusInfo = `HTTP ${res.status}: ${data.error || res.statusText}`
aiChatRef.current?.addAssistantMessage(`⚠️ ${reason}`) 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
} }
} catch (err: any) {
clearTimeout(timeoutId) const data = await res.json()
clearTimeout(statusId) const raw = data.response || ""
const msg = err.name === "AbortError" if (!raw) {
? "❌ Scraper timed out after 5 minutes. Try again or check the scraper service." addMsg("❌ Step 1/3 failed — AI returned an empty response.")
: `${err instanceof Error ? err.message : "Search failed"}` setSearching(false)
aiChatRef.current?.addAssistantMessage(msg) 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 { } finally {
setSearching(false) setSearching(false)
} }
@@ -89,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 (
@@ -186,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>
+5 -5
View File
@@ -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])
+14 -8
View File
@@ -39,8 +39,11 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
const searchLeads = async (q: string) => { const searchLeads = async (q: string) => {
setLeadSearch(q) setLeadSearch(q)
if (q.length < 2) { setSearchResults([]); return } if (q.length < 2) { setSearchResults([]); return }
const res = await fetch(`/api/leads?search=${encodeURIComponent(q)}&limit=10`) try {
if (res.ok) setSearchResults(await res.json()) 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 () => { const addStep = async () => {
@@ -84,12 +87,15 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
} }
const updateStatus = async (status: string) => { const updateStatus = async (status: string) => {
await fetch(`/api/campaigns/${id}`, { try {
method: "PATCH", const res = await fetch(`/api/campaigns/${id}`, {
headers: { "Content-Type": "application/json" }, method: "PATCH",
body: JSON.stringify({ status }), headers: { "Content-Type": "application/json" },
}) body: JSON.stringify({ status }),
fetchCampaign() })
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 (loading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>
+9 -9
View File
@@ -235,8 +235,8 @@ const formatPreviewContent = (content: string) => {
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread]))) 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)
} }
@@ -297,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()
@@ -320,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)
@@ -799,8 +799,8 @@ const formatPreviewContent = (content: string) => {
prev.map((c) => (c.id === convId ? { ...c, pinned: !pinned } : c)) prev.map((c) => (c.id === convId ? { ...c, pinned: !pinned } : c))
) )
} }
} catch { } catch (err) {
console.warn("Failed to toggle pin") console.error("Failed to toggle pin:", err)
} }
} }
@@ -912,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"
> >
+2 -2
View File
@@ -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)
} }
} }
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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)
} }
+19 -28
View File
@@ -42,33 +42,24 @@ interface Totals {
teamUtilization: number teamUtilization: number
} }
function utilizationColor(pct: number): string { function statusKey(pct: number): string {
if (pct > 100) return "bg-red-500" if (pct > 100) return "overloaded"
if (pct > 90) return "bg-orange-500" if (pct > 90) return "near-capacity"
if (pct > 70) return "bg-yellow-500" if (pct > 70) return "busy"
return "bg-emerald-500" if (pct > 50) return "available"
} return "underutilized"
function utilizationTextColor(pct: number): string {
if (pct > 100) return "text-red-600"
if (pct > 90) return "text-orange-600"
if (pct > 70) return "text-yellow-600"
return "text-emerald-600"
}
function utilizationBgColor(pct: number): string {
if (pct > 100) return "bg-red-50 border-red-200"
if (pct > 90) return "bg-orange-50 border-orange-200"
if (pct > 70) return "bg-yellow-50 border-yellow-200"
return "bg-emerald-50 border-emerald-200"
} }
function statusLabel(pct: number): { label: string; color: string } { function statusLabel(pct: number): { label: string; color: string } {
if (pct > 100) return { label: "Overloaded", color: "bg-red-100 text-red-700 border-red-300" } const key = statusKey(pct)
if (pct > 90) return { label: "Near Capacity", color: "bg-orange-100 text-orange-700 border-orange-300" } const labels: Record<string, string> = {
if (pct > 70) return { label: "Busy", color: "bg-yellow-100 text-yellow-700 border-yellow-300" } overloaded: "Overloaded",
if (pct > 50) return { label: "Available", color: "bg-emerald-100 text-emerald-700 border-emerald-300" } "near-capacity": "Near Capacity",
return { label: "Underutilized", color: "bg-slate-100 text-slate-600 border-slate-300" } busy: "Busy",
available: "Available",
underutilized: "Underutilized",
}
return { label: labels[key], color: `util-badge-${key}` }
} }
export default function ResourcePlanningPage() { export default function ResourcePlanningPage() {
@@ -120,7 +111,7 @@ export default function ResourcePlanningPage() {
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<TrendingUp className="h-4 w-4" /> Utilisation <TrendingUp className="h-4 w-4" /> Utilisation
</div> </div>
<p className={`text-2xl font-bold ${utilizationTextColor(totals?.teamUtilization ?? 0)}`}> <p className={`text-2xl font-bold util-text-${statusKey(totals?.teamUtilization ?? 0)}`}>
{totals?.teamUtilization ?? 0}% {totals?.teamUtilization ?? 0}%
</p> </p>
</Card> </Card>
@@ -157,7 +148,7 @@ export default function ResourcePlanningPage() {
{members.map((m) => { {members.map((m) => {
const status = statusLabel(m.utilization) const status = statusLabel(m.utilization)
return ( return (
<Card key={m.id} className={`p-4 space-y-3 border ${utilizationBgColor(m.utilization)}`}> <Card key={m.id} className={`p-4 space-y-3 border util-card-${statusKey(m.utilization)}`}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
@@ -179,11 +170,11 @@ export default function ResourcePlanningPage() {
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span>Capacity: {m.capacityHours}h</span> <span>Capacity: {m.capacityHours}h</span>
<span>Logged: {m.totalHours}h</span> <span>Logged: {m.totalHours}h</span>
<span className={utilizationTextColor(m.utilization)}>{m.utilization}%</span> <span className={`util-text-${statusKey(m.utilization)}`}>{m.utilization}%</span>
</div> </div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted"> <div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div <div
className={`h-full rounded-full transition-all ${utilizationColor(m.utilization)}`} className={`h-full rounded-full transition-all util-bar-${statusKey(m.utilization)}`}
style={{ width: `${Math.min(m.utilization, 100)}%` }} style={{ width: `${Math.min(m.utilization, 100)}%` }}
/> />
</div> </div>
+2 -2
View File
@@ -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)
} }
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server"
import crypto from "crypto"
import { query } from "@/lib/db"
import { getUserByEmail } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email } = await request.json()
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 })
}
const user = await getUserByEmail(email)
if (!user) {
return NextResponse.json({ message: "If that email exists, a reset link has been generated." })
}
const token = crypto.randomBytes(32).toString("hex")
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await query(
`INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)`,
[user.id, token, expiresAt],
)
const resetUrl = `${request.nextUrl.origin}/reset-password/${token}`
return NextResponse.json({ message: "If that email exists, a reset link has been generated.", resetUrl })
} catch (error) {
console.error("Forgot password error:", error)
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { token, password } = await request.json()
if (!token || !password) {
return NextResponse.json({ error: "Token and password are required" }, { status: 400 })
}
if (password.length < 6) {
return NextResponse.json({ error: "Password must be at least 6 characters" }, { status: 400 })
}
const result = await query(
`SELECT id, user_id, expires_at FROM password_reset_tokens
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
[token],
)
const row = result.rows[0]
if (!row) {
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 })
}
const passwordHash = await hashPassword(password)
await query(`UPDATE users SET password_hash = $1, password_change_required = FALSE WHERE id = $2`, [passwordHash, row.user_id])
await query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id])
return NextResponse.json({ message: "Password has been reset successfully." })
} catch (error) {
console.error("Reset password error:", error)
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const currentUser = await getSessionUser()
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const q = request.nextUrl.searchParams.get("q") || ""
if (!q.trim()) return NextResponse.json({ results: [] })
const like = `%${q}%`
const [leadsResult, projectsResult, proposalsResult, conversationsResult] = await Promise.all([
query(
`SELECT id, company_name AS title, 'lead' AS type FROM leads WHERE deleted_at IS NULL AND company_name ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, name AS title, 'project' AS type FROM projects WHERE deleted_at IS NULL AND name ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, title, 'proposal' AS type FROM proposals WHERE deleted_at IS NULL AND title ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, title, 'conversation' AS type FROM conversations WHERE title ILIKE $1 LIMIT 5`,
[like],
),
])
const results = [
...leadsResult.rows,
...projectsResult.rows,
...proposalsResult.rows,
...conversationsResult.rows,
]
return NextResponse.json({ results })
} catch (error) {
console.error("Search error:", error)
return NextResponse.json({ error: "Search failed" }, { status: 500 })
}
}
+15 -1
View File
@@ -22,12 +22,14 @@ interface Event {
export default function ClientActivity() { export default function ClientActivity() {
const router = useRouter() const router = useRouter()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch("/client-portal/api/activity", { credentials: "include" }) fetch("/client-portal/api/activity", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setEvents(d.events || [])) .then((d) => setEvents(d.events || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch activity:", err))
.finally(() => setLoading(false))
}, []) }, [])
const getIcon = (type: string) => { const getIcon = (type: string) => {
@@ -57,6 +59,18 @@ export default function ClientActivity() {
return date.toLocaleDateString([], { month: "short", day: "numeric" }) return date.toLocaleDateString([], { month: "short", day: "numeric" })
} }
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
<p className="text-sm text-muted-foreground mb-6">Recent project updates and changes</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return ( return (
<div> <div>
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1> <h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
+31 -20
View File
@@ -14,6 +14,7 @@ interface Deadline {
export default function ClientDashboard() { export default function ClientDashboard() {
const router = useRouter() const router = useRouter()
const [loading, setLoading] = useState(true)
const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 }) const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 })
const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] }) const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] })
const [name, setName] = useState("") const [name, setName] = useState("")
@@ -21,30 +22,32 @@ export default function ClientDashboard() {
const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject() const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject()
useEffect(() => { useEffect(() => {
fetch("/client-portal/api/projects", { credentials: "include" }) Promise.allSettled([
.then(safeJson) fetch("/client-portal/api/projects", { credentials: "include" })
.then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 }))) .then(safeJson)
.catch(() => {}) .then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 })))
.catch((err) => console.error("Failed to fetch projects:", err)),
fetch("/client-portal/api/invoices", { credentials: "include" }) fetch("/client-portal/api/invoices", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 }))) .then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 })))
.catch(() => {}) .catch((err) => console.error("Failed to fetch invoices:", err)),
fetch("/client-portal/api/support", { credentials: "include" }) fetch("/client-portal/api/support", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 }))) .then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 })))
.catch(() => {}) .catch((err) => console.error("Failed to fetch tickets:", err)),
fetch("/client-portal/api/dashboard/stats", { credentials: "include" }) fetch("/client-portal/api/dashboard/stats", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setStats(d)) .then((d) => setStats(d))
.catch(() => {}) .catch((err) => console.error("Failed to fetch stats:", err)),
fetch("/api/auth/me", { credentials: "include" }) fetch("/api/auth/me", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setName(d.user?.firstName || d.user?.email || "Client")) .then((d) => setName(d.user?.firstName || d.user?.email || "Client"))
.catch(() => {}) .catch((err) => console.error("Failed to fetch user:", err)),
]).finally(() => setLoading(false))
}, []) }, [])
const cards = [ const cards = [
@@ -58,6 +61,14 @@ export default function ClientDashboard() {
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
} }
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
return ( return (
<div> <div>
<h1 className="text-xl font-bold text-foreground mb-1">Welcome, {name}</h1> <h1 className="text-xl font-bold text-foreground mb-1">Welcome, {name}</h1>
+1 -10
View File
@@ -12,7 +12,6 @@ export default function ClientLoginPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [checking, setChecking] = useState(true) const [checking, setChecking] = useState(true)
const [debugInfo, setDebugInfo] = useState("")
const [sessionUser, setSessionUser] = useState<{ email: string; role: string } | null>(null) const [sessionUser, setSessionUser] = useState<{ email: string; role: string } | null>(null)
@@ -38,7 +37,6 @@ export default function ClientLoginPage() {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError("") setError("")
setDebugInfo("")
try { try {
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
@@ -48,27 +46,21 @@ export default function ClientLoginPage() {
}) })
const data = await res.json() const data = await res.json()
console.log("LOGIN RESPONSE:", res.status, data)
if (!res.ok) { if (!res.ok) {
setError(data.error || "Login failed") setError(data.error || "Login failed")
setDebugInfo(`HTTP ${res.status}`)
return return
} }
if (data.user?.role !== "client") { if (data.user?.role !== "client") {
setError("This portal is for clients only") setError("This portal is for clients only")
setDebugInfo(`User role: ${data.user?.role}`)
return return
} }
setError("Login successful! Redirecting...") setError("Login successful! Redirecting...")
setDebugInfo("")
setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300) setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300)
} catch (err) { } catch {
console.error("LOGIN FETCH ERROR:", err)
setError("Connection error") setError("Connection error")
setDebugInfo("Check network tab for details")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -147,7 +139,6 @@ export default function ClientLoginPage() {
{error !== "Login successful! Redirecting..." && <AlertCircle className="h-4 w-4 shrink-0" />} {error !== "Login successful! Redirecting..." && <AlertCircle className="h-4 w-4 shrink-0" />}
<span className="font-medium">{error}</span> <span className="font-medium">{error}</span>
</div> </div>
{debugInfo && <div className="mt-1 text-xs opacity-60">{debugInfo}</div>}
</div> </div>
)} )}
+1 -1
View File
@@ -40,7 +40,7 @@ export default function ClientProfile() {
setProfile(d.profile || null) setProfile(d.profile || null)
setContacts(d.contacts || []) setContacts(d.contacts || [])
}) })
.catch(() => {}) .catch((err) => console.error("Failed to fetch profile:", err))
}, []) }, [])
if (!profile) { if (!profile) {
+15 -1
View File
@@ -18,14 +18,28 @@ interface Project {
export default function ClientProjects() { export default function ClientProjects() {
const router = useRouter() const router = useRouter()
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch("/client-portal/api/projects", { credentials: "include" }) fetch("/client-portal/api/projects", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setProjects(d.projects || [])) .then((d) => setProjects(d.projects || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch projects:", err))
.finally(() => setLoading(false))
}, []) }, [])
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
<p className="text-sm text-muted-foreground mb-6">View your project milestones and progress</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return ( return (
<div> <div>
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1> <h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
+15 -1
View File
@@ -15,14 +15,28 @@ interface Receipt {
export default function ClientReceipts() { export default function ClientReceipts() {
const [receipts, setReceipts] = useState<Receipt[]>([]) const [receipts, setReceipts] = useState<Receipt[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch("/client-portal/api/receipts", { credentials: "include" }) fetch("/client-portal/api/receipts", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setReceipts(d.receipts || [])) .then((d) => setReceipts(d.receipts || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch receipts:", err))
.finally(() => setLoading(false))
}, []) }, [])
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
<p className="text-sm text-muted-foreground mb-6">View all your payment receipts in one place</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return ( return (
<div> <div>
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1> <h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
+1
View File
@@ -36,6 +36,7 @@ const bgOptions = [
{ value: "bw", label: "Black & White", color: "bg-gradient-to-r from-gray-900 to-gray-400", ring: "ring-gray-500", desc: "Clean monochrome" }, { value: "bw", label: "Black & White", color: "bg-gradient-to-r from-gray-900 to-gray-400", ring: "ring-gray-500", desc: "Clean monochrome" },
{ value: "forest", label: "Forest", color: "bg-gradient-to-r from-green-700 to-green-400", ring: "ring-green-600", desc: "Earthy greens with tree silhouettes" }, { value: "forest", label: "Forest", color: "bg-gradient-to-r from-green-700 to-green-400", ring: "ring-green-600", desc: "Earthy greens with tree silhouettes" },
{ value: "luminous", label: "Luminous", color: "bg-gradient-to-r from-blue-500 to-teal-400", ring: "ring-blue-500", desc: "Deep Glass 2.0 with specular highlights" }, { value: "luminous", label: "Luminous", color: "bg-gradient-to-r from-blue-500 to-teal-400", ring: "ring-blue-500", desc: "Deep Glass 2.0 with specular highlights" },
{ value: "cuba", label: "Cuba", color: "bg-gradient-to-r from-orange-500 to-turquoise-400", ring: "ring-orange-500", desc: "Classic Cuban car on a playa at golden hour" },
] ]
function getStoredColorTheme(): string { function getStoredColorTheme(): string {
+1 -1
View File
@@ -35,7 +35,7 @@ export default function ClientSupport() {
fetch("/client-portal/api/support", { credentials: "include" }) fetch("/client-portal/api/support", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setTickets(d.tickets || [])) .then((d) => setTickets(d.tickets || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch tickets:", err))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
+110
View File
@@ -0,0 +1,110 @@
"use client"
import { useState } from "react"
import { Mail, ArrowLeft, CheckCircle, AlertCircle, Loader2 } from "lucide-react"
import Link from "next/link"
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [sent, setSent] = useState(false)
const [resetUrl, setResetUrl] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Something went wrong")
return
}
setSent(true)
if (data.resetUrl) setResetUrl(data.resetUrl)
} catch {
setError("Connection error")
} finally {
setLoading(false)
}
}
if (sent) {
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm text-center">
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
<h1 className="text-xl font-bold text-foreground mb-2">Check your email</h1>
<p className="text-sm text-muted-foreground mb-6">
If an account with that email exists, we&apos;ve sent a password reset link.
</p>
{resetUrl && (
<div className="bg-card border border-border rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-muted-foreground mb-2">Development mode reset link:</p>
<a href={resetUrl} className="text-sm text-primary break-all hover:underline">{resetUrl}</a>
</div>
)}
<Link href="/login" className="text-sm text-primary hover:underline">
Back to login
</Link>
</div>
</div>
)
}
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm">
<Link href="/login" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors">
<ArrowLeft className="h-4 w-4" />
Back to login
</Link>
<h1 className="text-xl font-bold text-foreground mb-2">Reset your password</h1>
<p className="text-sm text-muted-foreground mb-6">
Enter your email and we&apos;ll send you a reset link.
</p>
{error && (
<div className="mb-4 flex items-center gap-2 text-sm text-destructive bg-destructive/5 border border-destructive/10 rounded-lg px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1.5">Email</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-muted border border-border rounded-lg pl-10 pr-3 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="your@email.com"
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40 flex items-center justify-center gap-2"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Sending..." : "Send reset link"}
</button>
</form>
</div>
</div>
)
}
+54
View File
@@ -758,4 +758,58 @@ main {
opacity: 1 !important; opacity: 1 !important;
} }
/* ============================================================
Utilisation status card backgrounds (theme-aware)
============================================================ */
.util-card-overloaded {
background-color: color-mix(in srgb, hsl(0 80% 50% / 0.1), hsl(var(--card))) !important;
border-color: hsl(0 70% 50% / 0.3) !important;
}
.util-card-near-capacity {
background-color: color-mix(in srgb, hsl(25 95% 50% / 0.1), hsl(var(--card))) !important;
border-color: hsl(25 85% 45% / 0.3) !important;
}
.util-card-busy {
background-color: color-mix(in srgb, hsl(45 90% 50% / 0.08), hsl(var(--card))) !important;
border-color: hsl(45 80% 45% / 0.25) !important;
}
.util-card-available {
background-color: color-mix(in srgb, hsl(150 65% 40% / 0.08), hsl(var(--card))) !important;
border-color: hsl(150 60% 40% / 0.25) !important;
}
.util-card-underutilized {
/* Default card styling — no tint needed */
}
/* Utilisation status text */
.util-text-overloaded { @apply text-red-600 dark:text-red-400; }
.util-text-near-capacity { @apply text-orange-600 dark:text-orange-400; }
.util-text-busy { @apply text-yellow-600 dark:text-yellow-400; }
.util-text-available { @apply text-emerald-600 dark:text-emerald-400; }
.util-text-underutilized { @apply text-slate-600 dark:text-slate-400; }
/* Utilisation status badge */
.util-badge-overloaded {
@apply bg-red-100 text-red-700 border-red-300 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800;
}
.util-badge-near-capacity {
@apply bg-orange-100 text-orange-700 border-orange-300 dark:bg-orange-900/30 dark:text-orange-300 dark:border-orange-800;
}
.util-badge-busy {
@apply bg-yellow-100 text-yellow-700 border-yellow-300 dark:bg-yellow-900/25 dark:text-yellow-300 dark:border-yellow-800;
}
.util-badge-available {
@apply bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-900/25 dark:text-emerald-300 dark:border-emerald-800;
}
.util-badge-underutilized {
@apply bg-slate-100 text-slate-600 border-slate-300 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700;
}
/* Utilisation bar fill */
.util-bar-overloaded { @apply bg-red-500; }
.util-bar-near-capacity { @apply bg-orange-500; }
.util-bar-busy { @apply bg-yellow-500; }
.util-bar-available { @apply bg-emerald-500; }
.util-bar-underutilized { @apply bg-slate-400 dark:bg-slate-500; }
+3 -2
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, Suspense } from "react" import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation" import { useSearchParams, useRouter } from "next/navigation"
import Link from "next/link"
import { Eye, EyeOff, Loader2 } from "lucide-react" import { Eye, EyeOff, Loader2 } from "lucide-react"
const WEBSITE_THEME_KEY = "crm-website-theme" const WEBSITE_THEME_KEY = "crm-website-theme"
@@ -384,9 +385,9 @@ function LoginForm() {
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}> <label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
Password Password
</label> </label>
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}> <Link href="/forgot-password" className="text-[12px] text-[#1BB0CE] hover:underline">
Forgot password? Forgot password?
</button> </Link>
</div> </div>
<div className="input-sheen input-sheen-delayed"> <div className="input-sheen input-sheen-delayed">
<div className="relative"> <div className="relative">
+138
View File
@@ -0,0 +1,138 @@
"use client"
import { useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { Eye, EyeOff, AlertCircle, CheckCircle, Loader2 } from "lucide-react"
import Link from "next/link"
export default function ResetPasswordPage() {
const { token } = useParams<{ token: string }>()
const router = useRouter()
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [done, setDone] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
return
}
setLoading(true)
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Something went wrong")
return
}
setDone(true)
} catch {
setError("Connection error")
} finally {
setLoading(false)
}
}
if (done) {
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm text-center">
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
<h1 className="text-xl font-bold text-foreground mb-2">Password reset</h1>
<p className="text-sm text-muted-foreground mb-6">
Your password has been reset successfully.
</p>
<Link
href="/login"
className="inline-block bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-6 py-2.5 text-sm font-medium transition-all"
>
Sign in
</Link>
</div>
</div>
)
}
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm">
<h1 className="text-xl font-bold text-foreground mb-2">Set new password</h1>
<p className="text-sm text-muted-foreground mb-6">Enter your new password below.</p>
{error && (
<div className="mb-4 flex items-center gap-2 text-sm text-destructive bg-destructive/5 border border-destructive/10 rounded-lg px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1.5">New password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-muted border border-border rounded-lg px-3 py-2.5 pr-10 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="Min 6 characters"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1.5">Confirm password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-muted border border-border rounded-lg px-3 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="Repeat password"
required
minLength={6}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40 flex items-center justify-center gap-2"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Resetting..." : "Reset password"}
</button>
</form>
<p className="text-center mt-6">
<Link href="/login" className="text-sm text-primary hover:underline">
Back to login
</Link>
</p>
</div>
</div>
)
}
+1 -1
View File
@@ -79,7 +79,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
className="w-full flex items-center justify-center gap-2 mt-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed text-primary-foreground text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-primary/20" className="w-full flex items-center justify-center gap-2 mt-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed text-primary-foreground text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-primary/20"
> >
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />} {searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{searching ? "Searching..." : "Search Facebook"} {searching ? "Searching..." : "Search Leads"}
</button> </button>
)} )}
</div> </div>
+87 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -31,6 +31,11 @@ import {
Bug, Bug,
CheckCheck, CheckCheck,
Trash2, Trash2,
Building2,
FolderKanban,
FileText,
MessageSquare,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { useWebsiteTheme } from "@/providers/website-theme-provider"; import { useWebsiteTheme } from "@/providers/website-theme-provider";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield"; import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
@@ -48,11 +53,60 @@ export function Topbar({ onMenuClick }: TopbarProps) {
useNotifications(); useNotifications();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<{ id: number; title: string; type: string }[]>([]);
const [searching, setSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
const [bugModalOpen, setBugModalOpen] = useState(false); const [bugModalOpen, setBugModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); setSearching(false); return }
setSearching(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
const data = await res.json()
setResults(data.results || [])
} catch { setResults([]) }
setSearching(false)
}, []);
const handleSearchInput = useCallback((value: string) => {
setQuery(value)
setShowResults(true)
if (searchTimer.current) clearTimeout(searchTimer.current)
if (!value.trim()) { setResults([]); return }
searchTimer.current = setTimeout(() => doSearch(value), 250)
}, [doSearch]);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") setShowResults(false)
}, []);
const navigateTo = useCallback((type: string, id: number) => {
setShowResults(false)
setQuery("")
setResults([])
const paths: Record<string, string> = { lead: `/leads/${id}`, project: `/projects/${id}`, proposal: `/proposals/${id}`, conversation: `/conversations/${id}` }
router.push(paths[type] || "/")
}, [router]);
const typeIcons: Record<string, typeof Building2> = { lead: Building2, project: FolderKanban, proposal: FileText, conversation: MessageSquare }
if (!user) return null; if (!user) return null;
function formatTimeAgo(ts: string): string { function formatTimeAgo(ts: string): string {
@@ -93,12 +147,43 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</Button> </Button>
{/* Search */} {/* Search */}
<div className="relative hidden flex-1 sm:block md:w-80"> <div className="relative hidden flex-1 sm:block md:w-80" ref={searchRef}>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={query}
onChange={(e) => handleSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown}
onFocus={() => query.trim() && setShowResults(true)}
placeholder="Search leads, companies..." placeholder="Search leads, companies..."
className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground" className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground"
/> />
{showResults && (results.length > 0 || searching) && (
<div className="absolute left-0 right-0 top-full mt-1 bg-card border border-border rounded-xl shadow-xl overflow-hidden z-50">
{searching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching...
</div>
) : (
results.map((r) => {
const Icon = typeIcons[r.type] || Building2
return (
<button
key={`${r.type}-${r.id}`}
onClick={() => navigateTo(r.type, r.id)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/10 text-primary">
<Icon className="h-3.5 w-3.5" />
</div>
<span className="font-medium text-foreground truncate">{r.title}</span>
<span className="ml-auto text-[10px] uppercase tracking-wider text-muted-foreground/60">{r.type}</span>
</button>
)
})
)}
</div>
)}
</div> </div>
<div className="flex flex-1 items-center justify-end gap-3"> <div className="flex flex-1 items-center justify-end gap-3">