sitcod3 commited on
Commit
6248cc8
·
verified ·
1 Parent(s): 1137e8f

Delete files src\App.tsx src\components\AnimatedLogo.test.tsx src\components\AnimatedLogo.tsx src\components\BackgroundElements.tsx src\components\ConfirmModal.tsx src\components\CsvExportButton.test.tsx src\components\CsvExportButton.tsx src\components\FormDrawer.tsx src\components\GeometricBackground.test.tsx src\components\GeometricBackground.tsx src\components\GlassCard.tsx src\components\Logo.tsx src\components\RichTextEditor.tsx src\components\Sidebar.tsx src\components\StatCard.tsx src\components\StatusBadge.test.tsx src\components\StatusBadge.tsx src\components\TopBar.tsx src\context\ApiKeyContext.tsx src\index.css src\layouts\AppLayout.tsx src\lib\aiService.ts src\lib\apiClient.ts src\lib\supabaseClient.ts src\main.tsx src\pages\Analytics.test.tsx src\pages\Analytics.tsx src\pages\CampaignDetail.tsx src\pages\Campaigns.tsx src\pages\Clients.tsx src\pages\Dashboard.tsx src\pages\EmailAccounts.tsx src\pages\Gate.tsx src\pages\Inbox.tsx src\pages\Leads.tsx src\pages\Settings.tsx src\pages\Warmup.tsx src\pages\Webhooks.tsx src\pages\gate.css src\server\middleware\auth.ts src\utils\cn.ts src\vite-env.d.ts with huggingface_hub

Browse files
Files changed (42) hide show
  1. src//App.tsx +0 -82
  2. src//components//AnimatedLogo.test.tsx +0 -16
  3. src//components//AnimatedLogo.tsx +0 -69
  4. src//components//BackgroundElements.tsx +0 -18
  5. src//components//ConfirmModal.tsx +0 -113
  6. src//components//CsvExportButton.test.tsx +0 -11
  7. src//components//CsvExportButton.tsx +0 -47
  8. src//components//FormDrawer.tsx +0 -78
  9. src//components//GeometricBackground.test.tsx +0 -12
  10. src//components//GeometricBackground.tsx +0 -62
  11. src//components//GlassCard.tsx +0 -15
  12. src//components//Logo.tsx +0 -14
  13. src//components//RichTextEditor.tsx +0 -152
  14. src//components//Sidebar.tsx +0 -66
  15. src//components//StatCard.tsx +0 -149
  16. src//components//StatusBadge.test.tsx +0 -22
  17. src//components//StatusBadge.tsx +0 -83
  18. src//components//TopBar.tsx +0 -64
  19. src//context//ApiKeyContext.tsx +0 -93
  20. src//index.css +0 -522
  21. src//layouts//AppLayout.tsx +0 -37
  22. src//lib//aiService.ts +0 -45
  23. src//lib//apiClient.ts +0 -25
  24. src//lib//supabaseClient.ts +0 -27
  25. src//main.tsx +0 -10
  26. src//pages//Analytics.test.tsx +0 -83
  27. src//pages//Analytics.tsx +0 -247
  28. src//pages//CampaignDetail.tsx +0 -727
  29. src//pages//Campaigns.tsx +0 -441
  30. src//pages//Clients.tsx +0 -214
  31. src//pages//Dashboard.tsx +0 -551
  32. src//pages//EmailAccounts.tsx +0 -393
  33. src//pages//Gate.tsx +0 -262
  34. src//pages//Inbox.tsx +0 -307
  35. src//pages//Leads.tsx +0 -622
  36. src//pages//Settings.tsx +0 -434
  37. src//pages//Warmup.tsx +0 -266
  38. src//pages//Webhooks.tsx +0 -316
  39. src//pages//gate.css +0 -420
  40. src//server//middleware//auth.ts +0 -144
  41. src//utils//cn.ts +0 -6
  42. src//vite-env.d.ts +0 -1
src//App.tsx DELETED
@@ -1,82 +0,0 @@
1
- import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
2
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
- import { Toaster } from "react-hot-toast";
4
- import { ReactNode } from "react";
5
- import { ApiKeyProvider, useApiKey } from "./context/ApiKeyContext";
6
- import AppLayout from "./layouts/AppLayout";
7
- import Gate from "./pages/Gate";
8
-
9
- // All Real Pages
10
- import Dashboard from "./pages/Dashboard";
11
- import Campaigns from "./pages/Campaigns";
12
- import CampaignDetail from "./pages/CampaignDetail";
13
- import Leads from "./pages/Leads";
14
- import EmailAccounts from "./pages/EmailAccounts";
15
- import Warmup from "./pages/Warmup";
16
- import Inbox from "./pages/Inbox";
17
- import Webhooks from "./pages/Webhooks";
18
- import Analytics from "./pages/Analytics";
19
- import Clients from "./pages/Clients";
20
- import Settings from "./pages/Settings";
21
-
22
- const queryClient = new QueryClient({
23
- defaultOptions: {
24
- queries: {
25
- staleTime: 30_000,
26
- retry: 2,
27
- refetchOnWindowFocus: true,
28
- },
29
- },
30
- });
31
-
32
- function RequireKey({ children }: { children: ReactNode }) {
33
- const { activeKey } = useApiKey();
34
- if (!activeKey) {
35
- return <Navigate to="/connect" replace />;
36
- }
37
- return <>{children}</>;
38
- }
39
-
40
- export default function App() {
41
- return (
42
- <QueryClientProvider client={queryClient}>
43
- <ApiKeyProvider>
44
- <BrowserRouter>
45
- <Routes>
46
- <Route path="/connect" element={<Gate />} />
47
- <Route
48
- path="/"
49
- element={
50
- <RequireKey>
51
- <AppLayout />
52
- </RequireKey>
53
- }
54
- >
55
- <Route index element={<Dashboard />} />
56
- <Route path="campaigns" element={<Campaigns />} />
57
- <Route path="campaigns/:id" element={<CampaignDetail />} />
58
- <Route path="leads" element={<Leads />} />
59
- <Route path="email-accounts" element={<EmailAccounts />} />
60
- <Route path="warmup" element={<Warmup />} />
61
- <Route path="inbox" element={<Inbox />} />
62
- <Route path="webhooks" element={<Webhooks />} />
63
- <Route path="analytics" element={<Analytics />} />
64
- <Route path="clients" element={<Clients />} />
65
- <Route path="settings" element={<Settings />} />
66
- </Route>
67
- </Routes>
68
- </BrowserRouter>
69
- <Toaster
70
- position="bottom-right"
71
- toastOptions={{
72
- style: {
73
- background: 'var(--nt-bg-surface-raised)',
74
- color: 'var(--nt-text-primary)',
75
- border: '1px solid var(--nt-border-accent)'
76
- }
77
- }}
78
- />
79
- </ApiKeyProvider>
80
- </QueryClientProvider>
81
- );
82
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//AnimatedLogo.test.tsx DELETED
@@ -1,16 +0,0 @@
1
- import { render } from '@testing-library/react';
2
- import { describe, it, expect } from 'vitest';
3
- import AnimatedLogo from './AnimatedLogo';
4
-
5
- describe('AnimatedLogo', () => {
6
- it('renders SVG elements successfully', () => {
7
- const { container } = render(<AnimatedLogo className="test-class" />);
8
- const svg = container.querySelector('svg');
9
- expect(svg).toBeDefined();
10
- expect(svg?.classList.contains('test-class')).toBe(true);
11
-
12
- // Check for gradients and paths
13
- expect(container.querySelector('linearGradient#crypto-grad-1')).toBeDefined();
14
- expect(container.querySelector('path')).toBeDefined();
15
- });
16
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//AnimatedLogo.tsx DELETED
@@ -1,69 +0,0 @@
1
- import { motion } from "motion/react";
2
-
3
- export default function AnimatedLogo({ className = "w-8 h-8" }: { className?: string }) {
4
- return (
5
- <motion.svg
6
- className={className}
7
- viewBox="0 0 100 100"
8
- fill="none"
9
- xmlns="http://www.w3.org/2000/svg"
10
- initial={{ opacity: 0, y: 10 }}
11
- animate={{ opacity: 1, y: 0 }}
12
- transition={{ duration: 0.8, ease: [0.32, 0.72, 0, 1] }}
13
- >
14
- <defs>
15
- <linearGradient id="crypto-grad-1" x1="0%" y1="0%" x2="100%" y2="100%">
16
- <stop offset="0%" stopColor="var(--nt-deep-teal-600)" /> {/* Verdigris 600 */}
17
- <stop offset="100%" stopColor="var(--nt-terracotta-600)" /> {/* Kiln Ember 600 */}
18
- </linearGradient>
19
- <linearGradient id="crypto-grad-2" x1="100%" y1="0%" x2="0%" y2="100%">
20
- <stop offset="0%" stopColor="var(--nt-deep-teal-700)" /> {/* Verdigris 700 */}
21
- <stop offset="100%" stopColor="var(--nt-terracotta-700)" /> {/* Kiln Ember 700 */}
22
- </linearGradient>
23
- <filter id="neon-glow" x="-20%" y="-20%" width="140%" height="140%">
24
- <feGaussianBlur stdDeviation="4" result="blur" />
25
- <feComposite in="SourceGraphic" in2="blur" operator="over" />
26
- </filter>
27
- </defs>
28
-
29
- {/* Background glowing element */}
30
- <motion.polygon
31
- points="50,5 90,27.5 90,72.5 50,95 10,72.5 10,27.5"
32
- fill="url(#crypto-grad-1)"
33
- fillOpacity="0.1"
34
- stroke="url(#crypto-grad-2)"
35
- strokeWidth="2"
36
- filter="url(#neon-glow)"
37
- initial={{ scale: 0.8, opacity: 0, rotate: -30 }}
38
- animate={{ scale: 1, opacity: 1, rotate: 0 }}
39
- transition={{ duration: 1.2, ease: [0.32, 0.72, 0, 1] }}
40
- />
41
-
42
- {/* Abstract Sharp 'S' inside */}
43
- <motion.path
44
- d="M 65 35 L 35 35 L 35 50 L 65 50 L 65 65 L 35 65"
45
- stroke="url(#crypto-grad-2)"
46
- strokeWidth="10"
47
- strokeLinecap="square"
48
- strokeLinejoin="miter"
49
- initial={{ pathLength: 0 }}
50
- animate={{ pathLength: 1 }}
51
- transition={{ duration: 1.5, ease: [0.32, 0.72, 0, 1], delay: 0.4 }}
52
- />
53
-
54
- {/* Accent bridging line */}
55
- <motion.line
56
- x1="20"
57
- y1="50"
58
- x2="80"
59
- y2="50"
60
- stroke="url(#crypto-grad-1)"
61
- strokeWidth="4"
62
- filter="url(#neon-glow)"
63
- initial={{ scaleX: 0, opacity: 0 }}
64
- animate={{ scaleX: 1, opacity: 1 }}
65
- transition={{ duration: 1, ease: [0.32, 0.72, 0, 1], delay: 1 }}
66
- />
67
- </motion.svg>
68
- );
69
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//BackgroundElements.tsx DELETED
@@ -1,18 +0,0 @@
1
- import { motion } from 'motion/react';
2
-
3
- export default function BackgroundElements() {
4
- return (
5
- <div className="fixed inset-0 pointer-events-none -z-10 overflow-hidden">
6
- <motion.div
7
- animate={{ y: [0, 50, 0], opacity: [0.1, 0.3, 0.1] }}
8
- transition={{ duration: 10, repeat: Infinity, ease: [0.32, 0.72, 0, 1] }}
9
- className="absolute top-10 left-20 w-64 h-64 border border-white/5 rounded-full"
10
- />
11
- <motion.div
12
- animate={{ y: [0, -30, 0], opacity: [0.05, 0.2, 0.05] }}
13
- transition={{ duration: 15, repeat: Infinity, ease: [0.32, 0.72, 0, 1] }}
14
- className="absolute bottom-20 right-40 w-96 h-96 border border-white/5 rounded-full"
15
- />
16
- </div>
17
- );
18
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//ConfirmModal.tsx DELETED
@@ -1,113 +0,0 @@
1
- import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
2
- import { Fragment, ReactNode } from "react";
3
- import { AlertTriangle, X } from "lucide-react";
4
-
5
- interface ConfirmModalProps {
6
- isOpen: boolean;
7
- onClose: () => void;
8
- onConfirm: () => void;
9
- title: string;
10
- description: string | ReactNode;
11
- confirmText?: string;
12
- cancelText?: string;
13
- isDestructive?: boolean;
14
- isLoading?: boolean;
15
- }
16
-
17
- export default function ConfirmModal({
18
- isOpen,
19
- onClose,
20
- onConfirm,
21
- title,
22
- description,
23
- confirmText = "Confirm",
24
- cancelText = "Cancel",
25
- isDestructive = true,
26
- isLoading = false,
27
- }: ConfirmModalProps) {
28
- return (
29
- <Transition appear show={isOpen} as={Fragment}>
30
- <Dialog as="div" className="relative z-50" onClose={onClose}>
31
- <TransitionChild
32
- as={Fragment}
33
- enter="ease-out duration-200"
34
- enterFrom="opacity-0"
35
- enterTo="opacity-100"
36
- leave="ease-in duration-150"
37
- leaveFrom="opacity-100"
38
- leaveTo="opacity-0"
39
- >
40
- <div className="fixed inset-0 bg-black/70 backdrop-blur-xs" />
41
- </TransitionChild>
42
-
43
- <div className="fixed inset-0 overflow-y-auto">
44
- <div className="flex min-h-full items-center justify-center p-4 text-center">
45
- <TransitionChild
46
- as={Fragment}
47
- enter="ease-out duration-200"
48
- enterFrom="opacity-0 scale-95"
49
- enterTo="opacity-100 scale-100"
50
- leave="ease-in duration-150"
51
- leaveFrom="opacity-100 scale-100"
52
- leaveTo="opacity-0 scale-95"
53
- >
54
- <DialogPanel className="w-full max-w-md transform overflow-hidden rounded-xl bg-zinc-900 border border-zinc-800 p-6 text-left align-middle shadow-2xl transition-all">
55
- <div className="flex items-start gap-4">
56
- <div
57
- className={`p-3 rounded-full shrink-0 ${
58
- isDestructive ? "bg-rose-500/10 text-rose-400 border border-rose-500/20" : "bg-indigo-500/10 text-indigo-400 border border-indigo-500/20"
59
- }`}
60
- >
61
- <AlertTriangle className="w-6 h-6" />
62
- </div>
63
- <div className="flex-1 min-w-0">
64
- <DialogTitle as="h3" className="text-lg font-semibold text-zinc-100">
65
- {title}
66
- </DialogTitle>
67
- <div className="mt-2 text-sm text-zinc-400">
68
- {description}
69
- </div>
70
- </div>
71
- <button
72
- onClick={onClose}
73
- className="text-zinc-500 hover:text-zinc-300 transition-colors p-1 rounded-md"
74
- >
75
- <X className="w-5 h-5" />
76
- </button>
77
- </div>
78
-
79
- <div className="mt-6 flex justify-end gap-3">
80
- <button
81
- type="button"
82
- onClick={onClose}
83
- disabled={isLoading}
84
- className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm font-medium rounded-lg transition-colors"
85
- >
86
- {cancelText}
87
- </button>
88
- <button
89
- type="button"
90
- onClick={() => {
91
- onConfirm();
92
- }}
93
- disabled={isLoading}
94
- className={`px-4 py-2 text-sm font-medium rounded-lg text-white transition-colors flex items-center gap-2 ${
95
- isDestructive
96
- ? "bg-rose-600 hover:bg-rose-500"
97
- : "bg-indigo-600 hover:bg-indigo-500"
98
- } disabled:opacity-50`}
99
- >
100
- {isLoading && (
101
- <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
102
- )}
103
- {confirmText}
104
- </button>
105
- </div>
106
- </DialogPanel>
107
- </TransitionChild>
108
- </div>
109
- </div>
110
- </Dialog>
111
- </Transition>
112
- );
113
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//CsvExportButton.test.tsx DELETED
@@ -1,11 +0,0 @@
1
- import { render, screen } from '@testing-library/react';
2
- import { describe, it, expect } from 'vitest';
3
- import CsvExportButton from './CsvExportButton';
4
-
5
- describe('CsvExportButton', () => {
6
- it('renders correctly', () => {
7
- const data = [{ name: "Alex" }];
8
- render(<CsvExportButton data={data} filename="test.csv" />);
9
- expect(screen.getByText('Export CSV')).toBeDefined();
10
- });
11
- });
 
 
 
 
 
 
 
 
 
 
 
 
src//components//CsvExportButton.tsx DELETED
@@ -1,47 +0,0 @@
1
- import { Download } from "lucide-react";
2
-
3
- interface CsvExportButtonProps {
4
- data: any[];
5
- filename?: string;
6
- className?: string;
7
- }
8
-
9
- export default function CsvExportButton({
10
- data,
11
- filename = "smartlead-export.csv",
12
- className = "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 border border-zinc-700",
13
- }: CsvExportButtonProps) {
14
- const exportCsv = () => {
15
- if (!data || data.length === 0) return;
16
-
17
- const headers = Object.keys(data[0]);
18
- const rows = data.map((item) =>
19
- headers
20
- .map((header) => {
21
- const val = item[header];
22
- if (typeof val === "object") {
23
- return `"${JSON.stringify(val).replace(/"/g, '""')}"`;
24
- }
25
- return `"${String(val ?? "").replace(/"/g, '""')}"`;
26
- })
27
- .join(",")
28
- );
29
-
30
- const csvContent = [headers.join(","), ...rows].join("\n");
31
- const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
32
- const url = URL.createObjectURL(blob);
33
- const link = document.createElement("a");
34
- link.setAttribute("href", url);
35
- link.setAttribute("download", filename);
36
- document.body.appendChild(link);
37
- link.click();
38
- document.body.removeChild(link);
39
- };
40
-
41
- return (
42
- <button type="button" onClick={exportCsv} className={className}>
43
- <Download className="w-3.5 h-3.5" />
44
- Export CSV
45
- </button>
46
- );
47
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//FormDrawer.tsx DELETED
@@ -1,78 +0,0 @@
1
- import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
2
- import { Fragment, ReactNode } from "react";
3
- import { X } from "lucide-react";
4
-
5
- interface FormDrawerProps {
6
- isOpen: boolean;
7
- onClose: () => void;
8
- title: string;
9
- subtitle?: string;
10
- children: ReactNode;
11
- maxWidth?: string;
12
- }
13
-
14
- export default function FormDrawer({
15
- isOpen,
16
- onClose,
17
- title,
18
- subtitle,
19
- children,
20
- maxWidth = "max-w-xl",
21
- }: FormDrawerProps) {
22
- return (
23
- <Transition show={isOpen} as={Fragment}>
24
- <Dialog as="div" className="relative z-50" onClose={onClose}>
25
- <TransitionChild
26
- as={Fragment}
27
- enter="ease-in-out duration-300"
28
- enterFrom="opacity-0"
29
- enterTo="opacity-100"
30
- leave="ease-in-out duration-300"
31
- leaveFrom="opacity-100"
32
- leaveTo="opacity-0"
33
- >
34
- <div className="fixed inset-0 bg-black/70 backdrop-blur-xs transition-opacity" />
35
- </TransitionChild>
36
-
37
- <div className="fixed inset-0 overflow-hidden">
38
- <div className="absolute inset-0 overflow-hidden">
39
- <div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
40
- <TransitionChild
41
- as={Fragment}
42
- enter="transform transition ease-in-out duration-300 sm:duration-400"
43
- enterFrom="translate-x-full"
44
- enterTo="translate-x-0"
45
- leave="transform transition ease-in-out duration-300 sm:duration-400"
46
- leaveFrom="translate-x-0"
47
- leaveTo="translate-x-full"
48
- >
49
- <DialogPanel className={`pointer-events-auto w-screen ${maxWidth}`}>
50
- <div className="flex h-full flex-col overflow-y-auto bg-zinc-950 border-l border-zinc-800 shadow-2xl">
51
- <div className="px-6 py-5 border-b border-zinc-800 bg-zinc-900/50 flex items-center justify-between sticky top-0 z-10">
52
- <div>
53
- <DialogTitle className="text-lg font-semibold text-zinc-100">
54
- {title}
55
- </DialogTitle>
56
- {subtitle && (
57
- <p className="text-xs text-zinc-400 mt-0.5">{subtitle}</p>
58
- )}
59
- </div>
60
- <button
61
- onClick={onClose}
62
- className="rounded-lg p-2 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
63
- >
64
- <X className="w-5 h-5" />
65
- </button>
66
- </div>
67
-
68
- <div className="flex-1 px-6 py-6 space-y-6">{children}</div>
69
- </div>
70
- </DialogPanel>
71
- </TransitionChild>
72
- </div>
73
- </div>
74
- </div>
75
- </Dialog>
76
- </Transition>
77
- );
78
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//GeometricBackground.test.tsx DELETED
@@ -1,12 +0,0 @@
1
- import { render } from '@testing-library/react';
2
- import { describe, it, expect } from 'vitest';
3
- import GeometricBackground from './GeometricBackground';
4
-
5
- describe('GeometricBackground', () => {
6
- it('renders without crashing when mounted', () => {
7
- const { container } = render(<GeometricBackground />);
8
- // The component uses useEffect to set mounted=true, so it might render null initially.
9
- // However, in tests without strict act() wrappers for the initial effect, it might return empty.
10
- expect(container).toBeDefined();
11
- });
12
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//GeometricBackground.tsx DELETED
@@ -1,62 +0,0 @@
1
- import { motion } from "motion/react";
2
- import { useEffect, useState } from "react";
3
-
4
- export default function GeometricBackground() {
5
- const [mounted, setMounted] = useState(false);
6
-
7
- useEffect(() => {
8
- setMounted(true);
9
- }, []);
10
-
11
- if (!mounted) return null;
12
-
13
- return (
14
- <div className="fixed inset-0 z-[-1] overflow-hidden pointer-events-none">
15
- {/* Soft overlay gradient */}
16
- <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-indigo-900/10 via-transparent to-transparent"></div>
17
-
18
- {/* Floating geometric elements */}
19
- <motion.div
20
- className="absolute top-[10%] left-[15%] w-64 h-64 rounded-full border border-white/5"
21
- animate={{
22
- y: [0, -30, 0],
23
- x: [0, 20, 0],
24
- rotate: [0, 90, 0],
25
- }}
26
- transition={{
27
- duration: 20,
28
- repeat: Infinity,
29
- ease: [0.32, 0.72, 0, 1],
30
- }}
31
- />
32
- <motion.div
33
- className="absolute bottom-[20%] right-[10%] w-96 h-96 rounded-full border border-indigo-500/10"
34
- animate={{
35
- y: [0, 40, 0],
36
- x: [0, -30, 0],
37
- scale: [1, 1.1, 1],
38
- }}
39
- transition={{
40
- duration: 25,
41
- repeat: Infinity,
42
- ease: [0.32, 0.72, 0, 1],
43
- }}
44
- />
45
- <motion.div
46
- className="absolute top-[40%] right-[30%] w-32 h-32 rotate-45 border border-sky-500/10"
47
- animate={{
48
- rotate: [45, 225],
49
- y: [0, -50, 0],
50
- }}
51
- transition={{
52
- duration: 30,
53
- repeat: Infinity,
54
- ease: [0.32, 0.72, 0, 1],
55
- }}
56
- />
57
-
58
- {/* Grid pattern overlay */}
59
- <div className="absolute inset-0 bg-[linear-gradient(rgba(246,248,242,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(246,248,242,0.02)_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_60%_60%_at_50%_50%,var(--nt-espresso-100)_20%,transparent_100%)]"></div>
60
- </div>
61
- );
62
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//GlassCard.tsx DELETED
@@ -1,15 +0,0 @@
1
- import { ReactNode } from 'react';
2
- import { cn } from '../utils/cn';
3
-
4
- interface GlassCardProps {
5
- children: ReactNode;
6
- className?: string;
7
- }
8
-
9
- export default function GlassCard({ children, className }: GlassCardProps) {
10
- return (
11
- <div className={cn("bg-white/5 border border-white/10 backdrop-blur-md rounded-xl shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]", className)}>
12
- {children}
13
- </div>
14
- );
15
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//Logo.tsx DELETED
@@ -1,14 +0,0 @@
1
- import { motion } from 'motion/react';
2
-
3
- export default function Logo() {
4
- return (
5
- <motion.div
6
- initial={{ opacity: 0, scale: 0.9 }}
7
- animate={{ opacity: 1, scale: 1 }}
8
- transition={{ duration: 0.5, ease: [0.32, 0.72, 0, 1] }}
9
- className="text-2xl font-bold tracking-tight text-white font-sans"
10
- >
11
- BRIDGE<span className="text-emerald-400">ModulE</span>
12
- </motion.div>
13
- );
14
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//RichTextEditor.tsx DELETED
@@ -1,152 +0,0 @@
1
- import { useEditor, EditorContent } from "@tiptap/react";
2
- import StarterKit from "@tiptap/starter-kit";
3
- import { Bold, Italic, List, ListOrdered, Code, Undo, Redo, Sparkles } from "lucide-react";
4
- import { useEffect } from "react";
5
- import { cn } from "../utils/cn";
6
-
7
- interface RichTextEditorProps {
8
- content: string;
9
- onChange: (html: string) => void;
10
- placeholder?: string;
11
- variables?: string[];
12
- }
13
-
14
- export default function RichTextEditor({
15
- content,
16
- onChange,
17
- variables = ["first_name", "last_name", "company_name", "website", "location"],
18
- }: RichTextEditorProps) {
19
- const editor = useEditor({
20
- extensions: [StarterKit],
21
- content,
22
- editorProps: {
23
- attributes: {
24
- class:
25
- "prose prose-invert max-w-none focus:outline-none min-h-[160px] p-3 text-sm text-zinc-200 leading-relaxed",
26
- },
27
- },
28
- onUpdate: ({ editor }) => {
29
- onChange(editor.getHTML());
30
- },
31
- });
32
-
33
- useEffect(() => {
34
- if (editor && content !== editor.getHTML()) {
35
- editor.commands.setContent(content);
36
- }
37
- }, [content, editor]);
38
-
39
- if (!editor) return null;
40
-
41
- const insertVariable = (variable: string) => {
42
- editor.commands.insertContent(`{{${variable}}}`);
43
- };
44
-
45
- return (
46
- <div className="border border-zinc-800 rounded-xl overflow-hidden bg-zinc-950/80 focus-within:border-indigo-500/80 transition-colors">
47
- {/* Toolbar */}
48
- <div className="flex flex-wrap items-center justify-between gap-1 p-2 border-b border-zinc-800/80 bg-zinc-900/60">
49
- <div className="flex items-center gap-1">
50
- <button
51
- type="button"
52
- onClick={() => editor.chain().focus().toggleBold().run()}
53
- className={cn(
54
- "p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 transition-colors",
55
- editor.isActive("bold") ? "bg-zinc-800 text-indigo-400 font-bold" : ""
56
- )}
57
- title="Bold"
58
- >
59
- <Bold className="w-4 h-4" />
60
- </button>
61
- <button
62
- type="button"
63
- onClick={() => editor.chain().focus().toggleItalic().run()}
64
- className={cn(
65
- "p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 transition-colors",
66
- editor.isActive("italic") ? "bg-zinc-800 text-indigo-400" : ""
67
- )}
68
- title="Italic"
69
- >
70
- <Italic className="w-4 h-4" />
71
- </button>
72
- <button
73
- type="button"
74
- onClick={() => editor.chain().focus().toggleBulletList().run()}
75
- className={cn(
76
- "p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 transition-colors",
77
- editor.isActive("bulletList") ? "bg-zinc-800 text-indigo-400" : ""
78
- )}
79
- title="Bullet List"
80
- >
81
- <List className="w-4 h-4" />
82
- </button>
83
- <button
84
- type="button"
85
- onClick={() => editor.chain().focus().toggleOrderedList().run()}
86
- className={cn(
87
- "p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 transition-colors",
88
- editor.isActive("orderedList") ? "bg-zinc-800 text-indigo-400" : ""
89
- )}
90
- title="Numbered List"
91
- >
92
- <ListOrdered className="w-4 h-4" />
93
- </button>
94
- <button
95
- type="button"
96
- onClick={() => editor.chain().focus().toggleCodeBlock().run()}
97
- className={cn(
98
- "p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 transition-colors",
99
- editor.isActive("codeBlock") ? "bg-zinc-800 text-indigo-400" : ""
100
- )}
101
- title="Code Block"
102
- >
103
- <Code className="w-4 h-4" />
104
- </button>
105
- </div>
106
-
107
- <div className="flex items-center gap-1">
108
- <button
109
- type="button"
110
- onClick={() => editor.chain().focus().undo().run()}
111
- disabled={!editor.can().undo()}
112
- className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 disabled:opacity-30"
113
- >
114
- <Undo className="w-4 h-4" />
115
- </button>
116
- <button
117
- type="button"
118
- onClick={() => editor.chain().focus().redo().run()}
119
- disabled={!editor.can().redo()}
120
- className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 disabled:opacity-30"
121
- >
122
- <Redo className="w-4 h-4" />
123
- </button>
124
- </div>
125
- </div>
126
-
127
- {/* Variables Bar */}
128
- {variables.length > 0 && (
129
- <div className="px-3 py-1.5 bg-zinc-900/40 border-b border-zinc-800/60 flex items-center gap-2 overflow-x-auto text-xs">
130
- <span className="text-zinc-500 flex items-center gap-1 shrink-0">
131
- <Sparkles className="w-3 h-3 text-indigo-400" /> Insert tag:
132
- </span>
133
- <div className="flex items-center gap-1.5">
134
- {variables.map((v) => (
135
- <button
136
- key={v}
137
- type="button"
138
- onClick={() => insertVariable(v)}
139
- className="px-2 py-0.5 bg-zinc-800/80 hover:bg-indigo-500/20 hover:text-indigo-300 text-zinc-400 rounded text-[11px] font-mono transition-colors border border-zinc-700/60 shrink-0"
140
- >
141
- &#123;&#123;{v}&#125;&#125;
142
- </button>
143
- ))}
144
- </div>
145
- </div>
146
- )}
147
-
148
- {/* Editor Surface */}
149
- <EditorContent editor={editor} />
150
- </div>
151
- );
152
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//Sidebar.tsx DELETED
@@ -1,66 +0,0 @@
1
- import { Link, useLocation } from "react-router-dom";
2
- import {
3
- LayoutDashboard,
4
- Megaphone,
5
- Users,
6
- Mail,
7
- Flame,
8
- Inbox,
9
- Webhook,
10
- BarChart2,
11
- Building2,
12
- Settings
13
- } from "lucide-react";
14
- import { cn } from "../utils/cn";
15
- import AnimatedLogo from "./AnimatedLogo";
16
- import Logo from "./Logo";
17
-
18
- const navigation = [
19
- { name: "Dashboard", href: "/", icon: LayoutDashboard },
20
- { name: "Campaigns", href: "/campaigns", icon: Megaphone },
21
- { name: "Leads", href: "/leads", icon: Users },
22
- { name: "Email Accounts", href: "/email-accounts", icon: Mail },
23
- { name: "Warmup", href: "/warmup", icon: Flame },
24
- { name: "Unified Inbox", href: "/inbox", icon: Inbox },
25
- { name: "Webhooks", href: "/webhooks", icon: Webhook },
26
- { name: "Analytics", href: "/analytics", icon: BarChart2 },
27
- { name: "Clients", href: "/clients", icon: Building2 },
28
- { name: "Settings", href: "/settings", icon: Settings },
29
- ];
30
-
31
- export default function Sidebar({ onClose }: { onClose?: () => void }) {
32
- const location = useLocation();
33
-
34
- return (
35
- <div className="w-64 border-r border-white/5 bg-zinc-950/80 md:bg-zinc-950/40 backdrop-blur-xl flex flex-col shrink-0 h-full relative z-20 shadow-[4px_0_24px_-4px_rgba(6,10,12,0.65)]">
36
- <div className="h-16 flex items-center px-6 border-b border-white/5 shrink-0 gap-3">
37
- <AnimatedLogo className="w-7 h-7" />
38
- <Logo />
39
- </div>
40
- <nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1.5">
41
- {navigation.map((item) => {
42
- const isActive = location.pathname === item.href || (item.href !== "/" && location.pathname.startsWith(item.href));
43
- return (
44
- <Link
45
- key={item.name}
46
- to={item.href}
47
- onClick={onClose}
48
- className={cn(
49
- "w-full flex items-center gap-3 px-3 py-2.5 text-[13px] font-medium rounded-lg transition-all duration-300 relative group overflow-hidden",
50
- isActive
51
- ? "text-indigo-300 bg-white/5 border border-white/10 shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]"
52
- : "text-zinc-400 hover:text-zinc-200 hover:bg-white/5 border border-transparent"
53
- )}
54
- >
55
- {isActive && (
56
- <div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-indigo-500 rounded-r-full shadow-[0_0_10px_rgba(37,125,104,0.8)]" />
57
- )}
58
- <item.icon className={cn("w-4 h-4 transition-transform duration-300 group-hover:scale-110", isActive ? "text-indigo-400 drop-shadow-[0_0_8px_rgba(37,125,104,0.5)]" : "text-zinc-500")} />
59
- {item.name}
60
- </Link>
61
- );
62
- })}
63
- </nav>
64
- </div>
65
- );
66
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//StatCard.tsx DELETED
@@ -1,149 +0,0 @@
1
- import { LucideIcon, TrendingUp, TrendingDown, Minus } from "lucide-react";
2
- import { cn } from "../utils/cn";
3
-
4
- interface StatCardProps {
5
- title: string;
6
- value: string | number;
7
- icon: LucideIcon;
8
- iconColor?: "indigo" | "emerald" | "amber" | "rose" | "teal" | "blue" | "purple";
9
- change?: {
10
- value: string;
11
- isPositive?: boolean | null;
12
- period?: string;
13
- };
14
- subtitle?: string;
15
- progress?: number; // 0 to 100
16
- className?: string;
17
- }
18
-
19
- const colorMap = {
20
- indigo: {
21
- bg: "bg-indigo-500/10",
22
- border: "border-indigo-500/20",
23
- text: "text-indigo-400",
24
- glow: "group-hover:border-indigo-500/40",
25
- },
26
- emerald: {
27
- bg: "bg-emerald-500/10",
28
- border: "border-emerald-500/20",
29
- text: "text-emerald-400",
30
- glow: "group-hover:border-emerald-500/40",
31
- },
32
- amber: {
33
- bg: "bg-amber-500/10",
34
- border: "border-amber-500/20",
35
- text: "text-amber-400",
36
- glow: "group-hover:border-amber-500/40",
37
- },
38
- rose: {
39
- bg: "bg-rose-500/10",
40
- border: "border-rose-500/20",
41
- text: "text-rose-400",
42
- glow: "group-hover:border-rose-500/40",
43
- },
44
- teal: {
45
- bg: "bg-teal-500/10",
46
- border: "border-teal-500/20",
47
- text: "text-teal-400",
48
- glow: "group-hover:border-teal-500/40",
49
- },
50
- blue: {
51
- bg: "bg-blue-500/10",
52
- border: "border-blue-500/20",
53
- text: "text-blue-400",
54
- glow: "group-hover:border-blue-500/40",
55
- },
56
- purple: {
57
- bg: "bg-purple-500/10",
58
- border: "border-purple-500/20",
59
- text: "text-purple-400",
60
- glow: "group-hover:border-purple-500/40",
61
- },
62
- };
63
-
64
- export default function StatCard({
65
- title,
66
- value,
67
- icon: Icon,
68
- iconColor = "indigo",
69
- change,
70
- subtitle,
71
- progress,
72
- className,
73
- }: StatCardProps) {
74
- const c = colorMap[iconColor];
75
-
76
- return (
77
- <div
78
- className={cn(
79
- "group relative bg-white/5 hover:bg-white/10 backdrop-blur-md border border-white/10 rounded-xl p-5 transition-all duration-300 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)] overflow-hidden",
80
- className
81
- )}
82
- >
83
- <div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent pointer-events-none" />
84
- <div className="relative z-10 flex justify-between items-start">
85
- <div>
86
- <p className="text-xs font-semibold uppercase tracking-wider text-zinc-400 font-sans">
87
- {title}
88
- </p>
89
- <h3 className="text-2xl font-bold text-zinc-100 mt-2 tracking-tight font-mono">
90
- {value}
91
- </h3>
92
- {subtitle && (
93
- <p className="text-xs text-zinc-500 mt-1 font-sans">{subtitle}</p>
94
- )}
95
- </div>
96
- <div
97
- className={cn(
98
- "p-2.5 rounded-xl border transition-transform duration-300 group-hover:scale-110 shadow-[inset_0_1px_1px_rgba(246,248,242,0.1)]",
99
- c.bg,
100
- c.border
101
- )}
102
- >
103
- <Icon className={cn("w-5 h-5 drop-shadow-[0_0_8px_currentColor]", c.text)} />
104
- </div>
105
- </div>
106
-
107
- {progress !== undefined && (
108
- <div className="relative z-10 mt-4 space-y-1.5">
109
- <div className="flex justify-between text-[11px] text-zinc-400 font-sans font-medium">
110
- <span>Capacity</span>
111
- <span className="font-mono">{Math.round(progress)}%</span>
112
- </div>
113
- <div className="w-full h-1.5 bg-black/50 border border-white/5 rounded-full overflow-hidden">
114
- <div
115
- className={cn("h-full rounded-full transition-all duration-500", c.text.replace("text-", "bg-"))}
116
- style={{ width: `${Math.min(100, Math.max(0, progress))}%`, boxShadow: '0 0 10px currentColor' }}
117
- />
118
- </div>
119
- </div>
120
- )}
121
-
122
- {change && (
123
- <div className="relative z-10 mt-3.5 flex items-center text-xs font-medium font-sans">
124
- {change.isPositive === true && (
125
- <span className="flex items-center text-emerald-400 mr-1.5">
126
- <TrendingUp className="w-3.5 h-3.5 mr-0.5" />
127
- <span className="font-mono">{change.value}</span>
128
- </span>
129
- )}
130
- {change.isPositive === false && (
131
- <span className="flex items-center text-rose-400 mr-1.5">
132
- <TrendingDown className="w-3.5 h-3.5 mr-0.5" />
133
- <span className="font-mono">{change.value}</span>
134
- </span>
135
- )}
136
- {change.isPositive === null && (
137
- <span className="flex items-center text-zinc-400 mr-1.5">
138
- <Minus className="w-3.5 h-3.5 mr-0.5" />
139
- <span className="font-mono">{change.value}</span>
140
- </span>
141
- )}
142
- <span className="text-zinc-500 font-normal">
143
- {change.period || "vs last month"}
144
- </span>
145
- </div>
146
- )}
147
- </div>
148
- );
149
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//StatusBadge.test.tsx DELETED
@@ -1,22 +0,0 @@
1
- import { render, screen } from '@testing-library/react';
2
- import { describe, it, expect } from 'vitest';
3
- import StatusBadge from './StatusBadge';
4
-
5
- describe('StatusBadge', () => {
6
- it('renders ACTIVE status correctly', () => {
7
- render(<StatusBadge status="ACTIVE" />);
8
- expect(screen.getByText('ACTIVE')).toBeDefined();
9
- expect(document.querySelector('.bg-emerald-500\\/10')).toBeDefined();
10
- });
11
-
12
- it('renders PAUSED status correctly', () => {
13
- render(<StatusBadge status="PAUSED" />);
14
- expect(screen.getByText('PAUSED')).toBeDefined();
15
- expect(document.querySelector('.bg-amber-500\\/10')).toBeDefined();
16
- });
17
-
18
- it('renders unknown status gracefully', () => {
19
- render(<StatusBadge status="UNKNOWN_STATE" />);
20
- expect(screen.getByText('UNKNOWN STATE')).toBeDefined();
21
- });
22
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//StatusBadge.tsx DELETED
@@ -1,83 +0,0 @@
1
- import { ReactNode } from "react";
2
- import { cn } from "../utils/cn";
3
-
4
- interface StatusBadgeProps {
5
- status: string;
6
- className?: string;
7
- size?: "sm" | "md";
8
- }
9
-
10
- export default function StatusBadge({ status, className, size = "md" }: StatusBadgeProps) {
11
- const normalized = (status || "").toUpperCase();
12
-
13
- let colorClasses = "bg-zinc-800 text-zinc-300 border-zinc-700";
14
- let dotColor = "bg-zinc-400";
15
- let pulse = false;
16
-
17
- switch (normalized) {
18
- case "ACTIVE":
19
- case "CONNECTED":
20
- case "EXCELLENT":
21
- case "INTERESTED":
22
- case "MEETING_BOOKED":
23
- colorClasses = "bg-emerald-500/10 text-emerald-400 border-emerald-500/20";
24
- dotColor = "bg-emerald-400 shadow-[0_0_8px_rgba(63,156,132,0.6)]";
25
- pulse = true;
26
- break;
27
-
28
- case "PAUSED":
29
- case "GOOD":
30
- case "REPLIED":
31
- case "RECONNECTING":
32
- case "WARMING":
33
- colorClasses = "bg-amber-500/10 text-amber-400 border-amber-500/20";
34
- dotColor = "bg-amber-400 shadow-[0_0_8px_rgba(194,160,74,0.6)]";
35
- break;
36
-
37
- case "STOPPED":
38
- case "ERROR":
39
- case "POOR":
40
- case "BOUNCED":
41
- case "NOT_INTERESTED":
42
- case "UNSUBSCRIBED":
43
- case "DO_NOT_CONTACT":
44
- colorClasses = "bg-rose-500/10 text-rose-400 border-rose-500/20";
45
- dotColor = "bg-rose-400 shadow-[0_0_8px_rgba(203,117,80,0.6)]";
46
- break;
47
-
48
- case "MEETING_DONE":
49
- case "COMPLETED":
50
- colorClasses = "bg-indigo-500/10 text-indigo-400 border-indigo-500/20";
51
- dotColor = "bg-indigo-400";
52
- break;
53
-
54
- default:
55
- colorClasses = "bg-zinc-800/80 text-zinc-300 border-zinc-700";
56
- dotColor = "bg-zinc-400";
57
- break;
58
- }
59
-
60
- const formatText = (text: string) => {
61
- return text.replace(/_/g, " ");
62
- };
63
-
64
- return (
65
- <span
66
- className={cn(
67
- "inline-flex items-center gap-1.5 font-medium rounded-full border transition-all",
68
- size === "sm" ? "px-2 py-0.5 text-[11px]" : "px-2.5 py-1 text-xs",
69
- colorClasses,
70
- className
71
- )}
72
- >
73
- <span
74
- className={cn(
75
- "w-1.5 h-1.5 rounded-full shrink-0",
76
- dotColor,
77
- pulse && "animate-pulse"
78
- )}
79
- />
80
- <span className="capitalize">{formatText(status)}</span>
81
- </span>
82
- );
83
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//components//TopBar.tsx DELETED
@@ -1,64 +0,0 @@
1
- import { KeyRound, CheckCircle2, XCircle, Menu } from "lucide-react";
2
- import { useApiKey } from "../context/ApiKeyContext";
3
- import { Link } from "react-router-dom";
4
-
5
- export default function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
6
- const { apiKeys, activeKeyId, setActiveKeyId, activeKey } = useApiKey();
7
-
8
- return (
9
- <header className="h-16 border-b border-white/5 bg-zinc-950/40 backdrop-blur-xl flex items-center justify-between px-4 md:px-6 shrink-0 relative z-20 shadow-[0_4px_24px_-4px_rgba(6,10,12,0.65)]">
10
- <div className="flex items-center gap-4">
11
- {onMenuClick && (
12
- <button
13
- onClick={onMenuClick}
14
- className="p-2 -ml-2 text-zinc-400 hover:text-zinc-200 md:hidden"
15
- aria-label="Open menu"
16
- >
17
- <Menu className="w-5 h-5" />
18
- </button>
19
- )}
20
- </div>
21
-
22
- <div className="flex items-center gap-2 md:gap-4">
23
- <div className="flex items-center gap-2 hidden sm:flex">
24
- {activeKey ? (
25
- <div className="flex items-center gap-1.5 text-[11px] font-medium text-emerald-300 bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-full shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]">
26
- <CheckCircle2 className="w-3.5 h-3.5" />
27
- API Connected
28
- </div>
29
- ) : (
30
- <div className="flex items-center gap-1.5 text-[11px] font-medium text-rose-300 bg-rose-500/10 border border-rose-500/20 px-2.5 py-1 rounded-full shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]">
31
- <XCircle className="w-3.5 h-3.5" />
32
- API Key Missing
33
- </div>
34
- )}
35
- </div>
36
-
37
- <div className="h-5 w-px bg-white/10" />
38
-
39
- <div className="flex items-center gap-3">
40
- <div className="w-6 h-6 rounded-full bg-white/5 border border-white/10 flex items-center justify-center shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]">
41
- <KeyRound className="w-3.5 h-3.5 text-zinc-400" />
42
- </div>
43
- <select
44
- value={activeKeyId || ""}
45
- onChange={(e) => setActiveKeyId(e.target.value)}
46
- className="bg-white/5 border border-white/10 rounded-md py-1.5 pl-3 pr-8 text-xs text-zinc-200 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 w-32 md:w-48 shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)] transition-all hover:bg-white/10"
47
- >
48
- <option value="" disabled className="bg-zinc-900">Select API Key...</option>
49
- {apiKeys.map((key) => (
50
- <option key={key.id} value={key.id} className="bg-zinc-900">
51
- {key.name}
52
- </option>
53
- ))}
54
- </select>
55
- {apiKeys.length === 0 && (
56
- <Link to="/settings" className="text-xs text-indigo-400 hover:text-indigo-300 font-medium">
57
- Add Key
58
- </Link>
59
- )}
60
- </div>
61
- </div>
62
- </header>
63
- );
64
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//context//ApiKeyContext.tsx DELETED
@@ -1,93 +0,0 @@
1
- import { createContext, useContext, useState, useEffect, ReactNode } from "react";
2
-
3
- interface ApiKey {
4
- id: string;
5
- name: string;
6
- key: string;
7
- createdAt: string;
8
- }
9
-
10
- interface ApiKeyContextType {
11
- apiKeys: ApiKey[];
12
- activeKeyId: string | null;
13
- activeKey: string | null;
14
- addApiKey: (name: string, key: string, activate?: boolean) => void;
15
- removeApiKey: (id: string) => void;
16
- renameApiKey: (id: string, newName: string) => void;
17
- setActiveKeyId: (id: string) => void;
18
- }
19
-
20
- const ApiKeyContext = createContext<ApiKeyContextType | undefined>(undefined);
21
-
22
- export function ApiKeyProvider({ children }: { children: ReactNode }) {
23
- const [apiKeys, setApiKeys] = useState<ApiKey[]>(() => {
24
- const saved = localStorage.getItem("smartlead_api_keys");
25
- return saved ? JSON.parse(saved) : [];
26
- });
27
-
28
- const [activeKeyId, setActiveKeyIdState] = useState<string | null>(() => {
29
- return localStorage.getItem("smartlead_active_key_id");
30
- });
31
-
32
- useEffect(() => {
33
- localStorage.setItem("smartlead_api_keys", JSON.stringify(apiKeys));
34
- }, [apiKeys]);
35
-
36
- useEffect(() => {
37
- if (activeKeyId) {
38
- localStorage.setItem("smartlead_active_key_id", activeKeyId);
39
- } else {
40
- localStorage.removeItem("smartlead_active_key_id");
41
- }
42
- }, [activeKeyId]);
43
-
44
- const addApiKey = (name: string, key: string, activate?: boolean) => {
45
- const newKey: ApiKey = {
46
- id: Math.random().toString(36).substring(7),
47
- name,
48
- key,
49
- createdAt: new Date().toISOString()
50
- };
51
- setApiKeys((prev) => [...prev, newKey]);
52
- if (!activeKeyId || activate) {
53
- setActiveKeyIdState(newKey.id);
54
- }
55
- };
56
-
57
- const removeApiKey = (id: string) => {
58
- setApiKeys((prev) => prev.filter((k) => k.id !== id));
59
- if (activeKeyId === id) {
60
- setActiveKeyIdState(null);
61
- }
62
- };
63
-
64
- const renameApiKey = (id: string, newName: string) => {
65
- setApiKeys((prev) => prev.map((k) => k.id === id ? { ...k, name: newName } : k));
66
- };
67
-
68
- const activeKey = apiKeys.find((k) => k.id === activeKeyId)?.key || null;
69
-
70
- return (
71
- <ApiKeyContext.Provider
72
- value={{
73
- apiKeys,
74
- activeKeyId,
75
- activeKey,
76
- addApiKey,
77
- removeApiKey,
78
- renameApiKey,
79
- setActiveKeyId: setActiveKeyIdState,
80
- }}
81
- >
82
- {children}
83
- </ApiKeyContext.Provider>
84
- );
85
- }
86
-
87
- export function useApiKey() {
88
- const context = useContext(ApiKeyContext);
89
- if (context === undefined) {
90
- throw new Error("useApiKey must be used within an ApiKeyProvider");
91
- }
92
- return context;
93
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//index.css DELETED
@@ -1,522 +0,0 @@
1
- @import "tailwindcss";
2
-
3
- /*
4
- * Verdigris & Ember color tokens
5
- * Deep Ink, Verdigris, Molten Brass, Porcelain, Kiln Ember
6
- */
7
- :root {
8
- /* Deep Ink — primary dark base */
9
- --nt-deep-ink: #2b434b;
10
- --nt-deep-ink-100: #060a0c;
11
- --nt-deep-ink-200: #0c1518;
12
- --nt-deep-ink-300: #142126;
13
- --nt-deep-ink-400: #1e3036;
14
- --nt-deep-ink-500: #2b434b;
15
- --nt-deep-ink-600: #47646b;
16
- --nt-deep-ink-700: #6f8a8e;
17
- --nt-deep-ink-800: #9db4b4;
18
- --nt-deep-ink-900: #cfdedd;
19
-
20
- /* Verdigris — primary interactive accent */
21
- --nt-verdigris: #257d68;
22
- --nt-verdigris-100: #05130e;
23
- --nt-verdigris-200: #0c2a20;
24
- --nt-verdigris-300: #124433;
25
- --nt-verdigris-400: #1a5f47;
26
- --nt-verdigris-500: #257d68;
27
- --nt-verdigris-600: #3f9c84;
28
- --nt-verdigris-700: #6fbca4;
29
- --nt-verdigris-800: #a2d8c5;
30
- --nt-verdigris-900: #d4efe4;
31
-
32
- /* Molten Brass — warm secondary accent */
33
- --nt-molten-brass: #a8842c;
34
- --nt-molten-brass-100: #171104;
35
- --nt-molten-brass-200: #322609;
36
- --nt-molten-brass-300: #523f10;
37
- --nt-molten-brass-400: #745a19;
38
- --nt-molten-brass-500: #a8842c;
39
- --nt-molten-brass-600: #c2a04a;
40
- --nt-molten-brass-700: #d8bc72;
41
- --nt-molten-brass-800: #ecd8a4;
42
- --nt-molten-brass-900: #f8eecb;
43
-
44
- /* Porcelain — cool neutral */
45
- --nt-porcelain: #b4bfae;
46
- --nt-porcelain-100: #1e231e;
47
- --nt-porcelain-200: #3d463d;
48
- --nt-porcelain-300: #616c61;
49
- --nt-porcelain-400: #8a968a;
50
- --nt-porcelain-500: #b4bfae;
51
- --nt-porcelain-600: #c9d2c3;
52
- --nt-porcelain-700: #dde3d8;
53
- --nt-porcelain-800: #ecf0e8;
54
- --nt-porcelain-900: #f6f8f2;
55
-
56
- /* Kiln Ember — heat / danger accent */
57
- --nt-kiln-ember: #b25731;
58
- --nt-kiln-ember-100: #200e07;
59
- --nt-kiln-ember-200: #421d0f;
60
- --nt-kiln-ember-300: #662e18;
61
- --nt-kiln-ember-400: #8b4123;
62
- --nt-kiln-ember-500: #b25731;
63
- --nt-kiln-ember-600: #cb7550;
64
- --nt-kiln-ember-700: #dd9a78;
65
- --nt-kiln-ember-800: #ecc2ab;
66
- --nt-kiln-ember-900: #f8e5d9;
67
-
68
- /* Compatibility primitive aliases retained for existing color consumers. */
69
- --nt-espresso: var(--nt-deep-ink);
70
- --nt-espresso-100: var(--nt-deep-ink-100);
71
- --nt-espresso-200: var(--nt-deep-ink-200);
72
- --nt-espresso-300: var(--nt-deep-ink-300);
73
- --nt-espresso-400: var(--nt-deep-ink-400);
74
- --nt-espresso-500: var(--nt-deep-ink-500);
75
- --nt-espresso-600: var(--nt-deep-ink-600);
76
- --nt-espresso-700: var(--nt-deep-ink-700);
77
- --nt-espresso-800: var(--nt-deep-ink-800);
78
- --nt-espresso-900: var(--nt-deep-ink-900);
79
-
80
- --nt-deep-teal: var(--nt-verdigris);
81
- --nt-deep-teal-100: var(--nt-verdigris-100);
82
- --nt-deep-teal-200: var(--nt-verdigris-200);
83
- --nt-deep-teal-300: var(--nt-verdigris-300);
84
- --nt-deep-teal-400: var(--nt-verdigris-400);
85
- --nt-deep-teal-500: var(--nt-verdigris-500);
86
- --nt-deep-teal-600: var(--nt-verdigris-600);
87
- --nt-deep-teal-700: var(--nt-verdigris-700);
88
- --nt-deep-teal-800: var(--nt-verdigris-800);
89
- --nt-deep-teal-900: var(--nt-verdigris-900);
90
-
91
- --nt-warm-sand: var(--nt-porcelain);
92
- --nt-warm-sand-100: var(--nt-porcelain-100);
93
- --nt-warm-sand-200: var(--nt-porcelain-200);
94
- --nt-warm-sand-300: var(--nt-porcelain-300);
95
- --nt-warm-sand-400: var(--nt-porcelain-400);
96
- --nt-warm-sand-500: var(--nt-porcelain-500);
97
- --nt-warm-sand-600: var(--nt-porcelain-600);
98
- --nt-warm-sand-700: var(--nt-porcelain-700);
99
- --nt-warm-sand-800: var(--nt-porcelain-800);
100
- --nt-warm-sand-900: var(--nt-porcelain-900);
101
-
102
- --nt-terracotta: var(--nt-kiln-ember);
103
- --nt-terracotta-100: var(--nt-kiln-ember-100);
104
- --nt-terracotta-200: var(--nt-kiln-ember-200);
105
- --nt-terracotta-300: var(--nt-kiln-ember-300);
106
- --nt-terracotta-400: var(--nt-kiln-ember-400);
107
- --nt-terracotta-500: var(--nt-kiln-ember-500);
108
- --nt-terracotta-600: var(--nt-kiln-ember-600);
109
- --nt-terracotta-700: var(--nt-kiln-ember-700);
110
- --nt-terracotta-800: var(--nt-kiln-ember-800);
111
- --nt-terracotta-900: var(--nt-kiln-ember-900);
112
-
113
- --nt-light-peach: var(--nt-porcelain);
114
- --nt-light-peach-100: var(--nt-porcelain-100);
115
- --nt-light-peach-200: var(--nt-porcelain-200);
116
- --nt-light-peach-300: var(--nt-porcelain-300);
117
- --nt-light-peach-400: var(--nt-porcelain-400);
118
- --nt-light-peach-500: var(--nt-porcelain-500);
119
- --nt-light-peach-600: var(--nt-porcelain-600);
120
- --nt-light-peach-700: var(--nt-porcelain-700);
121
- --nt-light-peach-800: var(--nt-porcelain-800);
122
- --nt-light-peach-900: var(--nt-porcelain-900);
123
-
124
- /* Semantic surfaces */
125
- --nt-bg-root: var(--nt-deep-ink-200);
126
- --nt-bg-app: var(--nt-deep-ink-100);
127
- --nt-bg-surface: var(--nt-deep-ink-300);
128
- --nt-bg-surface-raised: var(--nt-deep-ink-400);
129
- --nt-bg-sunken: var(--nt-deep-ink-200);
130
- --nt-bg-overlay: rgba(6, 10, 12, 0.80);
131
- --nt-bg-secondary: var(--nt-verdigris-200);
132
- --nt-bg-inverse: var(--nt-porcelain-800);
133
-
134
- /* Semantic text */
135
- --nt-text-primary: var(--nt-porcelain-800);
136
- --nt-text-body: var(--nt-porcelain-600);
137
- --nt-text-muted: var(--nt-porcelain-400);
138
- --nt-text-subtle: var(--nt-porcelain-400);
139
- --nt-text-inverse: var(--nt-deep-ink-100);
140
- --nt-text-link: var(--nt-verdigris-600);
141
- --nt-text-link-hover: var(--nt-verdigris-700);
142
- --nt-text-link-visited: var(--nt-verdigris-500);
143
- --nt-text-link-active: var(--nt-kiln-ember-500);
144
- --nt-text-on-accent: var(--nt-porcelain-900);
145
-
146
- /* Semantic borders */
147
- --nt-border-subtle: var(--nt-deep-ink-400);
148
- --nt-border-default: var(--nt-deep-ink-600);
149
- --nt-border-strong: var(--nt-deep-ink-700);
150
- --nt-border-accent: var(--nt-verdigris-600);
151
-
152
- /* Semantic interactive colors */
153
- --nt-accent: var(--nt-verdigris-600);
154
- --nt-accent-hover: var(--nt-verdigris-700);
155
- --nt-accent-active: var(--nt-verdigris-500);
156
- --nt-accent-subtle: var(--nt-verdigris-200);
157
- --nt-focus-ring: var(--nt-verdigris-600);
158
- --nt-focus-ring-offset: var(--nt-deep-ink-200);
159
- --nt-selection-bg: var(--nt-verdigris-500);
160
- --nt-selection-fg: var(--nt-porcelain-800);
161
-
162
- /* Semantic buttons */
163
- --nt-btn-primary-bg: var(--nt-verdigris-600);
164
- --nt-btn-primary-fg: var(--nt-porcelain-900);
165
- --nt-btn-primary-hover: var(--nt-verdigris-700);
166
- --nt-btn-primary-active: var(--nt-verdigris-500);
167
- --nt-btn-secondary-bg: var(--nt-deep-ink-400);
168
- --nt-btn-secondary-fg: var(--nt-porcelain-600);
169
- --nt-btn-ghost-fg: var(--nt-verdigris-600);
170
- --nt-btn-danger-bg: var(--nt-kiln-ember-500);
171
- --nt-btn-danger-fg: var(--nt-porcelain-900);
172
- --nt-btn-disabled-bg: var(--nt-deep-ink-300);
173
- --nt-btn-disabled-fg: var(--nt-porcelain-400);
174
-
175
- /* Semantic statuses */
176
- --nt-info: var(--nt-verdigris-600);
177
- --nt-success: var(--nt-verdigris-600);
178
- --nt-warning: var(--nt-molten-brass-600);
179
- --nt-danger: var(--nt-kiln-ember-600);
180
-
181
- /* Elevation / glow colors */
182
- --nt-shadow-color: var(--nt-deep-ink-100);
183
- --nt-glow-primary: var(--nt-verdigris-600);
184
- --nt-glow-warm: var(--nt-molten-brass-500);
185
-
186
- /* Protocol aliases */
187
- --bg-canvas: var(--nt-bg-root);
188
- --bg-surface: var(--nt-bg-surface);
189
- --bg-sunken: var(--nt-bg-sunken);
190
- --bg-deep: var(--nt-bg-app);
191
- --text-primary: var(--nt-text-primary);
192
- --text-secondary: var(--nt-text-body);
193
- --text-muted: var(--nt-text-muted);
194
- --accent-primary: var(--nt-accent);
195
- --accent-hover: var(--nt-accent-hover);
196
- --accent-warm: var(--nt-molten-brass-500);
197
- --border-default: var(--nt-border-default);
198
- --border-focus: var(--nt-focus-ring);
199
- --shadow-color: rgba(6, 10, 12, 0.50);
200
- }
201
-
202
- @theme inline {
203
- --font-sans: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
204
- --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
205
-
206
- /* Existing utility names inherit the Verdigris & Ember source of truth. */
207
- --color-black: var(--nt-deep-ink-100);
208
- --color-white: var(--nt-porcelain-900);
209
- --color-zinc-950: var(--nt-bg-app);
210
- --color-zinc-900: var(--nt-bg-surface);
211
- --color-zinc-800: var(--nt-bg-surface-raised);
212
- --color-zinc-700: var(--nt-deep-ink-400);
213
- --color-zinc-600: var(--nt-text-muted);
214
- --color-zinc-500: var(--nt-text-muted);
215
- --color-zinc-400: var(--nt-text-muted);
216
- --color-zinc-300: var(--nt-text-body);
217
- --color-zinc-200: var(--nt-text-body);
218
- --color-zinc-100: var(--nt-text-primary);
219
-
220
- --color-indigo-950: var(--nt-verdigris-200);
221
- --color-indigo-900: var(--nt-verdigris-200);
222
- --color-indigo-800: var(--nt-verdigris-300);
223
- --color-indigo-700: var(--nt-verdigris-700);
224
- --color-indigo-600: var(--nt-btn-primary-bg);
225
- --color-indigo-500: var(--nt-btn-primary-hover);
226
- --color-indigo-400: var(--nt-text-link);
227
- --color-indigo-300: var(--nt-text-link-hover);
228
- --color-indigo-200: var(--nt-verdigris-900);
229
-
230
- --color-emerald-900: var(--nt-verdigris-200);
231
- --color-emerald-800: var(--nt-verdigris-300);
232
- --color-emerald-700: var(--nt-success);
233
- --color-emerald-600: var(--nt-verdigris-500);
234
- --color-emerald-500: var(--nt-verdigris-300);
235
- --color-emerald-400: var(--nt-verdigris-600);
236
- --color-emerald-300: var(--nt-verdigris-900);
237
- --color-emerald-200: var(--nt-verdigris-900);
238
-
239
- --color-teal-900: var(--nt-verdigris-900);
240
- --color-teal-800: var(--nt-verdigris-800);
241
- --color-teal-700: var(--nt-verdigris-600);
242
- --color-teal-600: var(--nt-verdigris-600);
243
- --color-teal-500: var(--nt-verdigris-300);
244
- --color-teal-400: var(--nt-verdigris-600);
245
- --color-teal-300: var(--nt-verdigris-900);
246
-
247
- --color-cyan-900: var(--nt-verdigris-900);
248
- --color-cyan-800: var(--nt-verdigris-800);
249
- --color-cyan-700: var(--nt-verdigris-700);
250
- --color-cyan-600: var(--nt-verdigris-600);
251
- --color-cyan-500: var(--nt-verdigris-400);
252
-
253
- --color-blue-900: var(--nt-verdigris-300);
254
- --color-blue-800: var(--nt-verdigris-600);
255
- --color-blue-700: var(--nt-verdigris-600);
256
- --color-blue-600: var(--nt-verdigris-600);
257
- --color-blue-500: var(--nt-verdigris-600);
258
- --color-blue-400: var(--nt-verdigris-700);
259
- --color-blue-300: var(--nt-verdigris-900);
260
-
261
- --color-sky-900: var(--nt-verdigris-300);
262
- --color-sky-800: var(--nt-verdigris-600);
263
- --color-sky-700: var(--nt-verdigris-600);
264
- --color-sky-600: var(--nt-verdigris-600);
265
- --color-sky-500: var(--nt-verdigris-700);
266
- --color-sky-400: var(--nt-verdigris-900);
267
-
268
- --color-purple-900: var(--nt-kiln-ember-200);
269
- --color-purple-800: var(--nt-kiln-ember-300);
270
- --color-purple-700: var(--nt-kiln-ember-400);
271
- --color-purple-600: var(--nt-kiln-ember-300);
272
- --color-purple-500: var(--nt-kiln-ember-500);
273
- --color-purple-400: var(--nt-kiln-ember-700);
274
- --color-purple-300: var(--nt-kiln-ember-800);
275
- --color-purple-200: var(--nt-kiln-ember-900);
276
-
277
- --color-amber-900: var(--nt-molten-brass-200);
278
- --color-amber-800: var(--nt-molten-brass-300);
279
- --color-amber-700: var(--nt-molten-brass-500);
280
- --color-amber-600: var(--nt-molten-brass-500);
281
- --color-amber-500: var(--nt-molten-brass-200);
282
- --color-amber-400: var(--nt-molten-brass-600);
283
- --color-amber-300: var(--nt-molten-brass-800);
284
-
285
- --color-rose-900: var(--nt-kiln-ember-200);
286
- --color-rose-800: var(--nt-kiln-ember-300);
287
- --color-rose-700: var(--nt-kiln-ember-400);
288
- --color-rose-600: var(--nt-btn-danger-bg);
289
- --color-rose-500: var(--nt-kiln-ember-400);
290
- --color-rose-400: var(--nt-kiln-ember-600);
291
- --color-rose-300: var(--nt-kiln-ember-700);
292
- }
293
-
294
- body {
295
- font-family: var(--font-sans);
296
- background:
297
- radial-gradient(ellipse at 82% -12%, color-mix(in srgb, var(--nt-verdigris-600) 18%, transparent), transparent 34%),
298
- radial-gradient(ellipse at 8% 112%, color-mix(in srgb, var(--nt-kiln-ember) 8%, transparent), transparent 42%),
299
- linear-gradient(180deg, var(--nt-bg-root) 0%, var(--nt-bg-app) 100%);
300
- background-attachment: fixed;
301
- color: var(--nt-text-primary);
302
- min-height: 100vh;
303
- accent-color: var(--nt-accent);
304
- caret-color: var(--nt-text-primary);
305
- }
306
-
307
- html {
308
- background-color: var(--nt-bg-root);
309
- }
310
-
311
- /* Role-specific corrections for utility names reused by borders and controls. */
312
- [class~="border-zinc-800"] {
313
- border-color: var(--nt-border-subtle);
314
- }
315
-
316
- [class~="border-zinc-700"] {
317
- border-color: var(--nt-border-strong);
318
- }
319
-
320
- [class~="border-zinc-800\/80"] {
321
- border-color: color-mix(in srgb, var(--nt-border-subtle) 80%, transparent);
322
- }
323
-
324
- [class~="border-zinc-800\/60"] {
325
- border-color: color-mix(in srgb, var(--nt-border-subtle) 60%, transparent);
326
- }
327
-
328
- [class~="border-indigo-500"]:focus,
329
- [class~="focus:border-indigo-500"]:focus {
330
- border-color: var(--nt-focus-ring);
331
- }
332
-
333
- [class~="focus:ring-indigo-500"]:focus {
334
- --tw-ring-color: var(--nt-focus-ring);
335
- }
336
-
337
- :focus-visible {
338
- outline-color: var(--nt-focus-ring);
339
- }
340
-
341
- :disabled {
342
- color: var(--nt-btn-disabled-fg);
343
- background-color: var(--nt-btn-disabled-bg);
344
- border-color: var(--nt-border-subtle);
345
- }
346
-
347
- button[class~="bg-zinc-800"][class~="text-zinc-200"],
348
- button[class~="bg-zinc-800"][class~="text-zinc-300"] {
349
- color: var(--nt-btn-secondary-fg);
350
- }
351
-
352
- a:visited {
353
- color: var(--nt-text-link-visited);
354
- }
355
-
356
- a:active {
357
- color: var(--nt-text-link-active);
358
- }
359
-
360
- * {
361
- scrollbar-color: var(--nt-deep-ink-600) var(--nt-deep-ink-200);
362
- }
363
-
364
- ::selection {
365
- background: var(--nt-selection-bg);
366
- color: var(--nt-selection-fg);
367
- }
368
-
369
- /* System-wide polish: hierarchy, data legibility, depth, and states. */
370
- html {
371
- scroll-behavior: smooth;
372
- }
373
-
374
- h1,
375
- h2,
376
- h3,
377
- h4,
378
- h5,
379
- h6 {
380
- text-wrap: balance;
381
- }
382
-
383
- p {
384
- text-wrap: pretty;
385
- }
386
-
387
- .font-mono {
388
- font-variant-numeric: tabular-nums;
389
- }
390
-
391
- .lucide {
392
- stroke-width: 1.5;
393
- filter: drop-shadow(1px 1px 0 rgba(6, 10, 12, 0.7)) drop-shadow(0 4px 8px rgba(6, 10, 12, 0.35));
394
- }
395
-
396
- button,
397
- [role="button"],
398
- select,
399
- input,
400
- textarea {
401
- transition-property: color, background-color, border-color, box-shadow, opacity, transform;
402
- transition-duration: 180ms;
403
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
404
- }
405
-
406
- button:not(:disabled):active,
407
- [role="button"]:not([aria-disabled="true"]):active {
408
- transform: translateY(1px) scale(0.985);
409
- }
410
-
411
- *:focus-visible {
412
- outline: 2px solid var(--nt-focus-ring) !important;
413
- outline-offset: 2px !important;
414
- }
415
-
416
- [class~="bg-white\/5"] {
417
- background-color: color-mix(in srgb, var(--nt-deep-ink-300) 84%, transparent);
418
- border-color: color-mix(in srgb, var(--nt-deep-ink-700) 38%, transparent);
419
- box-shadow: 0 14px 36px -24px rgba(6, 10, 12, 0.9), inset 0 1px 0 rgba(246, 248, 242, 0.06);
420
- }
421
-
422
- [class~="bg-white\/10"] {
423
- background-color: color-mix(in srgb, var(--nt-deep-ink-400) 88%, transparent);
424
- }
425
-
426
- [class~="bg-black\/40"] {
427
- background-color: color-mix(in srgb, var(--nt-deep-ink-100) 78%, transparent);
428
- }
429
-
430
- [class~="shadow"],
431
- [class~="shadow-sm"],
432
- [class~="shadow-md"],
433
- [class~="shadow-lg"],
434
- [class~="shadow-xl"],
435
- [class~="shadow-2xl"] {
436
- --tw-shadow-color: rgba(6, 10, 12, 0.5);
437
- }
438
-
439
- @media (prefers-reduced-motion: reduce) {
440
- html {
441
- scroll-behavior: auto;
442
- }
443
-
444
- *,
445
- *::before,
446
- *::after {
447
- animation-duration: 0.01ms !important;
448
- animation-iteration-count: 1 !important;
449
- transition-duration: 0.01ms !important;
450
- }
451
- }
452
-
453
- /* Fixed film grain keeps the glass surfaces tactile without repainting scroll content. */
454
- body::before {
455
- content: "";
456
- position: fixed;
457
- inset: 0;
458
- z-index: 50;
459
- pointer-events: none;
460
- opacity: 0.045;
461
- background-image: radial-gradient(rgba(246, 248, 242, 0.8) 0.5px, transparent 0.5px);
462
- background-size: 4px 4px;
463
- mix-blend-mode: soft-light;
464
- }
465
-
466
- /* Spring-like easing replaces the browser's default mechanical feel. */
467
- [class~="transition-all"],
468
- [class~="transition-colors"],
469
- [class~="transition-transform"],
470
- [class~="ease-in-out"],
471
- [class~="ease-out"],
472
- [class~="ease-in"],
473
- [class~="linear"] {
474
- transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
475
- }
476
-
477
- [class~="animate-pulse"],
478
- [class~="animate-ping"],
479
- [class~="animate-spin"] {
480
- animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
481
- }
482
-
483
- button[class~="bg-indigo-600"],
484
- a[class~="bg-indigo-600"] {
485
- border-radius: 9999px;
486
- box-shadow: 0 12px 28px -18px rgba(63, 156, 132, 0.75);
487
- }
488
-
489
- button[class~="bg-indigo-600"]:hover,
490
- a[class~="bg-indigo-600"]:hover {
491
- box-shadow: 0 16px 34px -18px rgba(63, 156, 132, 0.95);
492
- }
493
-
494
- .command-signal-ribbon {
495
- background-image:
496
- radial-gradient(circle at 92% 50%, color-mix(in srgb, var(--nt-molten-brass) 22%, transparent), transparent 28%),
497
- radial-gradient(circle at 4% 50%, color-mix(in srgb, var(--nt-verdigris-600) 20%, transparent), transparent 32%),
498
- linear-gradient(90deg, color-mix(in srgb, var(--nt-deep-ink-300) 86%, transparent), color-mix(in srgb, var(--nt-deep-ink-400) 68%, transparent));
499
- }
500
-
501
- .page-shell > * {
502
- animation: command-enter 720ms cubic-bezier(0.32, 0.72, 0, 1) both;
503
- }
504
-
505
- @keyframes command-enter {
506
- from {
507
- opacity: 0;
508
- transform: translateY(18px);
509
- filter: blur(4px);
510
- }
511
- to {
512
- opacity: 1;
513
- transform: translateY(0);
514
- filter: blur(0);
515
- }
516
- }
517
-
518
- @media (prefers-reduced-motion: reduce) {
519
- .page-shell > * {
520
- animation: none !important;
521
- }
522
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//layouts//AppLayout.tsx DELETED
@@ -1,37 +0,0 @@
1
- import { Outlet } from "react-router-dom";
2
- import { useState } from "react";
3
- import Sidebar from "../components/Sidebar";
4
- import TopBar from "../components/TopBar";
5
- import GeometricBackground from "../components/GeometricBackground";
6
- import BackgroundElements from "../components/BackgroundElements";
7
-
8
- export default function AppLayout() {
9
- const [isSidebarOpen, setIsSidebarOpen] = useState(false);
10
-
11
- return (
12
- <div className="flex h-screen bg-transparent text-zinc-100 overflow-hidden relative">
13
- <GeometricBackground />
14
- <BackgroundElements />
15
-
16
- {/* Mobile overlay */}
17
- {isSidebarOpen && (
18
- <div
19
- className="fixed inset-0 bg-black/60 z-30 md:hidden backdrop-blur-sm"
20
- onClick={() => setIsSidebarOpen(false)}
21
- />
22
- )}
23
-
24
- {/* Sidebar with mobile slide transition */}
25
- <div className={`fixed inset-y-0 left-0 z-40 transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}`}>
26
- <Sidebar onClose={() => setIsSidebarOpen(false)} />
27
- </div>
28
-
29
- <div className="flex flex-col flex-1 min-w-0 overflow-hidden w-full">
30
- <TopBar onMenuClick={() => setIsSidebarOpen(true)} />
31
- <main className="page-shell flex-1 overflow-y-auto bg-transparent relative z-10 w-full p-2 md:p-0">
32
- <Outlet />
33
- </main>
34
- </div>
35
- </div>
36
- );
37
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//lib//aiService.ts DELETED
@@ -1,45 +0,0 @@
1
- import axios from 'axios';
2
-
3
- const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
4
-
5
- const TARGET_MODELS = [
6
- 'mistralai/mistral-small',
7
- 'mistralai/mistral-medium',
8
- 'mistralai/mistral-nemo' // fallback free-tier compatible model
9
- ];
10
-
11
- let currentModelIndex = 0;
12
-
13
- export async function getAiCompletion(prompt: string) {
14
- let attempts = 0;
15
-
16
- while (attempts < TARGET_MODELS.length) {
17
- const model = TARGET_MODELS[currentModelIndex];
18
- try {
19
- const response = await axios.post(
20
- OPENROUTER_API_URL,
21
- {
22
- model: model,
23
- messages: [{ role: 'user', content: prompt }],
24
- },
25
- {
26
- headers: {
27
- 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
28
- 'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000',
29
- 'X-Title': 'BRIDGEModulE Dashboard',
30
- },
31
- }
32
- );
33
-
34
- // If successful, we keep this model as the active one for next time
35
- return response.data.choices[0].message.content;
36
- } catch (error: any) {
37
- console.error(`Error with model ${model}:`, error?.response?.data || error.message);
38
- attempts++;
39
- // Rotate to the next model for the next attempt
40
- currentModelIndex = (currentModelIndex + 1) % TARGET_MODELS.length;
41
- }
42
- }
43
-
44
- throw new Error('All OpenRouter Mistral models failed. Please check your API key and quotas.');
45
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//lib//apiClient.ts DELETED
@@ -1,25 +0,0 @@
1
- import axios from "axios";
2
-
3
- export const apiClient = axios.create({
4
- baseURL: "/api",
5
- });
6
-
7
- apiClient.interceptors.request.use((config) => {
8
- // Read keys from localStorage to inject into request
9
- const keysStr = localStorage.getItem("smartlead_api_keys");
10
- const activeId = localStorage.getItem("smartlead_active_key_id");
11
-
12
- if (keysStr && activeId) {
13
- try {
14
- const keys = JSON.parse(keysStr);
15
- const activeKey = keys.find((k: any) => k.id === activeId)?.key;
16
- if (activeKey) {
17
- config.headers["x-api-key"] = activeKey;
18
- }
19
- } catch (e) {
20
- console.error("Failed to parse API keys", e);
21
- }
22
- }
23
-
24
- return config;
25
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//lib//supabaseClient.ts DELETED
@@ -1,27 +0,0 @@
1
- import { createClient } from '@supabase/supabase-js';
2
-
3
- const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
4
- const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
5
-
6
- export const supabase = supabaseUrl && supabaseAnonKey
7
- ? createClient(supabaseUrl, supabaseAnonKey)
8
- : null;
9
-
10
- export const isSupabaseConfigured = () => {
11
- return !!supabase;
12
- };
13
-
14
- /** Project ref derived from VITE_SUPABASE_URL (for status text + links). */
15
- export const supabaseProjectRef = (): string => {
16
- const url = import.meta.env.VITE_SUPABASE_URL as string | undefined;
17
- const m = url?.match(/^https:\/\/([a-z0-9-]+)\.supabase\.co/i);
18
- return m ? m[1] : "unconfigured";
19
- };
20
-
21
- /** Deep link to the configured project (or the dashboard when unset). */
22
- export const supabaseDashboardUrl = (): string => {
23
- const ref = supabaseProjectRef();
24
- return ref === "unconfigured"
25
- ? "https://supabase.com/dashboard"
26
- : `https://supabase.com/dashboard/project/${ref}`;
27
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//main.tsx DELETED
@@ -1,10 +0,0 @@
1
- import {StrictMode} from 'react';
2
- import {createRoot} from 'react-dom/client';
3
- import App from './App.tsx';
4
- import './index.css';
5
-
6
- createRoot(document.getElementById('root')!).render(
7
- <StrictMode>
8
- <App />
9
- </StrictMode>,
10
- );
 
 
 
 
 
 
 
 
 
 
 
src//pages//Analytics.test.tsx DELETED
@@ -1,83 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { render, screen, waitFor } from '@testing-library/react';
3
- import Analytics from './Analytics';
4
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
- import { apiClient } from '../lib/apiClient';
6
-
7
- // Mock recharts to avoid rendering complex SVG elements in JSDOM
8
- vi.mock('recharts', () => ({
9
- ResponsiveContainer: ({ children }: any) => <div>{children}</div>,
10
- LineChart: () => <div>LineChart</div>,
11
- BarChart: () => <div>BarChart</div>,
12
- Line: () => <div />,
13
- Bar: () => <div />,
14
- XAxis: () => <div />,
15
- YAxis: () => <div />,
16
- CartesianGrid: () => <div />,
17
- Tooltip: () => <div />,
18
- Legend: () => <div />
19
- }));
20
-
21
- vi.mock('../lib/apiClient', () => ({
22
- apiClient: {
23
- get: vi.fn()
24
- }
25
- }));
26
-
27
- const queryClient = new QueryClient({
28
- defaultOptions: {
29
- queries: {
30
- retry: false,
31
- },
32
- },
33
- });
34
-
35
- describe('Analytics Page', () => {
36
- beforeEach(() => {
37
- vi.clearAllMocks();
38
- });
39
-
40
- it('renders loading states initially', () => {
41
- (apiClient.get as any).mockImplementation(() => new Promise(() => {}));
42
-
43
- render(
44
- <QueryClientProvider client={queryClient}>
45
- <Analytics />
46
- </QueryClientProvider>
47
- );
48
-
49
- expect(screen.getByText(/Outbound Analytics & Intelligence/i)).toBeTruthy();
50
- });
51
-
52
- it('renders correctly with empty data', async () => {
53
- (apiClient.get as any).mockResolvedValue({ data: [] });
54
-
55
- render(
56
- <QueryClientProvider client={queryClient}>
57
- <Analytics />
58
- </QueryClientProvider>
59
- );
60
-
61
- await waitFor(() => {
62
- // Ensure the chart wrappers rendered
63
- expect(screen.getByText(/LineChart/i)).toBeTruthy();
64
- expect(screen.getByText(/BarChart/i)).toBeTruthy();
65
- });
66
- });
67
-
68
- it('handles network error state gracefully', async () => {
69
- (apiClient.get as any).mockRejectedValue(new Error('Network error'));
70
-
71
- render(
72
- <QueryClientProvider client={queryClient}>
73
- <Analytics />
74
- </QueryClientProvider>
75
- );
76
-
77
- // Should still render headers and fallback data
78
- await waitFor(() => {
79
- expect(screen.getByText(/Outbound Analytics & Intelligence/i)).toBeTruthy();
80
- expect(screen.getByText(/LineChart/i)).toBeTruthy(); // Falls back to static data currently
81
- });
82
- });
83
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Analytics.tsx DELETED
@@ -1,247 +0,0 @@
1
- import { useQuery } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- BarChart2,
5
- TrendingUp,
6
- Download,
7
- Calendar,
8
- Mail,
9
- Inbox,
10
- Flame,
11
- ShieldCheck,
12
- Percent,
13
- ArrowUpRight
14
- } from "lucide-react";
15
- import {
16
- ResponsiveContainer,
17
- LineChart,
18
- Line,
19
- BarChart,
20
- Bar,
21
- XAxis,
22
- YAxis,
23
- CartesianGrid,
24
- Tooltip,
25
- Legend
26
- } from "recharts";
27
- import { useState } from "react";
28
- import StatCard from "../components/StatCard";
29
- import CsvExportButton from "../components/CsvExportButton";
30
-
31
- const timelineData = [
32
- { date: "Oct 1", sent: 820, opens: 530, replies: 48, bounces: 4 },
33
- { date: "Oct 5", sent: 1140, opens: 760, replies: 72, bounces: 8 },
34
- { date: "Oct 10", sent: 1650, opens: 1120, replies: 98, bounces: 11 },
35
- { date: "Oct 15", sent: 2100, opens: 1450, replies: 134, bounces: 15 },
36
- { date: "Oct 20", sent: 2800, opens: 1980, replies: 182, bounces: 21 },
37
- { date: "Oct 25", sent: 3400, opens: 2450, replies: 226, bounces: 28 },
38
- { date: "Oct 30", sent: 4120, opens: 2980, replies: 280, bounces: 34 },
39
- ];
40
-
41
- const campaignComparisonData = [
42
- { name: "SaaS Enterprise VPs", sent: 4200, opens: 2600, replies: 240 },
43
- { name: "Series A Tech Founders", sent: 3100, opens: 2100, replies: 195 },
44
- { name: "Agency Owners Q4", sent: 2400, opens: 1400, replies: 120 },
45
- { name: "E-commerce Directors", sent: 1800, opens: 980, replies: 84 },
46
- { name: "RevOps Decision Makers", sent: 1200, opens: 720, replies: 65 },
47
- ];
48
-
49
- export default function Analytics() {
50
- const [dateRange, setDateRange] = useState("30d");
51
-
52
- const { data: analytics } = useQuery({
53
- queryKey: ["analytics-campaigns"],
54
- queryFn: async () => {
55
- const res = await apiClient.get("/analytics/campaigns");
56
- return res.data;
57
- },
58
- });
59
-
60
- const { data: emailAccounts = [] } = useQuery({
61
- queryKey: ["email-accounts"],
62
- queryFn: async () => {
63
- const res = await apiClient.get("/email-accounts");
64
- return res.data;
65
- },
66
- });
67
-
68
- return (
69
- <div className="p-6 max-w-7xl mx-auto space-y-6">
70
- {/* Header */}
71
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
72
- <div>
73
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
74
- <BarChart2 className="w-6 h-6 text-indigo-400" />
75
- Outbound Analytics & Intelligence
76
- </h1>
77
- <p className="text-zinc-400 text-sm mt-1">
78
- Global deliverability performance, engagement conversion funnels, and mailbox health telemetry.
79
- </p>
80
- </div>
81
-
82
- <div className="flex items-center gap-3">
83
- <select
84
- value={dateRange}
85
- onChange={(e) => setDateRange(e.target.value)}
86
- className="bg-zinc-900 border border-zinc-800 rounded-lg py-2 px-3 text-xs text-zinc-200 focus:outline-none focus:border-indigo-500"
87
- >
88
- <option value="7d">Last 7 Days</option>
89
- <option value="14d">Last 14 Days</option>
90
- <option value="30d">Last 30 Days</option>
91
- <option value="90d">Last 90 Days</option>
92
- </select>
93
- <CsvExportButton data={timelineData} filename="smartlead-analytics-export.csv" />
94
- </div>
95
- </div>
96
-
97
- {/* KPI Conversion Cards */}
98
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
99
- <StatCard
100
- title="Open Rate"
101
- value="64.8%"
102
- subtitle="4,890 unique opens"
103
- icon={Mail}
104
- iconColor="emerald"
105
- change={{ value: "+3.4%", isPositive: true, period: "vs benchmark" }}
106
- />
107
- <StatCard
108
- title="Click Rate (CTR)"
109
- value="18.2%"
110
- subtitle="1,120 link clicks"
111
- icon={ArrowUpRight}
112
- iconColor="blue"
113
- change={{ value: "+1.1%", isPositive: true, period: "last 30 days" }}
114
- />
115
- <StatCard
116
- title="Reply Rate"
117
- value="6.2%"
118
- subtitle="378 prospect responses"
119
- icon={Inbox}
120
- iconColor="purple"
121
- change={{ value: "+0.8%", isPositive: true, period: "positive intent" }}
122
- />
123
- <StatCard
124
- title="Bounce Rate"
125
- value="0.7%"
126
- subtitle="Deliverability safe"
127
- icon={ShieldCheck}
128
- iconColor="teal"
129
- change={{ value: "-0.2%", isPositive: true, period: "health optimal" }}
130
- />
131
- </div>
132
-
133
- {/* Timeline Chart */}
134
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
135
- <div className="flex items-center justify-between mb-6">
136
- <div>
137
- <h3 className="text-base font-semibold text-zinc-100">Cumulative Volume & Responses</h3>
138
- <p className="text-xs text-zinc-400 mt-0.5">
139
- Daily trajectory of outbound sends, unique opens, and replied prospects.
140
- </p>
141
- </div>
142
- </div>
143
-
144
- <div className="h-[320px] w-full">
145
- <ResponsiveContainer width="100%" height="100%">
146
- <LineChart data={timelineData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
147
- <CartesianGrid strokeDasharray="3 3" stroke="rgba(246,248,242,0.05)" vertical={false} />
148
- <XAxis dataKey="date" stroke="var(--nt-text-muted)" fontSize={12} tickLine={false} axisLine={false} />
149
- <YAxis stroke="var(--nt-text-muted)" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => new Intl.NumberFormat('en-US').format(val)} className="font-mono" />
150
- <Tooltip
151
- contentStyle={{
152
- backgroundColor: "rgba(6, 10, 12, 0.88)",
153
- backdropFilter: "blur(12px)",
154
- borderColor: "rgba(37,125,104,0.2)",
155
- borderRadius: "12px",
156
- fontSize: "12px",
157
- color: "var(--nt-text-primary)"
158
- }}
159
- />
160
- <Legend wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }} />
161
- <Line type="monotone" dataKey="sent" name="Emails Sent" stroke="var(--nt-deep-teal-600)" strokeWidth={3} dot={{ r: 4, fill: 'var(--nt-deep-teal-600)', strokeWidth: 2, stroke: 'var(--nt-focus-ring-offset)' }} activeDot={{ r: 6, fill: 'var(--nt-deep-teal-700)' }} animationDuration={1500} />
162
- <Line type="monotone" dataKey="opens" name="Opens" stroke="var(--nt-deep-teal-500)" strokeWidth={3} dot={{ r: 4, fill: 'var(--nt-deep-teal-500)', strokeWidth: 2, stroke: 'var(--nt-focus-ring-offset)' }} activeDot={{ r: 6, fill: 'var(--nt-deep-teal-700)' }} animationDuration={1500} />
163
- <Line type="monotone" dataKey="replies" name="Replies" stroke="var(--nt-terracotta-500)" strokeWidth={3} dot={{ r: 4, fill: 'var(--nt-terracotta-500)', strokeWidth: 2, stroke: 'var(--nt-focus-ring-offset)' }} activeDot={{ r: 6, fill: 'var(--nt-terracotta-700)' }} animationDuration={1500} />
164
- </LineChart>
165
- </ResponsiveContainer>
166
- </div>
167
- </div>
168
-
169
- {/* Comparison Grid */}
170
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
171
- {/* Top Campaigns Comparison */}
172
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
173
- <h3 className="text-base font-semibold text-zinc-100 mb-1">Top Campaign Performance</h3>
174
- <p className="text-xs text-zinc-400 mb-4">Volume dispatched vs. engaged prospects.</p>
175
-
176
- <div className="h-[280px] w-full">
177
- <ResponsiveContainer width="100%" height="100%">
178
- <BarChart data={campaignComparisonData} layout="vertical" margin={{ top: 5, right: 20, bottom: 5, left: 40 }}>
179
- <defs>
180
- <linearGradient id="colorOpens" x1="0" y1="0" x2="1" y2="0">
181
- <stop offset="0%" stopColor="var(--nt-deep-teal-500)" stopOpacity={0.8}/>
182
- <stop offset="100%" stopColor="var(--nt-deep-teal-700)" stopOpacity={1}/>
183
- </linearGradient>
184
- <linearGradient id="colorReplies" x1="0" y1="0" x2="1" y2="0">
185
- <stop offset="0%" stopColor="var(--nt-terracotta-400)" stopOpacity={0.8}/>
186
- <stop offset="100%" stopColor="var(--nt-terracotta-700)" stopOpacity={1}/>
187
- </linearGradient>
188
- </defs>
189
- <CartesianGrid strokeDasharray="3 3" stroke="rgba(246,248,242,0.05)" horizontal={false} />
190
- <XAxis type="number" stroke="var(--nt-text-muted)" fontSize={11} tickLine={false} axisLine={false} className="font-mono" />
191
- <YAxis dataKey="name" type="category" stroke="var(--nt-text-muted)" fontSize={11} tickLine={false} axisLine={false} />
192
- <Tooltip
193
- contentStyle={{
194
- backgroundColor: "rgba(6, 10, 12, 0.88)",
195
- backdropFilter: "blur(12px)",
196
- borderColor: "rgba(37,125,104,0.2)",
197
- borderRadius: "12px",
198
- fontSize: "12px",
199
- }}
200
- />
201
- <Bar dataKey="opens" name="Opens" fill="url(#colorOpens)" radius={[0, 4, 4, 0]} animationDuration={1500}>
202
- </Bar>
203
- <Bar dataKey="replies" name="Replies" fill="url(#colorReplies)" radius={[0, 4, 4, 0]} animationDuration={1500} />
204
- </BarChart>
205
- </ResponsiveContainer>
206
- </div>
207
- </div>
208
-
209
- {/* Mailbox Deliverability Health Matrix */}
210
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)] flex flex-col justify-between">
211
- <div>
212
- <h3 className="text-base font-semibold text-zinc-100 mb-1">Mailbox Deliverability Matrix</h3>
213
- <p className="text-xs text-zinc-400 mb-4">DNS authentication, warmup status, and health metrics.</p>
214
-
215
- <div className="space-y-3">
216
- {emailAccounts.map((acc: any) => (
217
- <div
218
- key={acc.id}
219
- className="flex items-center justify-between p-3 bg-white/5 border border-white/10 rounded-lg text-xs hover:bg-white/10 transition-colors"
220
- >
221
- <div>
222
- <span className="font-semibold text-zinc-200">{acc.from_email}</span>
223
- <div className="text-[10px] text-zinc-400 mt-0.5">
224
- SPF: PASS • DKIM: PASS • DMARC: PASS
225
- </div>
226
- </div>
227
-
228
- <div className="flex items-center gap-3 font-mono">
229
- <span className="text-emerald-400 font-bold drop-shadow-[0_0_8px_rgba(63,156,132,0.5)]">99.4%</span>
230
- <span className="px-2 py-0.5 bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 rounded text-[10px]">
231
- Optimal
232
- </span>
233
- </div>
234
- </div>
235
- ))}
236
- </div>
237
- </div>
238
-
239
- <div className="mt-4 pt-3 border-t border-white/10 text-[11px] text-zinc-400 flex items-center justify-between">
240
- <span>Global Reputation: <span className="font-mono text-zinc-300">98.4 / 100</span></span>
241
- <span className="text-emerald-400 flex items-center gap-1.5"><ShieldCheck className="w-3.5 h-3.5" /> All Nodes Secure</span>
242
- </div>
243
- </div>
244
- </div>
245
- </div>
246
- );
247
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//CampaignDetail.tsx DELETED
@@ -1,727 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import { useParams, Link } from "react-router-dom";
4
- import {
5
- ArrowLeft,
6
- Megaphone,
7
- Layers,
8
- Mail,
9
- Users,
10
- BarChart2,
11
- Settings as SettingsIcon,
12
- Plus,
13
- Trash2,
14
- Play,
15
- Pause,
16
- Clock,
17
- CheckCircle2,
18
- Sparkles,
19
- Download,
20
- Send,
21
- Split,
22
- RefreshCw
23
- } from "lucide-react";
24
- import { useState } from "react";
25
- import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
26
- import StatusBadge from "../components/StatusBadge";
27
- import StatCard from "../components/StatCard";
28
- import RichTextEditor from "../components/RichTextEditor";
29
- import FormDrawer from "../components/FormDrawer";
30
- import CsvExportButton from "../components/CsvExportButton";
31
- import toast from "react-hot-toast";
32
-
33
- export default function CampaignDetail() {
34
- const { id } = useParams<{ id: string }>();
35
- const queryClient = useQueryClient();
36
- const [activeTab, setActiveTab] = useState<"overview" | "sequences" | "accounts" | "leads" | "analytics" | "settings">("overview");
37
- const [isAddLeadOpen, setIsAddLeadOpen] = useState(false);
38
- const [selectedVariantTab, setSelectedVariantTab] = useState<Record<string, number>>({});
39
-
40
- const { data: campaign, isLoading, isError, refetch: refetchCampaign } = useQuery({
41
- queryKey: ["campaign", id],
42
- queryFn: async () => {
43
- const res = await apiClient.get(`/campaigns/${id}`);
44
- return res.data;
45
- },
46
- enabled: Boolean(id),
47
- });
48
-
49
- const { data: sequences = [], refetch: refetchSequences } = useQuery({
50
- queryKey: ["campaign-sequences", id],
51
- queryFn: async () => {
52
- const res = await apiClient.get(`/campaigns/${id}/sequences`);
53
- return res.data;
54
- },
55
- enabled: Boolean(id),
56
- });
57
-
58
- const { data: campaignLeads = [], refetch: refetchLeads } = useQuery({
59
- queryKey: ["campaign-leads", id],
60
- queryFn: async () => {
61
- const res = await apiClient.get(`/campaigns/${id}/leads`);
62
- return res.data;
63
- },
64
- enabled: Boolean(id),
65
- });
66
-
67
- const { data: assignedAccounts = [], refetch: refetchAccounts } = useQuery({
68
- queryKey: ["campaign-accounts", id],
69
- queryFn: async () => {
70
- const res = await apiClient.get(`/campaigns/${id}/email-accounts`);
71
- return res.data;
72
- },
73
- enabled: Boolean(id),
74
- });
75
-
76
- const { data: allEmailAccounts = [] } = useQuery({
77
- queryKey: ["email-accounts"],
78
- queryFn: async () => {
79
- const res = await apiClient.get("/email-accounts");
80
- return res.data;
81
- },
82
- });
83
-
84
- const { data: analyticsHistory = [] } = useQuery({
85
- queryKey: ["campaign-analytics-date", id],
86
- queryFn: async () => {
87
- const res = await apiClient.get(`/campaigns/${id}/analytics-by-date`);
88
- return res.data;
89
- },
90
- enabled: Boolean(id),
91
- });
92
-
93
- // Save sequences mutation
94
- const saveSequencesMutation = useMutation({
95
- mutationFn: async (updatedSequences: any[]) => {
96
- const res = await apiClient.post(`/campaigns/${id}/sequences`, { sequences: updatedSequences });
97
- return res.data;
98
- },
99
- onSuccess: () => {
100
- toast.success("Sequence saved successfully");
101
- refetchSequences();
102
- queryClient.invalidateQueries({ queryKey: ["campaign", id] });
103
- },
104
- onError: (err: any) => {
105
- toast.error(err.response?.data?.error || "Failed to save sequences");
106
- },
107
- });
108
-
109
- const addSequenceStep = () => {
110
- const nextSeqNum = (sequences.length || 0) + 1;
111
- const newStep = {
112
- id: "seq_" + Math.random().toString(36).substring(2, 7),
113
- seq_number: nextSeqNum,
114
- delay_days: nextSeqNum === 1 ? 0 : 3,
115
- subject: `Follow-up #${nextSeqNum - 1}: Re: {{company_name}}`,
116
- email_body: `<p>Hi {{first_name}},</p><p>Wanted to quickly circle back on my earlier note. Any thoughts?</p>`,
117
- variant_distribution_percentage: 100,
118
- variants: [],
119
- };
120
- saveSequencesMutation.mutate([...sequences, newStep]);
121
- };
122
-
123
- const removeSequenceStep = (stepIndex: number) => {
124
- const filtered = sequences.filter((_: any, idx: number) => idx !== stepIndex);
125
- saveSequencesMutation.mutate(filtered);
126
- };
127
-
128
- const addVariantToStep = (stepIndex: number) => {
129
- const updated = [...sequences];
130
- const target = updated[stepIndex];
131
- if (!target.variants) target.variants = [];
132
- target.variant_distribution_percentage = 50;
133
- target.variants.push({
134
- id: "var_" + Math.random().toString(36).substring(2, 7),
135
- variant_label: `Variant ${String.fromCharCode(66 + target.variants.length)}`,
136
- subject: `Alternative: ${target.subject}`,
137
- email_body: `<p>Hey {{first_name}}, short follow up regarding {{company_name}}.</p>`,
138
- });
139
- saveSequencesMutation.mutate(updated);
140
- };
141
-
142
- const addAccountToCampaign = async (accountId: string) => {
143
- try {
144
- await apiClient.post(`/campaigns/${id}/email-accounts`, { email_account_id: accountId });
145
- toast.success("Mailbox assigned to campaign");
146
- refetchAccounts();
147
- } catch (err: any) {
148
- toast.error("Failed to assign account");
149
- }
150
- };
151
-
152
- const removeAccountFromCampaign = async (accountId: string) => {
153
- try {
154
- await apiClient.delete(`/campaigns/${id}/email-accounts/${accountId}`);
155
- toast.success("Mailbox removed from campaign");
156
- refetchAccounts();
157
- } catch (err: any) {
158
- toast.error("Failed to remove account");
159
- }
160
- };
161
-
162
- if (isLoading) {
163
- return (
164
- <div className="p-12 flex justify-center items-center">
165
- <div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin" />
166
- </div>
167
- );
168
- }
169
-
170
- if (isError || !campaign) {
171
- return (
172
- <div className="p-12 flex flex-col items-center justify-center gap-3 text-center">
173
- <h1 className="text-lg font-semibold text-zinc-100">Campaign unavailable</h1>
174
- <p className="text-sm text-zinc-400">We couldn’t load this campaign. Try again or return to the campaign list.</p>
175
- <div className="flex items-center gap-2">
176
- <button type="button" onClick={() => refetchCampaign()} className="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg inline-flex items-center gap-2">
177
- <RefreshCw className="w-3.5 h-3.5" /> Retry
178
- </button>
179
- <Link to="/campaigns" className="px-3 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium rounded-lg">
180
- Back to campaigns
181
- </Link>
182
- </div>
183
- </div>
184
- );
185
- }
186
-
187
- return (
188
- <div className="p-6 max-w-7xl mx-auto space-y-6">
189
- {/* Back Navigation and Header */}
190
- <div className="space-y-3">
191
- <Link
192
- to="/campaigns"
193
- className="inline-flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
194
- >
195
- <ArrowLeft className="w-3.5 h-3.5" /> Back to all campaigns
196
- </Link>
197
-
198
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
199
- <div>
200
- <div className="flex items-center gap-3">
201
- <h1 className="text-2xl font-bold text-zinc-100 tracking-tight">{campaign.name}</h1>
202
- <StatusBadge status={campaign.status} />
203
- </div>
204
- <p className="text-xs text-zinc-400 mt-1">
205
- Timezone: {campaign.timezone} • Schedule: {campaign.start_schedule} - {campaign.end_schedule}
206
- </p>
207
- </div>
208
-
209
- <div className="flex items-center gap-2">
210
- <button
211
- onClick={() => setIsAddLeadOpen(true)}
212
- className="px-3.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5"
213
- >
214
- <Plus className="w-3.5 h-3.5" /> Add Leads
215
- </button>
216
- </div>
217
- </div>
218
- </div>
219
-
220
- {/* Tabs Bar */}
221
- <div className="border-b border-zinc-800 flex items-center gap-1 text-sm font-medium overflow-x-auto">
222
- {[
223
- { key: "overview", label: "Overview", icon: BarChart2 },
224
- { key: "sequences", label: `Sequences (${sequences.length})`, icon: Layers },
225
- { key: "accounts", label: `Email Accounts (${assignedAccounts.length})`, icon: Mail },
226
- { key: "leads", label: `Leads (${campaignLeads.length})`, icon: Users },
227
- { key: "analytics", label: "Analytics", icon: BarChart2 },
228
- { key: "settings", label: "Settings", icon: SettingsIcon },
229
- ].map((tab) => (
230
- <button
231
- key={tab.key}
232
- onClick={() => setActiveTab(tab.key as any)}
233
- className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors whitespace-nowrap ${
234
- activeTab === tab.key
235
- ? "border-indigo-500 text-indigo-400 bg-indigo-500/5"
236
- : "border-transparent text-zinc-400 hover:text-zinc-200 hover:border-zinc-700"
237
- }`}
238
- >
239
- <tab.icon className="w-4 h-4" />
240
- {tab.label}
241
- </button>
242
- ))}
243
- </div>
244
-
245
- {/* TAB 1: OVERVIEW */}
246
- {activeTab === "overview" && (
247
- <div className="space-y-6">
248
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
249
- <StatCard
250
- title="Leads in Sequence"
251
- value={campaign.lead_count || 0}
252
- icon={Users}
253
- iconColor="teal"
254
- />
255
- <StatCard
256
- title="Emails Sent"
257
- value={campaign.sent_count || 0}
258
- icon={Send}
259
- iconColor="blue"
260
- />
261
- <StatCard
262
- title="Unique Opens"
263
- value={campaign.open_count || 0}
264
- subtitle={`${((campaign.open_count / (campaign.sent_count || 1)) * 100).toFixed(1)}% Open Rate`}
265
- icon={Mail}
266
- iconColor="emerald"
267
- />
268
- <StatCard
269
- title="Replies Received"
270
- value={campaign.reply_count || 0}
271
- subtitle={`${((campaign.reply_count / (campaign.sent_count || 1)) * 100).toFixed(1)}% Reply Rate`}
272
- icon={CheckCircle2}
273
- iconColor="purple"
274
- />
275
- </div>
276
-
277
- {/* 14-Day Trajectory */}
278
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 shadow-sm">
279
- <h3 className="text-sm font-semibold text-zinc-100 mb-4">
280
- Engagement Trajectory (Last 14 Days)
281
- </h3>
282
- <div className="h-[260px] w-full">
283
- <ResponsiveContainer width="100%" height="100%">
284
- <LineChart data={analyticsHistory} margin={{ top: 5, right: 10, bottom: 5, left: -15 }}>
285
- <CartesianGrid strokeDasharray="3 3" stroke="var(--nt-border-subtle)" vertical={false} />
286
- <XAxis dataKey="date" stroke="var(--nt-text-muted)" fontSize={11} tickLine={false} axisLine={false} />
287
- <YAxis stroke="var(--nt-text-muted)" fontSize={11} tickLine={false} axisLine={false} />
288
- <Tooltip
289
- contentStyle={{ backgroundColor: "var(--nt-bg-root)", borderColor: "var(--nt-border-accent)", borderRadius: "8px", fontSize: "12px" }}
290
- />
291
- <Line type="monotone" dataKey="sent" stroke="var(--nt-deep-teal-600)" strokeWidth={2} name="Sent" dot={false} />
292
- <Line type="monotone" dataKey="opens" stroke="var(--nt-deep-teal-500)" strokeWidth={2} name="Opens" dot={false} />
293
- <Line type="monotone" dataKey="replies" stroke="var(--nt-terracotta-500)" strokeWidth={2} name="Replies" dot={false} />
294
- </LineChart>
295
- </ResponsiveContainer>
296
- </div>
297
- </div>
298
- </div>
299
- )}
300
-
301
- {/* TAB 2: SEQUENCES WITH A/B VARIANTS */}
302
- {activeTab === "sequences" && (
303
- <div className="space-y-6">
304
- <div className="flex items-center justify-between">
305
- <div>
306
- <h3 className="text-base font-semibold text-zinc-100">Multi-Step Sequence Flow</h3>
307
- <p className="text-xs text-zinc-400 mt-0.5">
308
- Set delay days, customize subject lines, and configure A/B variants per email step.
309
- </p>
310
- </div>
311
- <button
312
- onClick={addSequenceStep}
313
- className="px-3.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5"
314
- >
315
- <Plus className="w-3.5 h-3.5" /> Add Email Step
316
- </button>
317
- </div>
318
-
319
- <div className="space-y-4">
320
- {sequences.map((step: any, stepIndex: number) => {
321
- const currentVariantTab = selectedVariantTab[step.id] || 0;
322
- const hasVariants = step.variants && step.variants.length > 0;
323
-
324
- return (
325
- <div key={step.id || stepIndex} className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-sm">
326
- {/* Step Header */}
327
- <div className="px-5 py-3.5 bg-zinc-950 border-b border-zinc-800 flex items-center justify-between">
328
- <div className="flex items-center gap-3">
329
- <span className="w-6 h-6 rounded-full bg-indigo-500/20 text-indigo-400 font-bold text-xs flex items-center justify-center border border-indigo-500/30">
330
- {step.seq_number}
331
- </span>
332
- <div>
333
- <span className="text-xs font-semibold text-zinc-200">
334
- {step.seq_number === 1 ? "Initial Outreach (Step 1)" : `Follow-up #${step.seq_number - 1}`}
335
- </span>
336
- <div className="flex items-center gap-2 text-[11px] text-zinc-400 mt-0.5">
337
- <Clock className="w-3 h-3 text-zinc-500" />
338
- <span>Delay:</span>
339
- <input
340
- type="number"
341
- min="0"
342
- value={step.delay_days}
343
- onChange={(e) => {
344
- const updated = [...sequences];
345
- updated[stepIndex].delay_days = Number(e.target.value);
346
- saveSequencesMutation.mutate(updated);
347
- }}
348
- className="w-12 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 text-xs text-zinc-100"
349
- />
350
- <span>days after previous email</span>
351
- </div>
352
- </div>
353
- </div>
354
-
355
- <div className="flex items-center gap-2">
356
- <button
357
- type="button"
358
- onClick={() => addVariantToStep(stepIndex)}
359
- className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-medium rounded-md transition-colors flex items-center gap-1"
360
- >
361
- <Split className="w-3.5 h-3.5 text-indigo-400" /> + A/B Variant
362
- </button>
363
- <button
364
- type="button"
365
- onClick={() => removeSequenceStep(stepIndex)}
366
- className="p-1.5 text-zinc-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-md transition-colors"
367
- >
368
- <Trash2 className="w-4 h-4" />
369
- </button>
370
- </div>
371
- </div>
372
-
373
- {/* Step Body & Variant Tabs */}
374
- <div className="p-5 space-y-4">
375
- {hasVariants && (
376
- <div className="flex items-center justify-between border-b border-zinc-800 pb-3">
377
- <div className="flex items-center gap-2">
378
- <button
379
- type="button"
380
- onClick={() => setSelectedVariantTab((prev) => ({ ...prev, [step.id]: 0 }))}
381
- className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
382
- currentVariantTab === 0 ? "bg-indigo-600 text-white" : "bg-zinc-800 text-zinc-400"
383
- }`}
384
- >
385
- Variant A (Default)
386
- </button>
387
- {step.variants.map((v: any, vIdx: number) => (
388
- <button
389
- key={v.id || vIdx}
390
- type="button"
391
- onClick={() => setSelectedVariantTab((prev) => ({ ...prev, [step.id]: vIdx + 1 }))}
392
- className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
393
- currentVariantTab === vIdx + 1 ? "bg-indigo-600 text-white" : "bg-zinc-800 text-zinc-400"
394
- }`}
395
- >
396
- {v.variant_label || `Variant ${String.fromCharCode(66 + vIdx)}`}
397
- </button>
398
- ))}
399
- </div>
400
-
401
- <div className="text-xs text-zinc-400 font-mono">
402
- Split Traffic: 50% / 50%
403
- </div>
404
- </div>
405
- )}
406
-
407
- {/* Active Variant Editor */}
408
- {currentVariantTab === 0 ? (
409
- <div className="space-y-3">
410
- <div>
411
- <label className="block text-xs font-medium text-zinc-400 mb-1">
412
- Subject Line (Variant A)
413
- </label>
414
- <input
415
- type="text"
416
- value={step.subject}
417
- onChange={(e) => {
418
- const updated = [...sequences];
419
- updated[stepIndex].subject = e.target.value;
420
- saveSequencesMutation.mutate(updated);
421
- }}
422
- className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-xs text-zinc-100 focus:outline-none focus:border-indigo-500"
423
- />
424
- </div>
425
-
426
- <div>
427
- <label className="block text-xs font-medium text-zinc-400 mb-1">
428
- Email Body (Variant A)
429
- </label>
430
- <RichTextEditor
431
- content={step.email_body}
432
- onChange={(html) => {
433
- const updated = [...sequences];
434
- updated[stepIndex].email_body = html;
435
- saveSequencesMutation.mutate(updated);
436
- }}
437
- />
438
- </div>
439
- </div>
440
- ) : (
441
- <div className="space-y-3">
442
- <div>
443
- <label className="block text-xs font-medium text-zinc-400 mb-1">
444
- Subject Line ({step.variants[currentVariantTab - 1]?.variant_label})
445
- </label>
446
- <input
447
- type="text"
448
- value={step.variants[currentVariantTab - 1]?.subject || ""}
449
- onChange={(e) => {
450
- const updated = [...sequences];
451
- updated[stepIndex].variants[currentVariantTab - 1].subject = e.target.value;
452
- saveSequencesMutation.mutate(updated);
453
- }}
454
- className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-xs text-zinc-100 focus:outline-none focus:border-indigo-500"
455
- />
456
- </div>
457
-
458
- <div>
459
- <label className="block text-xs font-medium text-zinc-400 mb-1">
460
- Email Body ({step.variants[currentVariantTab - 1]?.variant_label})
461
- </label>
462
- <RichTextEditor
463
- content={step.variants[currentVariantTab - 1]?.email_body || ""}
464
- onChange={(html) => {
465
- const updated = [...sequences];
466
- updated[stepIndex].variants[currentVariantTab - 1].email_body = html;
467
- saveSequencesMutation.mutate(updated);
468
- }}
469
- />
470
- </div>
471
- </div>
472
- )}
473
- </div>
474
- </div>
475
- );
476
- })}
477
- </div>
478
- </div>
479
- )}
480
-
481
- {/* TAB 3: EMAIL ACCOUNTS ASSIGNMENT */}
482
- {activeTab === "accounts" && (
483
- <div className="space-y-6">
484
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
485
- <h3 className="text-base font-semibold text-zinc-100 mb-1">Assigned Sending Inboxes</h3>
486
- <p className="text-xs text-zinc-400 mb-4">
487
- Emails will be rotated across these connected mailboxes to preserve deliverability.
488
- </p>
489
-
490
- <div className="space-y-3">
491
- {assignedAccounts.map((acc: any) => (
492
- <div
493
- key={acc.id}
494
- className="flex items-center justify-between p-3 bg-zinc-950 border border-zinc-800 rounded-lg"
495
- >
496
- <div className="flex items-center gap-3">
497
- <div className="p-2 bg-indigo-500/10 text-indigo-400 rounded-md">
498
- <Mail className="w-4 h-4" />
499
- </div>
500
- <div>
501
- <span className="text-xs font-semibold text-zinc-200">{acc.from_name}</span>
502
- <div className="text-[11px] text-zinc-400">{acc.from_email}</div>
503
- </div>
504
- </div>
505
-
506
- <div className="flex items-center gap-3">
507
- <StatusBadge status={acc.status} size="sm" />
508
- <button
509
- onClick={() => removeAccountFromCampaign(acc.id)}
510
- className="text-xs text-rose-400 hover:text-rose-300 font-medium"
511
- >
512
- Remove
513
- </button>
514
- </div>
515
- </div>
516
- ))}
517
-
518
- {assignedAccounts.length === 0 && (
519
- <div className="p-6 text-center text-xs text-zinc-500 bg-zinc-950/60 rounded-lg border border-dashed border-zinc-800">
520
- No email accounts assigned yet. Select from available mailboxes below.
521
- </div>
522
- )}
523
- </div>
524
- </div>
525
-
526
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
527
- <h4 className="text-xs font-semibold uppercase tracking-wider text-zinc-400 mb-3">
528
- Available Mailboxes
529
- </h4>
530
- <div className="space-y-2">
531
- {allEmailAccounts
532
- .filter((a: any) => !assignedAccounts.some((aa: any) => aa.id === a.id))
533
- .map((acc: any) => (
534
- <div
535
- key={acc.id}
536
- className="flex items-center justify-between p-3 bg-zinc-950/40 border border-zinc-800/80 rounded-lg"
537
- >
538
- <div>
539
- <span className="text-xs font-medium text-zinc-200">{acc.from_email}</span>
540
- <div className="text-[10px] text-zinc-500">
541
- Provider: {acc.provider} • Daily limit: {acc.daily_limit} emails
542
- </div>
543
- </div>
544
- <button
545
- onClick={() => addAccountToCampaign(acc.id)}
546
- className="px-3 py-1 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded text-xs font-medium transition-colors"
547
- >
548
- + Assign to Campaign
549
- </button>
550
- </div>
551
- ))}
552
- </div>
553
- </div>
554
- </div>
555
- )}
556
-
557
- {/* TAB 4: LEADS */}
558
- {activeTab === "leads" && (
559
- <div className="space-y-4">
560
- <div className="flex items-center justify-between">
561
- <div className="text-xs text-zinc-400">
562
- Showing {campaignLeads.length} leads in this campaign.
563
- </div>
564
- <div className="flex items-center gap-2">
565
- <CsvExportButton data={campaignLeads} filename={`campaign-${id}-leads.csv`} />
566
- <button
567
- onClick={() => setIsAddLeadOpen(true)}
568
- className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5"
569
- >
570
- <Plus className="w-3.5 h-3.5" /> Add Lead
571
- </button>
572
- </div>
573
- </div>
574
-
575
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
576
- <table className="w-full text-sm text-left">
577
- <thead className="bg-zinc-950 border-b border-zinc-800 text-zinc-400 text-xs uppercase">
578
- <tr>
579
- <th className="px-5 py-3 font-semibold">Lead</th>
580
- <th className="px-5 py-3 font-semibold">Company</th>
581
- <th className="px-5 py-3 font-semibold">Status</th>
582
- <th className="px-5 py-3 font-semibold">Opens</th>
583
- <th className="px-5 py-3 font-semibold">Clicks</th>
584
- </tr>
585
- </thead>
586
- <tbody className="divide-y divide-zinc-800">
587
- {campaignLeads.map((lead: any) => (
588
- <tr key={lead.id} className="hover:bg-zinc-800/30">
589
- <td className="px-5 py-3.5">
590
- <div className="font-medium text-zinc-200">
591
- {lead.first_name} {lead.last_name}
592
- </div>
593
- <div className="text-xs text-zinc-400 font-mono">{lead.email}</div>
594
- </td>
595
- <td className="px-5 py-3.5 text-zinc-300">{lead.company_name}</td>
596
- <td className="px-5 py-3.5">
597
- <StatusBadge status={lead.status} size="sm" />
598
- </td>
599
- <td className="px-5 py-3.5 text-zinc-300 font-mono">{lead.open_count || 0}</td>
600
- <td className="px-5 py-3.5 text-zinc-300 font-mono">{lead.click_count || 0}</td>
601
- </tr>
602
- ))}
603
- </tbody>
604
- </table>
605
- </div>
606
- </div>
607
- )}
608
-
609
- {/* TAB 5: ANALYTICS */}
610
- {activeTab === "analytics" && (
611
- <div className="space-y-6">
612
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
613
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
614
- <span className="text-xs text-zinc-400">Open Rate</span>
615
- <div className="text-2xl font-bold text-emerald-400 mt-1">56.2%</div>
616
- <span className="text-[11px] text-zinc-500">Above industry avg (28%)</span>
617
- </div>
618
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
619
- <span className="text-xs text-zinc-400">Reply Rate</span>
620
- <div className="text-2xl font-bold text-purple-400 mt-1">6.4%</div>
621
- <span className="text-[11px] text-zinc-500">Strong positive intent</span>
622
- </div>
623
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
624
- <span className="text-xs text-zinc-400">Bounce Rate</span>
625
- <div className="text-2xl font-bold text-rose-400 mt-1">0.6%</div>
626
- <span className="text-[11px] text-zinc-500">Safe zone (&lt; 2%)</span>
627
- </div>
628
- </div>
629
- </div>
630
- )}
631
-
632
- {/* TAB 6: SETTINGS */}
633
- {activeTab === "settings" && (
634
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 max-w-2xl">
635
- <h3 className="text-base font-semibold text-zinc-100 mb-4">Campaign Parameters</h3>
636
- <div className="space-y-4 text-xs">
637
- <div>
638
- <label className="block text-zinc-400 font-medium mb-1">Timezone</label>
639
- <input
640
- disabled
641
- value={campaign.timezone}
642
- className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-zinc-300"
643
- />
644
- </div>
645
- <div>
646
- <label className="block text-zinc-400 font-medium mb-1">Unsubscribe Footer</label>
647
- <input
648
- disabled
649
- value={campaign.unsubscribe_text}
650
- className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-zinc-300"
651
- />
652
- </div>
653
- </div>
654
- </div>
655
- )}
656
-
657
- {/* Add Lead Slide-Over */}
658
- <FormDrawer
659
- isOpen={isAddLeadOpen}
660
- onClose={() => setIsAddLeadOpen(false)}
661
- title="Add Lead to Campaign"
662
- subtitle="Upload prospect details to be enrolled into this sequence."
663
- >
664
- <form
665
- onSubmit={async (e) => {
666
- e.preventDefault();
667
- const form = e.target as HTMLFormElement;
668
- const newLead = {
669
- first_name: (form.elements.namedItem("first_name") as HTMLInputElement).value,
670
- last_name: (form.elements.namedItem("last_name") as HTMLInputElement).value,
671
- email: (form.elements.namedItem("email") as HTMLInputElement).value,
672
- company_name: (form.elements.namedItem("company_name") as HTMLInputElement).value,
673
- website: (form.elements.namedItem("website") as HTMLInputElement).value,
674
- status: "INTERESTED",
675
- };
676
-
677
- try {
678
- await apiClient.post(`/campaigns/${id}/leads`, newLead);
679
- toast.success("Lead enrolled in campaign");
680
- setIsAddLeadOpen(false);
681
- refetchLeads();
682
- } catch (err: any) {
683
- toast.error("Failed to add lead");
684
- }
685
- }}
686
- className="space-y-4 text-xs"
687
- >
688
- <div>
689
- <label className="block font-medium text-zinc-300 mb-1">First Name *</label>
690
- <input name="first_name" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
691
- </div>
692
- <div>
693
- <label className="block font-medium text-zinc-300 mb-1">Last Name</label>
694
- <input name="last_name" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
695
- </div>
696
- <div>
697
- <label className="block font-medium text-zinc-300 mb-1">Email *</label>
698
- <input type="email" name="email" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
699
- </div>
700
- <div>
701
- <label className="block font-medium text-zinc-300 mb-1">Company Name</label>
702
- <input name="company_name" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
703
- </div>
704
- <div>
705
- <label className="block font-medium text-zinc-300 mb-1">Website</label>
706
- <input name="website" placeholder="https://" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
707
- </div>
708
- <div className="pt-4 flex justify-end gap-2">
709
- <button
710
- type="button"
711
- onClick={() => setIsAddLeadOpen(false)}
712
- className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
713
- >
714
- Cancel
715
- </button>
716
- <button
717
- type="submit"
718
- className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium"
719
- >
720
- Enroll Lead
721
- </button>
722
- </div>
723
- </form>
724
- </FormDrawer>
725
- </div>
726
- );
727
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Campaigns.tsx DELETED
@@ -1,441 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- Plus,
5
- Search,
6
- Megaphone,
7
- Play,
8
- Pause,
9
- Square,
10
- Trash2,
11
- ArrowUpRight,
12
- Sliders,
13
- Clock,
14
- Globe,
15
- CheckCircle2,
16
- MailCheck
17
- } from "lucide-react";
18
- import { useState } from "react";
19
- import { Link } from "react-router-dom";
20
- import StatusBadge from "../components/StatusBadge";
21
- import ConfirmModal from "../components/ConfirmModal";
22
- import FormDrawer from "../components/FormDrawer";
23
- import toast from "react-hot-toast";
24
-
25
- export default function Campaigns() {
26
- const queryClient = useQueryClient();
27
- const [searchTerm, setSearchTerm] = useState("");
28
- const [statusFilter, setStatusFilter] = useState("ALL");
29
- const [isCreateOpen, setIsCreateOpen] = useState(false);
30
- const [selectedForDelete, setSelectedForDelete] = useState<string | null>(null);
31
-
32
- const { data: campaigns, isLoading } = useQuery({
33
- queryKey: ["campaigns"],
34
- queryFn: async () => {
35
- const res = await apiClient.get("/campaigns");
36
- return res.data;
37
- },
38
- });
39
-
40
- const updateStatusMutation = useMutation({
41
- mutationFn: async ({ id, status }: { id: string; status: "START" | "PAUSE" | "STOP" }) => {
42
- const res = await apiClient.post(`/campaigns/${id}/status`, { status });
43
- return res.data;
44
- },
45
- onSuccess: (_, variables) => {
46
- const statusLabel = variables.status === "START" ? "started" : variables.status === "PAUSE" ? "paused" : "stopped";
47
- toast.success(`Campaign ${statusLabel}`);
48
- queryClient.invalidateQueries({ queryKey: ["campaigns"] });
49
- },
50
- onError: (err: any) => {
51
- toast.error(err.response?.data?.error || "Failed to update campaign status");
52
- }
53
- });
54
-
55
- const deleteMutation = useMutation({
56
- mutationFn: async (id: string) => {
57
- const res = await apiClient.delete(`/campaigns/${id}`);
58
- return res.data;
59
- },
60
- onSuccess: () => {
61
- toast.success("Campaign removed");
62
- setSelectedForDelete(null);
63
- queryClient.invalidateQueries({ queryKey: ["campaigns"] });
64
- },
65
- onError: (err: any) => {
66
- toast.error(err.response?.data?.error || "Failed to delete campaign");
67
- }
68
- });
69
-
70
- const filteredCampaigns = (campaigns || []).filter((c: any) => {
71
- const matchesSearch = c.name?.toLowerCase().includes(searchTerm.toLowerCase());
72
- const matchesStatus = statusFilter === "ALL" || c.status === statusFilter;
73
- return matchesSearch && matchesStatus;
74
- });
75
-
76
- return (
77
- <div className="p-6 max-w-7xl mx-auto space-y-6">
78
- {/* Header */}
79
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
80
- <div>
81
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
82
- <Megaphone className="w-6 h-6 text-indigo-400" />
83
- Outreach Campaigns
84
- </h1>
85
- <p className="text-zinc-400 text-sm mt-1">
86
- Automated multi-step cold email sequences with A/B variant testing.
87
- </p>
88
- </div>
89
-
90
- <button
91
- onClick={() => setIsCreateOpen(true)}
92
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-2 self-start sm:self-auto"
93
- >
94
- <Plus className="w-4 h-4" />
95
- Create Campaign
96
- </button>
97
- </div>
98
-
99
- {/* Filter and Search Bar */}
100
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
101
- <div className="relative flex-1 max-w-md">
102
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" />
103
- <input
104
- type="text"
105
- placeholder="Search campaigns by name..."
106
- value={searchTerm}
107
- onChange={(e) => setSearchTerm(e.target.value)}
108
- className="w-full bg-black/40 border border-white/5 rounded-lg py-2 pl-9 pr-4 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] transition-all"
109
- />
110
- </div>
111
-
112
- <div className="flex items-center gap-2">
113
- {["ALL", "ACTIVE", "PAUSED", "STOPPED"].map((status) => (
114
- <button
115
- key={status}
116
- onClick={() => setStatusFilter(status)}
117
- className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-300 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] ${
118
- statusFilter === status
119
- ? "bg-indigo-600 text-white shadow-[0_0_12px_rgba(37,125,104,0.5)] border border-indigo-500/50"
120
- : "bg-black/40 text-zinc-400 hover:text-zinc-200 border border-white/5"
121
- }`}
122
- >
123
- {status}
124
- </button>
125
- ))}
126
- </div>
127
- </div>
128
-
129
- {/* Campaigns Table */}
130
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl overflow-hidden shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
131
- {isLoading ? (
132
- <div className="py-20 flex flex-col items-center justify-center gap-3">
133
- <div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin drop-shadow-[0_0_8px_rgba(63,156,132,0.5)]" />
134
- <p className="text-sm text-zinc-400">Loading campaign records...</p>
135
- </div>
136
- ) : filteredCampaigns.length > 0 ? (
137
- <div className="overflow-x-auto">
138
- <table className="w-full text-sm text-left">
139
- <thead className="bg-black/20 border-b border-white/5 text-zinc-400 text-xs uppercase font-sans tracking-wider">
140
- <tr>
141
- <th className="px-6 py-3.5 font-semibold">Campaign Name</th>
142
- <th className="px-6 py-3.5 font-semibold">Status</th>
143
- <th className="px-6 py-3.5 font-semibold">Leads</th>
144
- <th className="px-6 py-3.5 font-semibold">Sent</th>
145
- <th className="px-6 py-3.5 font-semibold">Opens</th>
146
- <th className="px-6 py-3.5 font-semibold">Replies</th>
147
- <th className="px-6 py-3.5 font-semibold">Bounces</th>
148
- <th className="px-6 py-3.5 font-semibold text-right">Actions</th>
149
- </tr>
150
- </thead>
151
- <tbody className="divide-y divide-white/5">
152
- {filteredCampaigns.map((camp: any) => (
153
- <tr key={camp.id} className="hover:bg-white/5 transition-colors">
154
- <td className="px-6 py-4">
155
- <Link
156
- to={`/campaigns/${camp.id}`}
157
- className="font-semibold text-zinc-100 hover:text-indigo-400 transition-colors flex items-center gap-1.5"
158
- >
159
- {camp.name}
160
- <ArrowUpRight className="w-3.5 h-3.5 text-zinc-500" />
161
- </Link>
162
- <div className="text-[11px] text-zinc-400 mt-0.5 font-sans">
163
- Schedule: <span className="font-mono">{camp.start_schedule || "09:00"} - {camp.end_schedule || "17:00"}</span> ({camp.timezone || "America/New_York"})
164
- </div>
165
- </td>
166
- <td className="px-6 py-4">
167
- <StatusBadge status={camp.status} />
168
- </td>
169
- <td className="px-6 py-4 font-mono text-zinc-300">
170
- {camp.lead_count?.toLocaleString() || 0}
171
- </td>
172
- <td className="px-6 py-4 font-mono text-zinc-300">
173
- {camp.sent_count?.toLocaleString() || 0}
174
- </td>
175
- <td className="px-6 py-4 font-mono text-emerald-400 drop-shadow-[0_0_8px_rgba(63,156,132,0.3)]">
176
- {camp.open_count?.toLocaleString() || 0}
177
- </td>
178
- <td className="px-6 py-4 font-mono text-purple-400 font-semibold drop-shadow-[0_0_8px_rgba(178,87,49,0.3)]">
179
- {camp.reply_count?.toLocaleString() || 0}
180
- </td>
181
- <td className="px-6 py-4 font-mono text-rose-400 drop-shadow-[0_0_8px_rgba(203,117,80,0.3)]">
182
- {camp.bounce_count || 0}
183
- </td>
184
- <td className="px-6 py-4 text-right">
185
- <div className="flex items-center justify-end gap-1.5">
186
- {camp.status === "ACTIVE" ? (
187
- <button
188
- onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "PAUSE" })}
189
- title="Pause Campaign"
190
- className="p-1.5 bg-black/40 hover:bg-amber-500/20 text-zinc-400 hover:text-amber-400 rounded-md transition-colors border border-white/5 hover:border-amber-500/30"
191
- >
192
- <Pause className="w-3.5 h-3.5" />
193
- </button>
194
- ) : (
195
- <button
196
- onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "START" })}
197
- title="Start Campaign"
198
- className="p-1.5 bg-black/40 hover:bg-emerald-500/20 text-zinc-400 hover:text-emerald-400 rounded-md transition-colors border border-white/5 hover:border-emerald-500/30"
199
- >
200
- <Play className="w-3.5 h-3.5" />
201
- </button>
202
- )}
203
- <button
204
- onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "STOP" })}
205
- title="Stop Campaign"
206
- className="p-1.5 bg-black/40 hover:bg-rose-500/20 text-zinc-400 hover:text-rose-400 rounded-md transition-colors border border-white/5 hover:border-rose-500/30"
207
- >
208
- <Square className="w-3.5 h-3.5" />
209
- </button>
210
- <button
211
- onClick={() => setSelectedForDelete(camp.id)}
212
- title="Delete Campaign"
213
- className="p-1.5 bg-black/40 hover:bg-rose-500/20 text-zinc-400 hover:text-rose-400 rounded-md transition-colors border border-white/5 hover:border-rose-500/30"
214
- >
215
- <Trash2 className="w-3.5 h-3.5" />
216
- </button>
217
- </div>
218
- </td>
219
- </tr>
220
- ))}
221
- </tbody>
222
- </table>
223
- </div>
224
- ) : (
225
- <div className="py-16 text-center">
226
- <Megaphone className="w-12 h-12 text-zinc-600 mx-auto mb-3" />
227
- <h3 className="text-base font-medium text-zinc-300">No campaigns found</h3>
228
- <p className="text-xs text-zinc-500 mt-1 max-w-sm mx-auto font-sans">
229
- Create your first campaign to begin dispatching automated cold outreach sequences.
230
- </p>
231
- <button
232
- onClick={() => setIsCreateOpen(true)}
233
- className="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors shadow-[0_0_12px_rgba(37,125,104,0.5)] border border-indigo-500/50"
234
- >
235
- Create Campaign
236
- </button>
237
- </div>
238
- )}
239
- </div>
240
-
241
- {/* Delete Campaign Confirmation */}
242
- <ConfirmModal
243
- isOpen={Boolean(selectedForDelete)}
244
- onClose={() => setSelectedForDelete(null)}
245
- onConfirm={() => selectedForDelete && deleteMutation.mutate(selectedForDelete)}
246
- title="Delete Campaign"
247
- description="Are you sure you want to permanently delete this campaign and all associated sequences? This action cannot be undone."
248
- confirmText="Delete Campaign"
249
- isDestructive={true}
250
- isLoading={deleteMutation.isPending}
251
- />
252
-
253
- {/* Full Multi-Step Create Campaign Drawer */}
254
- <FormDrawer
255
- isOpen={isCreateOpen}
256
- onClose={() => setIsCreateOpen(false)}
257
- title="Launch New Campaign"
258
- subtitle="Configure schedule parameters, tracking domain, and initial sequence step."
259
- >
260
- <form
261
- onSubmit={async (e) => {
262
- e.preventDefault();
263
- const form = e.target as HTMLFormElement;
264
- const data = {
265
- name: (form.elements.namedItem("name") as HTMLInputElement).value,
266
- timezone: (form.elements.namedItem("timezone") as HTMLSelectElement).value,
267
- start_schedule: (form.elements.namedItem("start_schedule") as HTMLInputElement).value,
268
- end_schedule: (form.elements.namedItem("end_schedule") as HTMLInputElement).value,
269
- track_settings: (form.elements.namedItem("track_settings") as HTMLSelectElement).value,
270
- send_as_plain_text: (form.elements.namedItem("send_as_plain_text") as HTMLInputElement).checked,
271
- follow_up_percentage: Number((form.elements.namedItem("follow_up_percentage") as HTMLInputElement).value),
272
- unsubscribe_text: (form.elements.namedItem("unsubscribe_text") as HTMLInputElement).value,
273
- stop_lead_settings: (form.elements.namedItem("stop_lead_settings") as HTMLSelectElement).value,
274
- initial_subject: (form.elements.namedItem("subject") as HTMLInputElement).value,
275
- initial_body: (form.elements.namedItem("body") as HTMLTextAreaElement).value,
276
- };
277
-
278
- try {
279
- await apiClient.post("/campaigns", data);
280
- toast.success("Campaign launched successfully");
281
- setIsCreateOpen(false);
282
- queryClient.invalidateQueries({ queryKey: ["campaigns"] });
283
- } catch (err: any) {
284
- toast.error(err.response?.data?.error || "Failed to create campaign");
285
- }
286
- }}
287
- className="space-y-5 text-sm"
288
- >
289
- <div>
290
- <label className="block font-medium text-zinc-300 mb-1">Campaign Name *</label>
291
- <input
292
- name="name"
293
- required
294
- placeholder="e.g. Enterprise Founders Outbound"
295
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
296
- />
297
- </div>
298
-
299
- <div className="grid grid-cols-2 gap-3">
300
- <div>
301
- <label className="block font-medium text-zinc-300 mb-1">Start Time</label>
302
- <input
303
- type="time"
304
- name="start_schedule"
305
- defaultValue="09:00"
306
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
307
- />
308
- </div>
309
- <div>
310
- <label className="block font-medium text-zinc-300 mb-1">End Time</label>
311
- <input
312
- type="time"
313
- name="end_schedule"
314
- defaultValue="17:00"
315
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
316
- />
317
- </div>
318
- </div>
319
-
320
- <div>
321
- <label className="block font-medium text-zinc-300 mb-1">Timezone</label>
322
- <select
323
- name="timezone"
324
- defaultValue="America/New_York"
325
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
326
- >
327
- <option value="America/New_York">America/New_York (EST)</option>
328
- <option value="America/Chicago">America/Chicago (CST)</option>
329
- <option value="America/Los_Angeles">America/Los_Angeles (PST)</option>
330
- <option value="Europe/London">Europe/London (GMT)</option>
331
- <option value="Europe/Paris">Europe/Paris (CET)</option>
332
- <option value="Asia/Singapore">Asia/Singapore (SGT)</option>
333
- </select>
334
- </div>
335
-
336
- <div>
337
- <label className="block font-medium text-zinc-300 mb-1">Tracking Settings</label>
338
- <select
339
- name="track_settings"
340
- defaultValue="TRACK_EMAIL_OPEN_AND_CLICK"
341
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
342
- >
343
- <option value="TRACK_EMAIL_OPEN_AND_CLICK">Track Opens and Clicks</option>
344
- <option value="TRACK_EMAIL_OPEN">Track Email Opens Only</option>
345
- <option value="TRACK_EMAIL_CLICK">Track Email Clicks Only</option>
346
- <option value="DONT_TRACK_EMAIL_OPEN">Do Not Track Opens or Clicks</option>
347
- </select>
348
- </div>
349
-
350
- <div className="flex items-center justify-between p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
351
- <div>
352
- <span className="font-medium text-zinc-200 block text-xs">Send as Plain Text</span>
353
- <span className="text-[11px] text-zinc-500">Improves deliverability in strict enterprise inboxes</span>
354
- </div>
355
- <input
356
- type="checkbox"
357
- name="send_as_plain_text"
358
- className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 bg-zinc-950 border-zinc-700"
359
- />
360
- </div>
361
-
362
- <div>
363
- <div className="flex justify-between text-xs font-medium text-zinc-300 mb-1">
364
- <span>Follow-up Distribution %</span>
365
- <span>85%</span>
366
- </div>
367
- <input
368
- type="range"
369
- name="follow_up_percentage"
370
- min="10"
371
- max="100"
372
- defaultValue="85"
373
- className="w-full accent-indigo-500"
374
- />
375
- </div>
376
-
377
- <div>
378
- <label className="block font-medium text-zinc-300 mb-1">Unsubscribe Message</label>
379
- <input
380
- name="unsubscribe_text"
381
- defaultValue="Reply unsubscribe if you prefer not to receive updates."
382
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
383
- />
384
- </div>
385
-
386
- <div>
387
- <label className="block font-medium text-zinc-300 mb-1">Stop Lead Condition</label>
388
- <select
389
- name="stop_lead_settings"
390
- defaultValue="REPLIED"
391
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
392
- >
393
- <option value="REPLIED">Stop sequence once lead replies</option>
394
- <option value="OPENED">Stop sequence once lead opens email</option>
395
- <option value="CLICKED">Stop sequence once lead clicks a link</option>
396
- </select>
397
- </div>
398
-
399
- <div className="pt-2 border-t border-zinc-800 space-y-3">
400
- <h4 className="text-xs font-semibold uppercase text-zinc-400">Step 1 Sequence Setup</h4>
401
- <div>
402
- <label className="block font-medium text-zinc-300 mb-1">Subject Line</label>
403
- <input
404
- name="subject"
405
- required
406
- defaultValue="Quick sync for {{first_name}}"
407
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
408
- />
409
- </div>
410
- <div>
411
- <label className="block font-medium text-zinc-300 mb-1">Email Body</label>
412
- <textarea
413
- name="body"
414
- rows={3}
415
- required
416
- defaultValue="<p>Hi {{first_name}},</p><p>Wanted to see how {{company_name}} is optimizing outbound deliverability?</p>"
417
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 font-mono text-xs"
418
- />
419
- </div>
420
- </div>
421
-
422
- <div className="pt-4 flex justify-end gap-3 border-t border-zinc-800">
423
- <button
424
- type="button"
425
- onClick={() => setIsCreateOpen(false)}
426
- className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-xs"
427
- >
428
- Cancel
429
- </button>
430
- <button
431
- type="submit"
432
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg text-xs shadow-md"
433
- >
434
- Launch Campaign
435
- </button>
436
- </div>
437
- </form>
438
- </FormDrawer>
439
- </div>
440
- );
441
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Clients.tsx DELETED
@@ -1,214 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- Building2,
5
- Plus,
6
- Trash2,
7
- ExternalLink,
8
- Users,
9
- Megaphone,
10
- Mail,
11
- ArrowUpRight,
12
- ShieldCheck
13
- } from "lucide-react";
14
- import { useState } from "react";
15
- import FormDrawer from "../components/FormDrawer";
16
- import ConfirmModal from "../components/ConfirmModal";
17
- import StatusBadge from "../components/StatusBadge";
18
- import toast from "react-hot-toast";
19
-
20
- export default function Clients() {
21
- const queryClient = useQueryClient();
22
- const [isAddOpen, setIsAddOpen] = useState(false);
23
- const [selectedForDelete, setSelectedForDelete] = useState<string | null>(null);
24
-
25
- const { data: clients = [], isLoading, refetch } = useQuery({
26
- queryKey: ["clients"],
27
- queryFn: async () => {
28
- const res = await apiClient.get("/clients");
29
- return res.data;
30
- },
31
- });
32
-
33
- const deleteMutation = useMutation({
34
- mutationFn: async (id: string) => {
35
- const res = await apiClient.delete(`/clients/${id}`);
36
- return res.data;
37
- },
38
- onSuccess: () => {
39
- toast.success("Client account removed");
40
- setSelectedForDelete(null);
41
- refetch();
42
- },
43
- });
44
-
45
- return (
46
- <div className="p-6 max-w-7xl mx-auto space-y-6">
47
- {/* Header */}
48
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
49
- <div>
50
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
51
- <Building2 className="w-6 h-6 text-indigo-400" />
52
- Agency Client Sub-Accounts
53
- </h1>
54
- <p className="text-zinc-400 text-sm mt-1">
55
- Isolate campaigns, custom domains, leads, and analytics across distinct client sub-portals.
56
- </p>
57
- </div>
58
-
59
- <button
60
- onClick={() => setIsAddOpen(true)}
61
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-2"
62
- >
63
- <Plus className="w-4 h-4" /> Add Client Portal
64
- </button>
65
- </div>
66
-
67
- {/* Client Cards Grid */}
68
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
69
- {clients.map((client: any) => (
70
- <div
71
- key={client.id}
72
- className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 shadow-sm hover:border-zinc-700 transition-all flex flex-col justify-between"
73
- >
74
- <div>
75
- <div className="flex items-start justify-between">
76
- <div className="flex items-center gap-3">
77
- <div className="w-10 h-10 rounded-lg bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 flex items-center justify-center font-bold text-base">
78
- {client.name ? client.name[0] : "C"}
79
- </div>
80
- <div>
81
- <h3 className="font-semibold text-zinc-100 text-sm">{client.name}</h3>
82
- <span className="text-xs text-zinc-500 font-mono">{client.email}</span>
83
- </div>
84
- </div>
85
-
86
- <StatusBadge status={client.status || "ACTIVE"} size="sm" />
87
- </div>
88
-
89
- {client.website && (
90
- <div className="mt-3 text-xs text-zinc-400 flex items-center gap-1.5 font-mono">
91
- <ExternalLink className="w-3.5 h-3.5 text-zinc-500" />
92
- <a
93
- href={client.website}
94
- target="_blank"
95
- rel="noreferrer"
96
- className="hover:text-indigo-400 transition-colors truncate"
97
- >
98
- {client.website.replace("https://", "")}
99
- </a>
100
- </div>
101
- )}
102
-
103
- {/* Metrics */}
104
- <div className="grid grid-cols-2 gap-3 mt-5 pt-4 border-t border-zinc-800/80 text-xs">
105
- <div className="p-2.5 bg-zinc-950 rounded-lg border border-zinc-800/60">
106
- <span className="text-zinc-500 flex items-center gap-1 text-[11px]">
107
- <Megaphone className="w-3 h-3 text-indigo-400" /> Campaigns
108
- </span>
109
- <span className="text-base font-bold text-zinc-200 mt-1 block font-mono">
110
- {client.campaigns_count || 3}
111
- </span>
112
- </div>
113
- <div className="p-2.5 bg-zinc-950 rounded-lg border border-zinc-800/60">
114
- <span className="text-zinc-500 flex items-center gap-1 text-[11px]">
115
- <Users className="w-3 h-3 text-teal-400" /> Leads
116
- </span>
117
- <span className="text-base font-bold text-zinc-200 mt-1 block font-mono">
118
- {client.leads_count?.toLocaleString() || "1,420"}
119
- </span>
120
- </div>
121
- </div>
122
- </div>
123
-
124
- <div className="mt-5 pt-3 border-t border-zinc-800/80 flex items-center justify-between">
125
- <span className="text-[11px] text-zinc-500 font-mono">ID: {client.id}</span>
126
- <div className="flex items-center gap-2">
127
- <button
128
- onClick={() => setSelectedForDelete(client.id)}
129
- className="p-1.5 text-zinc-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-md transition-colors"
130
- >
131
- <Trash2 className="w-3.5 h-3.5" />
132
- </button>
133
- </div>
134
- </div>
135
- </div>
136
- ))}
137
-
138
- {clients.length === 0 && (
139
- <div className="col-span-full py-16 text-center text-xs text-zinc-500 bg-zinc-900 border border-zinc-800 rounded-xl">
140
- No client portals created yet. Add your first agency sub-account above.
141
- </div>
142
- )}
143
- </div>
144
-
145
- {/* Delete Confirmation */}
146
- <ConfirmModal
147
- isOpen={Boolean(selectedForDelete)}
148
- onClose={() => setSelectedForDelete(null)}
149
- onConfirm={() => selectedForDelete && deleteMutation.mutate(selectedForDelete)}
150
- title="Delete Client Sub-Account"
151
- description="Are you sure you want to remove this client sub-account? All associated campaigns and isolated settings will be detached."
152
- />
153
-
154
- {/* Add Client Drawer */}
155
- <FormDrawer
156
- isOpen={isAddOpen}
157
- onClose={() => setIsAddOpen(false)}
158
- title="Create Agency Client Sub-Account"
159
- subtitle="Provision dedicated workspaces for client stakeholders."
160
- >
161
- <form
162
- onSubmit={async (e) => {
163
- e.preventDefault();
164
- const form = e.target as HTMLFormElement;
165
- const data = {
166
- name: (form.elements.namedItem("name") as HTMLInputElement).value,
167
- email: (form.elements.namedItem("email") as HTMLInputElement).value,
168
- website: (form.elements.namedItem("website") as HTMLInputElement).value,
169
- };
170
-
171
- try {
172
- await apiClient.post("/clients", data);
173
- toast.success("Client account created");
174
- setIsAddOpen(false);
175
- refetch();
176
- } catch (err: any) {
177
- toast.error("Failed to create client");
178
- }
179
- }}
180
- className="space-y-4 text-xs"
181
- >
182
- <div>
183
- <label className="block font-medium text-zinc-300 mb-1">Client Company Name *</label>
184
- <input name="name" required placeholder="e.g. Acme Ventures" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
185
- </div>
186
- <div>
187
- <label className="block font-medium text-zinc-300 mb-1">Primary Contact Email *</label>
188
- <input type="email" name="email" required placeholder="contact@acme.com" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
189
- </div>
190
- <div>
191
- <label className="block font-medium text-zinc-300 mb-1">Website URL</label>
192
- <input name="website" placeholder="https://acme.com" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
193
- </div>
194
-
195
- <div className="pt-4 flex justify-end gap-2 border-t border-zinc-800">
196
- <button
197
- type="button"
198
- onClick={() => setIsAddOpen(false)}
199
- className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
200
- >
201
- Cancel
202
- </button>
203
- <button
204
- type="submit"
205
- className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium shadow"
206
- >
207
- Provision Account
208
- </button>
209
- </div>
210
- </form>
211
- </FormDrawer>
212
- </div>
213
- );
214
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Dashboard.tsx DELETED
@@ -1,551 +0,0 @@
1
- import { useQuery, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- Megaphone,
5
- Users,
6
- Mail,
7
- Inbox,
8
- Flame,
9
- TrendingUp,
10
- Sparkles,
11
- Activity,
12
- Plus,
13
- ArrowUpRight,
14
- CheckCircle2,
15
- ShieldCheck,
16
- Radio,
17
- Zap
18
- } from "lucide-react";
19
- import {
20
- LineChart,
21
- Line,
22
- XAxis,
23
- YAxis,
24
- CartesianGrid,
25
- Tooltip,
26
- ResponsiveContainer,
27
- PieChart,
28
- Pie,
29
- Cell
30
- } from "recharts";
31
- import { useState } from "react";
32
- import { Link } from "react-router-dom";
33
- import StatCard from "../components/StatCard";
34
- import StatusBadge from "../components/StatusBadge";
35
- import FormDrawer from "../components/FormDrawer";
36
- import toast from "react-hot-toast";
37
-
38
- const lineData = [
39
- { date: "Day 1", sent: 480, open: 310, reply: 28, bounce: 6 },
40
- { date: "Day 4", sent: 820, open: 530, reply: 46, bounce: 10 },
41
- { date: "Day 7", sent: 1250, open: 820, reply: 74, bounce: 14 },
42
- { date: "Day 10", sent: 1780, open: 1190, reply: 105, bounce: 19 },
43
- { date: "Day 14", sent: 2420, open: 1650, reply: 148, bounce: 23 },
44
- { date: "Day 18", sent: 3150, open: 2190, reply: 196, bounce: 31 },
45
- { date: "Day 22", sent: 4020, open: 2840, reply: 254, bounce: 38 },
46
- { date: "Day 26", sent: 4980, open: 3510, reply: 312, bounce: 46 },
47
- { date: "Day 30", sent: 5890, open: 4120, reply: 378, bounce: 52 },
48
- ];
49
-
50
- const pieData = [
51
- { name: "Interested", value: 340, color: "var(--nt-success)" },
52
- { name: "Meeting Booked", value: 160, color: "var(--nt-accent)" },
53
- { name: "Replied", value: 290, color: "var(--nt-info)" },
54
- { name: "Not Interested", value: 180, color: "var(--nt-danger)" },
55
- { name: "Bounced", value: 45, color: "var(--nt-espresso-700)" },
56
- ];
57
-
58
- export default function Dashboard() {
59
- const queryClient = useQueryClient();
60
- const [isCreateCampaignOpen, setIsCreateCampaignOpen] = useState(false);
61
- const [activeMetric, setActiveMetric] = useState<"all" | "open" | "reply">("all");
62
-
63
- const { data: analytics, isLoading: isAnalyticsLoading, isError: isAnalyticsError } = useQuery({
64
- queryKey: ["analytics-summary"],
65
- queryFn: async () => {
66
- const res = await apiClient.get("/analytics/campaigns");
67
- return res.data;
68
- },
69
- });
70
-
71
- const { data: campaigns, isLoading: isCampaignsLoading, isError: isCampaignsError } = useQuery({
72
- queryKey: ["campaigns"],
73
- queryFn: async () => {
74
- const res = await apiClient.get("/campaigns");
75
- return res.data;
76
- },
77
- });
78
-
79
- const { data: emailAccounts = [], isLoading: isEmailAccountsLoading } = useQuery({
80
- queryKey: ["email-accounts"],
81
- queryFn: async () => {
82
- const res = await apiClient.get("/email-accounts");
83
- return res.data;
84
- },
85
- });
86
-
87
- const warmupDailyCount = emailAccounts.reduce((total: number, account: any) => total + (account.warmup_daily_count || 0), 0);
88
- const connectedMailboxCount = emailAccounts.filter((account: any) => account.status === "CONNECTED").length;
89
- const warmupScoreValues = emailAccounts.map((account: any) => account.warmup_score).filter((score: any) => typeof score === "number");
90
- const averageWarmupScore = warmupScoreValues.length
91
- ? (warmupScoreValues.reduce((total: number, score: number) => total + score, 0) / warmupScoreValues.length).toFixed(1)
92
- : null;
93
-
94
- return (
95
- <div className="p-6 max-w-7xl mx-auto space-y-7">
96
- {/* Header with Live Status & Quick Action Buttons */}
97
- <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
98
- <div>
99
- <div className="flex items-center gap-2">
100
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
101
- Command Center
102
- </h1>
103
- <span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-0.5 rounded-full">
104
- <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-ping" />
105
- Live Engine
106
- </span>
107
- </div>
108
- <p className="text-zinc-400 text-sm mt-1">
109
- Real-time deliverability radar, campaign sequencing, and cold outreach throughput.
110
- </p>
111
- </div>
112
-
113
- <div className="flex items-center gap-3">
114
- <button
115
- onClick={() => setIsCreateCampaignOpen(true)}
116
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-2"
117
- >
118
- <Plus className="w-4 h-4" />
119
- Create Campaign
120
- </button>
121
- </div>
122
- </div>
123
-
124
- {/* High-Impact Stat Cards */}
125
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
126
- <StatCard
127
- title="Total Campaigns"
128
- value={isCampaignsLoading ? "…" : isCampaignsError ? "—" : campaigns?.length ?? 0}
129
- subtitle="Outbound Sequences"
130
- icon={Megaphone}
131
- iconColor="indigo"
132
- change={{ value: "+2 new", isPositive: true, period: "this week" }}
133
- />
134
- <StatCard
135
- title="Total Leads"
136
- value={isAnalyticsLoading ? "…" : isAnalyticsError ? "—" : (analytics?.total_leads ?? 0).toLocaleString()}
137
- subtitle="Prospects Targeted"
138
- icon={Users}
139
- iconColor="teal"
140
- change={{ value: "+380", isPositive: true, period: "last 7 days" }}
141
- />
142
- <StatCard
143
- title="Emails Sent"
144
- value={isAnalyticsLoading ? "…" : isAnalyticsError ? "—" : (analytics?.total_sent ?? 0).toLocaleString()}
145
- subtitle="Across All Mailboxes"
146
- icon={Mail}
147
- iconColor="blue"
148
- change={{ value: "+14.8%", isPositive: true, period: "month-over-month" }}
149
- />
150
- <StatCard
151
- title="Avg Reply Rate"
152
- value={isAnalyticsLoading ? "…" : isAnalyticsError ? "—" : analytics?.reply_rate != null ? `${analytics.reply_rate}%` : "—"}
153
- subtitle="Deliverability High"
154
- icon={Inbox}
155
- iconColor="emerald"
156
- change={{ value: "+1.2%", isPositive: true, period: "vs benchmark" }}
157
- />
158
- </div>
159
-
160
- {/* Deliverability & Mailbox Health Ribbon */}
161
- <div className="command-signal-ribbon bg-gradient-to-r from-white/5 via-indigo-900/20 to-white/5 border border-white/10 backdrop-blur-md rounded-xl p-4 flex flex-col md:flex-row items-center justify-between gap-4 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
162
- <div className="flex items-center gap-3">
163
- <div className="p-2.5 bg-emerald-500/20 border border-emerald-500/30 rounded-xl text-emerald-300 shadow-[inset_0_1px_1px_rgba(246,248,242,0.1)]">
164
- <ShieldCheck className="w-5 h-5 drop-shadow-[0_0_8px_rgba(63,156,132,0.5)]" />
165
- </div>
166
- <div>
167
- <h4 className="text-sm font-semibold text-zinc-100 flex items-center gap-2">
168
- Deliverability & Reputation Guard
169
- <span className="text-[10px] font-bold uppercase tracking-wider bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 px-2 py-0.5 rounded-full drop-shadow-[0_0_8px_rgba(63,156,132,0.3)]">
170
- {averageWarmupScore ? `${averageWarmupScore}% Score` : isEmailAccountsLoading ? "Checking score" : "No score"}
171
- </span>
172
- </h4>
173
- <p className="text-xs text-zinc-400 mt-0.5">
174
- {connectedMailboxCount} connected mailboxes with {warmupDailyCount} daily warmup messages.
175
- </p>
176
- </div>
177
- </div>
178
-
179
- <div className="flex items-center gap-4 text-xs">
180
- <div className="flex items-center gap-2 bg-black/40 border border-white/5 px-3 py-1.5 rounded-lg shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)]">
181
- <Flame className="w-4 h-4 text-amber-400 drop-shadow-[0_0_8px_rgba(194,160,74,0.5)]" />
182
- <span className="text-zinc-300 font-medium font-sans">Warmup:</span>
183
- <span className="text-amber-400 font-bold font-mono drop-shadow-[0_0_8px_rgba(194,160,74,0.3)]">{warmupDailyCount} daily / {connectedMailboxCount} inboxes</span>
184
- </div>
185
- <Link
186
- to="/warmup"
187
- className="text-indigo-400 hover:text-indigo-300 font-medium flex items-center gap-1 transition-colors"
188
- >
189
- Warmup Center <ArrowUpRight className="w-3.5 h-3.5" />
190
- </Link>
191
- </div>
192
- </div>
193
-
194
- {/* Main Visualizations Grid */}
195
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
196
- {/* 30-Day Performance Multi-Line Chart */}
197
- <div className="lg:col-span-2 bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)] flex flex-col">
198
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
199
- <div>
200
- <h3 className="text-base font-semibold text-zinc-100 flex items-center gap-2">
201
- <Activity className="w-4 h-4 text-indigo-400 drop-shadow-[0_0_8px_rgba(37,125,104,0.5)]" />
202
- Performance Trajectory (30 Days)
203
- </h3>
204
- <p className="text-xs text-zinc-400 mt-0.5">
205
- Sent volume, open rate, and reply convergence.
206
- </p>
207
- </div>
208
-
209
- {/* Metric toggles */}
210
- <div className="flex items-center bg-black/40 p-1 rounded-lg border border-white/5 text-xs shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)]">
211
- <button
212
- onClick={() => setActiveMetric("all")}
213
- className={`px-3 py-1 rounded-md font-medium transition-all duration-300 ${
214
- activeMetric === "all" ? "bg-indigo-600 text-white shadow-[0_0_12px_rgba(37,125,104,0.5)]" : "text-zinc-400 hover:text-zinc-200"
215
- }`}
216
- >
217
- All Metrics
218
- </button>
219
- <button
220
- onClick={() => setActiveMetric("open")}
221
- className={`px-3 py-1 rounded-md font-medium transition-all duration-300 ${
222
- activeMetric === "open" ? "bg-emerald-600 text-white shadow-[0_0_12px_rgba(37,125,104,0.5)]" : "text-zinc-400 hover:text-zinc-200"
223
- }`}
224
- >
225
- Opens
226
- </button>
227
- <button
228
- onClick={() => setActiveMetric("reply")}
229
- className={`px-3 py-1 rounded-md font-medium transition-all duration-300 ${
230
- activeMetric === "reply" ? "bg-purple-600 text-white shadow-[0_0_12px_rgba(178,87,49,0.5)]" : "text-zinc-400 hover:text-zinc-200"
231
- }`}
232
- >
233
- Replies
234
- </button>
235
- </div>
236
- </div>
237
-
238
- <div className="h-[300px] w-full">
239
- <ResponsiveContainer width="100%" height="100%">
240
- <LineChart data={lineData} margin={{ top: 5, right: 10, bottom: 5, left: -10 }}>
241
- <CartesianGrid strokeDasharray="3 3" stroke="rgba(246,248,242,0.05)" vertical={false} />
242
- <XAxis dataKey="date" stroke="var(--nt-text-muted)" fontSize={12} tickLine={false} axisLine={false} />
243
- <YAxis stroke="var(--nt-text-muted)" fontSize={12} tickLine={false} axisLine={false} className="font-mono" />
244
- <Tooltip
245
- contentStyle={{
246
- backgroundColor: "rgba(6, 10, 12, 0.88)",
247
- backdropFilter: "blur(12px)",
248
- borderColor: "rgba(37,125,104,0.2)",
249
- borderRadius: "12px",
250
- color: "var(--nt-text-primary)",
251
- fontSize: "12px",
252
- }}
253
- />
254
- {(activeMetric === "all" || activeMetric === "open") && (
255
- <Line
256
- type="monotone"
257
- dataKey="sent"
258
- name="Sent"
259
- stroke="var(--nt-deep-teal-600)"
260
- strokeWidth={3}
261
- dot={{ r: 4, fill: "var(--nt-deep-teal-600)", strokeWidth: 2, stroke: "var(--nt-focus-ring-offset)" }}
262
- activeDot={{ r: 6, fill: "var(--nt-deep-teal-700)" }}
263
- animationDuration={1500}
264
- />
265
- )}
266
- {(activeMetric === "all" || activeMetric === "open") && (
267
- <Line
268
- type="monotone"
269
- dataKey="open"
270
- name="Opens"
271
- stroke="var(--nt-deep-teal-500)"
272
- strokeWidth={3}
273
- dot={{ r: 4, fill: "var(--nt-deep-teal-500)", strokeWidth: 2, stroke: "var(--nt-focus-ring-offset)" }}
274
- activeDot={{ r: 6, fill: "var(--nt-deep-teal-700)" }}
275
- animationDuration={1500}
276
- />
277
- )}
278
- {(activeMetric === "all" || activeMetric === "reply") && (
279
- <Line
280
- type="monotone"
281
- dataKey="reply"
282
- name="Replies"
283
- stroke="var(--nt-terracotta-500)"
284
- strokeWidth={3}
285
- dot={{ r: 4, fill: "var(--nt-terracotta-500)", strokeWidth: 2, stroke: "var(--nt-focus-ring-offset)" }}
286
- activeDot={{ r: 6, fill: "var(--nt-terracotta-700)" }}
287
- animationDuration={1500}
288
- />
289
- )}
290
- {activeMetric === "all" && (
291
- <Line
292
- type="monotone"
293
- dataKey="bounce"
294
- name="Bounces"
295
- stroke="var(--nt-danger)"
296
- strokeWidth={2}
297
- strokeDasharray="4 4"
298
- dot={false}
299
- animationDuration={1500}
300
- />
301
- )}
302
- </LineChart>
303
- </ResponsiveContainer>
304
- </div>
305
-
306
- <div className="mt-4 pt-3 border-t border-white/5 flex items-center justify-between text-xs text-zinc-400">
307
- <div className="flex items-center gap-4 font-sans">
308
- <span className="flex items-center gap-1.5">
309
- <span className="w-2.5 h-2.5 rounded-full bg-blue-500 shadow-[0_0_8px_rgba(63,156,132,0.8)]" /> Sent
310
- </span>
311
- <span className="flex items-center gap-1.5">
312
- <span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(37,125,104,0.8)]" /> Opens
313
- </span>
314
- <span className="flex items-center gap-1.5">
315
- <span className="w-2.5 h-2.5 rounded-full bg-purple-500 shadow-[0_0_8px_rgba(178,87,49,0.8)]" /> Replies
316
- </span>
317
- <span className="flex items-center gap-1.5">
318
- <span className="w-2.5 h-2.5 rounded-full bg-rose-500 shadow-[0_0_8px_rgba(203,117,80,0.8)]" /> Bounces
319
- </span>
320
- </div>
321
- <Link to="/analytics" className="text-indigo-400 hover:text-indigo-300 font-medium">
322
- View Detailed Analytics →
323
- </Link>
324
- </div>
325
- </div>
326
-
327
- {/* Lead Status Distribution Donut */}
328
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)] flex flex-col justify-between">
329
- <div>
330
- <h3 className="text-base font-semibold text-zinc-100 flex items-center gap-2">
331
- <Zap className="w-4 h-4 text-amber-400 drop-shadow-[0_0_8px_rgba(194,160,74,0.5)]" />
332
- Lead Intent Distribution
333
- </h3>
334
- <p className="text-xs text-zinc-400 mt-0.5">Classification of prospect responses.</p>
335
- </div>
336
-
337
- <div className="relative h-[220px] w-full flex items-center justify-center my-2">
338
- <ResponsiveContainer width="100%" height="100%">
339
- <PieChart>
340
- <Pie
341
- data={pieData}
342
- innerRadius={65}
343
- outerRadius={95}
344
- paddingAngle={4}
345
- dataKey="value"
346
- stroke="none"
347
- animationDuration={1500}
348
- >
349
- {pieData.map((entry, index) => (
350
- <Cell key={`cell-${index}`} fill={entry.color} />
351
- ))}
352
- </Pie>
353
- <Tooltip
354
- contentStyle={{
355
- backgroundColor: "rgba(6, 10, 12, 0.88)",
356
- backdropFilter: "blur(12px)",
357
- borderColor: "rgba(37,125,104,0.2)",
358
- borderRadius: "12px",
359
- fontSize: "12px",
360
- }}
361
- />
362
- </PieChart>
363
- </ResponsiveContainer>
364
- <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none drop-shadow-[0_0_12px_rgba(246,248,242,0.3)]">
365
- <span className="text-2xl font-bold text-white font-mono">1,015</span>
366
- <span className="text-[10px] uppercase tracking-wider text-zinc-400 font-medium font-sans">
367
- Classified
368
- </span>
369
- </div>
370
- </div>
371
-
372
- <div className="space-y-1.5 pt-3 border-t border-white/5 text-xs font-sans">
373
- {pieData.map((item) => (
374
- <div key={item.name} className="flex items-center justify-between text-zinc-300">
375
- <span className="flex items-center gap-2">
376
- <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color, boxShadow: `0 0 8px ${item.color}` }} />
377
- {item.name}
378
- </span>
379
- <span className="font-semibold text-zinc-200 font-mono">{item.value}</span>
380
- </div>
381
- ))}
382
- </div>
383
- </div>
384
- </div>
385
-
386
- {/* Active Campaigns Overview Table */}
387
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl overflow-hidden shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
388
- <div className="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
389
- <div>
390
- <h3 className="text-base font-semibold text-zinc-100 flex items-center gap-2">
391
- <Radio className="w-4 h-4 text-indigo-400 drop-shadow-[0_0_8px_rgba(37,125,104,0.5)]" />
392
- Active Outreach Sequences
393
- </h3>
394
- <p className="text-xs text-zinc-400 mt-0.5">
395
- Live status, recipient quotas, and response metrics.
396
- </p>
397
- </div>
398
- <Link
399
- to="/campaigns"
400
- className="text-xs font-semibold text-indigo-400 hover:text-indigo-300 transition-colors"
401
- >
402
- Manage Campaigns →
403
- </Link>
404
- </div>
405
-
406
- <div className="overflow-x-auto">
407
- <table className="w-full text-sm text-left">
408
- <thead className="bg-black/20 border-b border-white/5 text-zinc-400 text-xs uppercase">
409
- <tr>
410
- <th className="px-6 py-3 font-medium">Campaign</th>
411
- <th className="px-6 py-3 font-medium">Status</th>
412
- <th className="px-6 py-3 font-medium">Leads</th>
413
- <th className="px-6 py-3 font-medium">Sent</th>
414
- <th className="px-6 py-3 font-medium">Opens</th>
415
- <th className="px-6 py-3 font-medium">Replies</th>
416
- <th className="px-6 py-3 font-medium text-right">Action</th>
417
- </tr>
418
- </thead>
419
- <tbody className="divide-y divide-white/5">
420
- {(Array.isArray(campaigns) ? campaigns : []).map((camp: any) => (
421
- <tr key={camp.id} className="hover:bg-white/5 transition-colors">
422
- <td className="px-6 py-4 font-semibold text-zinc-100">
423
- <Link to={`/campaigns/${camp.id}`} className="hover:text-indigo-400 transition-colors">
424
- {camp.name}
425
- </Link>
426
- <div className="text-[11px] text-zinc-400 font-normal mt-0.5 font-sans">
427
- {camp.timezone} • {camp.sequences?.length || 1} step sequence
428
- </div>
429
- </td>
430
- <td className="px-6 py-4">
431
- <StatusBadge status={camp.status} />
432
- </td>
433
- <td className="px-6 py-4 text-zinc-300 font-mono">{camp.lead_count || 0}</td>
434
- <td className="px-6 py-4 text-zinc-300 font-mono">{camp.sent_count || 0}</td>
435
- <td className="px-6 py-4 text-emerald-400 font-mono drop-shadow-[0_0_8px_rgba(63,156,132,0.3)]">{camp.open_count || 0}</td>
436
- <td className="px-6 py-4 text-purple-400 font-mono font-semibold drop-shadow-[0_0_8px_rgba(178,87,49,0.3)]">{camp.reply_count || 0}</td>
437
- <td className="px-6 py-4 text-right">
438
- <Link
439
- to={`/campaigns/${camp.id}`}
440
- className="px-3 py-1.5 bg-white/5 hover:bg-white/10 text-zinc-200 text-xs font-medium rounded-lg border border-white/5 transition-all inline-flex items-center gap-1 shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]"
441
- >
442
- Inspect <ArrowUpRight className="w-3 h-3" />
443
- </Link>
444
- </td>
445
- </tr>
446
- ))}
447
- </tbody>
448
- </table>
449
- </div>
450
- </div>
451
-
452
- {/* Quick Campaign Creator Slide-Over Drawer */}
453
- <FormDrawer
454
- isOpen={isCreateCampaignOpen}
455
- onClose={() => setIsCreateCampaignOpen(false)}
456
- title="Create New Campaign"
457
- subtitle="Configure schedule, tracking domains, and email sequence steps."
458
- >
459
- <form
460
- onSubmit={async (e) => {
461
- e.preventDefault();
462
- const form = e.target as HTMLFormElement;
463
- const name = (form.elements.namedItem("name") as HTMLInputElement).value;
464
- const timezone = (form.elements.namedItem("timezone") as HTMLSelectElement).value;
465
- const initial_subject = (form.elements.namedItem("subject") as HTMLInputElement).value;
466
- const initial_body = (form.elements.namedItem("body") as HTMLTextAreaElement).value;
467
-
468
- try {
469
- await apiClient.post("/campaigns", {
470
- name,
471
- timezone,
472
- initial_subject,
473
- initial_body,
474
- track_settings: "TRACK_EMAIL_OPEN_AND_CLICK",
475
- });
476
- await queryClient.invalidateQueries({ queryKey: ["campaigns"] });
477
- toast.success("Campaign created successfully");
478
- setIsCreateCampaignOpen(false);
479
- } catch (err: any) {
480
- toast.error(err.message || "Failed to create campaign");
481
- }
482
- }}
483
- className="space-y-4 text-sm"
484
- >
485
- <div>
486
- <label className="block font-medium text-zinc-300 mb-1">Campaign Name</label>
487
- <input
488
- name="name"
489
- required
490
- placeholder="e.g. Q4 Tech VP Outreach"
491
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
492
- />
493
- </div>
494
-
495
- <div>
496
- <label className="block font-medium text-zinc-300 mb-1">Timezone</label>
497
- <select
498
- name="timezone"
499
- defaultValue="America/New_York"
500
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
501
- >
502
- <option value="America/New_York">Eastern Time (US & Canada)</option>
503
- <option value="America/Chicago">Central Time (US & Canada)</option>
504
- <option value="America/Los_Angeles">Pacific Time (US & Canada)</option>
505
- <option value="Europe/London">London (GMT)</option>
506
- <option value="Europe/Paris">Central European Time (CET)</option>
507
- <option value="Asia/Singapore">Singapore (SGT)</option>
508
- </select>
509
- </div>
510
-
511
- <div>
512
- <label className="block font-medium text-zinc-300 mb-1">Step 1: Subject Line</label>
513
- <input
514
- name="subject"
515
- required
516
- defaultValue="Question about {{company_name}}'s pipeline"
517
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
518
- />
519
- </div>
520
-
521
- <div>
522
- <label className="block font-medium text-zinc-300 mb-1">Step 1: Email Body</label>
523
- <textarea
524
- name="body"
525
- rows={4}
526
- required
527
- defaultValue="Hi {{first_name}},\n\nWanted to reach out and see if you are exploring new outbound channels this quarter?\n\nBest,\nAlex"
528
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 font-mono text-xs"
529
- />
530
- </div>
531
-
532
- <div className="pt-4 flex justify-end gap-3">
533
- <button
534
- type="button"
535
- onClick={() => setIsCreateCampaignOpen(false)}
536
- className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
537
- >
538
- Cancel
539
- </button>
540
- <button
541
- type="submit"
542
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg shadow-md"
543
- >
544
- Create Campaign
545
- </button>
546
- </div>
547
- </form>
548
- </FormDrawer>
549
- </div>
550
- );
551
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//EmailAccounts.tsx DELETED
@@ -1,393 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- Mail,
5
- Plus,
6
- RefreshCw,
7
- Trash2,
8
- CheckCircle2,
9
- AlertCircle,
10
- Server,
11
- Flame,
12
- Sliders,
13
- ShieldCheck
14
- } from "lucide-react";
15
- import { useState } from "react";
16
- import StatusBadge from "../components/StatusBadge";
17
- import FormDrawer from "../components/FormDrawer";
18
- import ConfirmModal from "../components/ConfirmModal";
19
- import toast from "react-hot-toast";
20
-
21
- export default function EmailAccounts() {
22
- const queryClient = useQueryClient();
23
- const [isAddOpen, setIsAddOpen] = useState(false);
24
- const [activeSetupTab, setActiveSetupTab] = useState<"smtp" | "google" | "outlook">("smtp");
25
- const [selectedForDelete, setSelectedForDelete] = useState<string | null>(null);
26
-
27
- const { data: accounts = [], isLoading, refetch } = useQuery({
28
- queryKey: ["email-accounts"],
29
- queryFn: async () => {
30
- const res = await apiClient.get("/email-accounts");
31
- return res.data;
32
- },
33
- });
34
-
35
- const reconnectMutation = useMutation({
36
- mutationFn: async (id: string) => {
37
- const res = await apiClient.post("/email-accounts/reconnect", { account_id: id });
38
- return res.data;
39
- },
40
- onSuccess: () => {
41
- toast.success("Mailbox connection verified and active");
42
- refetch();
43
- },
44
- onError: () => {
45
- toast.error("Failed to verify mailbox connection");
46
- }
47
- });
48
-
49
- const deleteMutation = useMutation({
50
- mutationFn: async (id: string) => {
51
- const res = await apiClient.delete(`/email-accounts/${id}`);
52
- return res.data;
53
- },
54
- onSuccess: () => {
55
- toast.success("Email account disconnected");
56
- setSelectedForDelete(null);
57
- refetch();
58
- },
59
- });
60
-
61
- const getProviderIcon = (provider: string) => {
62
- switch (provider?.toLowerCase()) {
63
- case "google":
64
- case "gmail":
65
- return <span className="text-red-400 font-bold">G</span>;
66
- case "microsoft":
67
- case "outlook":
68
- return <span className="text-blue-400 font-bold">M</span>;
69
- default:
70
- return <Server className="w-3.5 h-3.5 text-zinc-400" />;
71
- }
72
- };
73
-
74
- return (
75
- <div className="p-6 max-w-7xl mx-auto space-y-6">
76
- {/* Header */}
77
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
78
- <div>
79
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
80
- <Mail className="w-6 h-6 text-blue-400" />
81
- Sending Inboxes & Mailboxes
82
- </h1>
83
- <p className="text-zinc-400 text-sm mt-1">
84
- Connect and manage sending infrastructure across Google Workspace, Microsoft 365, and custom SMTP/IMAP.
85
- </p>
86
- </div>
87
-
88
- <button
89
- onClick={() => setIsAddOpen(true)}
90
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-2"
91
- >
92
- <Plus className="w-4 h-4" /> Add Mailbox
93
- </button>
94
- </div>
95
-
96
- {/* Overview Cards */}
97
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
98
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
99
- <span className="text-xs text-zinc-400">Total Sending Capacity</span>
100
- <div className="text-2xl font-bold text-zinc-100 mt-1">
101
- {accounts.reduce((sum: number, a: any) => sum + (a.daily_limit || 0), 0)} / day
102
- </div>
103
- <span className="text-[11px] text-zinc-500">{accounts.length} connected mailboxes</span>
104
- </div>
105
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
106
- <span className="text-xs text-zinc-400">Active in Warmup</span>
107
- <div className="text-2xl font-bold text-amber-400 mt-1">
108
- {accounts.filter((a: any) => a.warmup_enabled).length} of {accounts.length}
109
- </div>
110
- <span className="text-[11px] text-zinc-500">Autonomous ramp-up active</span>
111
- </div>
112
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
113
- <span className="text-xs text-zinc-400">Network Deliverability</span>
114
- <div className="text-2xl font-bold text-emerald-400 mt-1">
115
- {accounts.length ? `${(accounts.reduce((sum: number, account: any) => sum + (account.warmup_score || 0), 0) / accounts.length).toFixed(1)}%` : "—"}
116
- </div>
117
- <span className="text-[11px] text-zinc-500">All DNS / SPF / DKIM valid</span>
118
- </div>
119
- </div>
120
-
121
- {/* Accounts Table */}
122
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-sm">
123
- <div className="overflow-x-auto">
124
- <table className="w-full text-sm text-left">
125
- <thead className="bg-zinc-950 border-b border-zinc-800 text-zinc-400 text-xs uppercase">
126
- <tr>
127
- <th className="px-6 py-3.5 font-semibold">Account / Sender</th>
128
- <th className="px-6 py-3.5 font-semibold">Provider</th>
129
- <th className="px-6 py-3.5 font-semibold">Status</th>
130
- <th className="px-6 py-3.5 font-semibold">Daily Limit & Usage</th>
131
- <th className="px-6 py-3.5 font-semibold">Warmup</th>
132
- <th className="px-6 py-3.5 font-semibold text-right">Actions</th>
133
- </tr>
134
- </thead>
135
- <tbody className="divide-y divide-zinc-800/80">
136
- {accounts.map((acc: any) => {
137
- const usage = acc.sent_today || 0;
138
- const limit = acc.daily_limit || 0;
139
- const percentage = limit ? Math.min(100, Math.round((usage / limit) * 100)) : 0;
140
-
141
- return (
142
- <tr key={acc.id} className="hover:bg-zinc-800/30 transition-colors">
143
- <td className="px-6 py-4">
144
- <div className="font-semibold text-zinc-100">{acc.from_name}</div>
145
- <div className="text-xs text-zinc-400 font-mono mt-0.5">{acc.from_email}</div>
146
- </td>
147
- <td className="px-6 py-4">
148
- <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-zinc-950 border border-zinc-800 rounded-md text-xs text-zinc-300 font-medium capitalize">
149
- {getProviderIcon(acc.provider)}
150
- {acc.provider}
151
- </span>
152
- </td>
153
- <td className="px-6 py-4">
154
- <StatusBadge status={acc.status} size="sm" />
155
- </td>
156
- <td className="px-6 py-4">
157
- <div className="w-40 space-y-1">
158
- <div className="flex justify-between text-[11px] font-mono text-zinc-400">
159
- <span>{usage} sent</span>
160
- <span>{limit} limit</span>
161
- </div>
162
- <div className="w-full h-1.5 bg-zinc-950 rounded-full overflow-hidden border border-zinc-800">
163
- <div
164
- className={`h-full rounded-full ${
165
- percentage > 90 ? "bg-amber-500" : "bg-indigo-500"
166
- }`}
167
- style={{ width: `${percentage}%` }}
168
- />
169
- </div>
170
- </div>
171
- </td>
172
- <td className="px-6 py-4">
173
- <div className="flex items-center gap-2">
174
- <Flame className={`w-4 h-4 ${acc.warmup_enabled ? "text-amber-400 animate-pulse" : "text-zinc-600"}`} />
175
- <span className="text-xs font-medium text-zinc-300">
176
- {acc.warmup_enabled ? "Ramping Up" : "Disabled"}
177
- </span>
178
- </div>
179
- </td>
180
- <td className="px-6 py-4 text-right">
181
- <div className="flex items-center justify-end gap-2">
182
- <button
183
- onClick={() => reconnectMutation.mutate(acc.id)}
184
- title="Verify Connection"
185
- className="p-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-md transition-colors"
186
- >
187
- <RefreshCw className="w-3.5 h-3.5" />
188
- </button>
189
- <button
190
- onClick={() => setSelectedForDelete(acc.id)}
191
- title="Disconnect Mailbox"
192
- className="p-1.5 bg-zinc-800 hover:bg-rose-500/20 text-zinc-400 hover:text-rose-400 rounded-md transition-colors"
193
- >
194
- <Trash2 className="w-3.5 h-3.5" />
195
- </button>
196
- </div>
197
- </td>
198
- </tr>
199
- );
200
- })}
201
- </tbody>
202
- </table>
203
- </div>
204
- </div>
205
-
206
- {/* Delete Confirmation */}
207
- <ConfirmModal
208
- isOpen={Boolean(selectedForDelete)}
209
- onClose={() => setSelectedForDelete(null)}
210
- onConfirm={() => selectedForDelete && deleteMutation.mutate(selectedForDelete)}
211
- title="Disconnect Mailbox"
212
- description="Are you sure you want to disconnect this email account? Active campaigns using this mailbox will cease dispatching from it."
213
- confirmText="Disconnect"
214
- />
215
-
216
- {/* Add Mailbox Drawer */}
217
- <FormDrawer
218
- isOpen={isAddOpen}
219
- onClose={() => setIsAddOpen(false)}
220
- title="Connect Sending Mailbox"
221
- subtitle="Add Google Workspace, Microsoft 365, or Custom SMTP/IMAP credentials."
222
- >
223
- <div className="space-y-5 text-xs">
224
- {/* Provider Selector Tabs */}
225
- <div className="grid grid-cols-3 gap-2 p-1 bg-zinc-900 border border-zinc-800 rounded-xl">
226
- <button
227
- type="button"
228
- onClick={() => setActiveSetupTab("smtp")}
229
- className={`py-2 rounded-lg font-medium transition-colors ${
230
- activeSetupTab === "smtp" ? "bg-indigo-600 text-white" : "text-zinc-400 hover:text-zinc-200"
231
- }`}
232
- >
233
- SMTP / IMAP
234
- </button>
235
- <button
236
- type="button"
237
- onClick={() => setActiveSetupTab("google")}
238
- className={`py-2 rounded-lg font-medium transition-colors ${
239
- activeSetupTab === "google" ? "bg-indigo-600 text-white" : "text-zinc-400 hover:text-zinc-200"
240
- }`}
241
- >
242
- Google OAuth
243
- </button>
244
- <button
245
- type="button"
246
- onClick={() => setActiveSetupTab("outlook")}
247
- className={`py-2 rounded-lg font-medium transition-colors ${
248
- activeSetupTab === "outlook" ? "bg-indigo-600 text-white" : "text-zinc-400 hover:text-zinc-200"
249
- }`}
250
- >
251
- Microsoft 365
252
- </button>
253
- </div>
254
-
255
- {activeSetupTab === "smtp" && (
256
- <form
257
- onSubmit={async (e) => {
258
- e.preventDefault();
259
- const form = e.target as HTMLFormElement;
260
- const data = {
261
- from_name: (form.elements.namedItem("from_name") as HTMLInputElement).value,
262
- from_email: (form.elements.namedItem("from_email") as HTMLInputElement).value,
263
- smtp_host: (form.elements.namedItem("smtp_host") as HTMLInputElement).value,
264
- smtp_port: Number((form.elements.namedItem("smtp_port") as HTMLInputElement).value),
265
- smtp_username: (form.elements.namedItem("smtp_username") as HTMLInputElement).value,
266
- smtp_password: (form.elements.namedItem("smtp_password") as HTMLInputElement).value,
267
- imap_host: (form.elements.namedItem("imap_host") as HTMLInputElement).value,
268
- imap_port: Number((form.elements.namedItem("imap_port") as HTMLInputElement).value),
269
- imap_username: (form.elements.namedItem("imap_username") as HTMLInputElement).value,
270
- imap_password: (form.elements.namedItem("imap_password") as HTMLInputElement).value,
271
- daily_limit: Number((form.elements.namedItem("daily_limit") as HTMLInputElement).value),
272
- provider: "smtp",
273
- };
274
-
275
- try {
276
- await apiClient.post("/email-accounts/smtp", data);
277
- toast.success("SMTP Mailbox connected successfully");
278
- setIsAddOpen(false);
279
- refetch();
280
- } catch (err: any) {
281
- toast.error(err.response?.data?.error || "Failed to connect SMTP account");
282
- }
283
- }}
284
- className="space-y-4"
285
- >
286
- <div className="grid grid-cols-2 gap-3">
287
- <div>
288
- <label className="block font-medium text-zinc-300 mb-1">Sender Name *</label>
289
- <input name="from_name" required placeholder="Alex Turner" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
290
- </div>
291
- <div>
292
- <label className="block font-medium text-zinc-300 mb-1">Email Address *</label>
293
- <input name="from_email" type="email" required placeholder="alex@company.com" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
294
- </div>
295
- </div>
296
-
297
- <div className="pt-2 border-t border-zinc-800 space-y-3">
298
- <h4 className="font-semibold text-zinc-400 uppercase tracking-wider text-[10px]">SMTP Configuration (Outbound)</h4>
299
- <div className="grid grid-cols-3 gap-2">
300
- <div className="col-span-2">
301
- <label className="block font-medium text-zinc-400 mb-1">Host</label>
302
- <input name="smtp_host" defaultValue="smtp.mailgun.org" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
303
- </div>
304
- <div>
305
- <label className="block font-medium text-zinc-400 mb-1">Port</label>
306
- <input name="smtp_port" defaultValue="587" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
307
- </div>
308
- </div>
309
- <div className="grid grid-cols-2 gap-2">
310
- <div>
311
- <label className="block font-medium text-zinc-400 mb-1">Username</label>
312
- <input name="smtp_username" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
313
- </div>
314
- <div>
315
- <label className="block font-medium text-zinc-400 mb-1">Password</label>
316
- <input name="smtp_password" type="password" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
317
- </div>
318
- </div>
319
- </div>
320
-
321
- <div className="pt-2 border-t border-zinc-800 space-y-3">
322
- <h4 className="font-semibold text-zinc-400 uppercase tracking-wider text-[10px]">IMAP Configuration (Inbox & Replies)</h4>
323
- <div className="grid grid-cols-3 gap-2">
324
- <div className="col-span-2">
325
- <label className="block font-medium text-zinc-400 mb-1">Host</label>
326
- <input name="imap_host" defaultValue="imap.mailgun.org" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
327
- </div>
328
- <div>
329
- <label className="block font-medium text-zinc-400 mb-1">Port</label>
330
- <input name="imap_port" defaultValue="993" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
331
- </div>
332
- </div>
333
- <div className="grid grid-cols-2 gap-2">
334
- <div>
335
- <label className="block font-medium text-zinc-400 mb-1">Username</label>
336
- <input name="imap_username" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
337
- </div>
338
- <div>
339
- <label className="block font-medium text-zinc-400 mb-1">Password</label>
340
- <input name="imap_password" type="password" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
341
- </div>
342
- </div>
343
- </div>
344
-
345
- <div>
346
- <label className="block font-medium text-zinc-300 mb-1">Max Daily Send Limit</label>
347
- <input name="daily_limit" type="number" defaultValue="50" min="1" max="2000" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
348
- </div>
349
-
350
- <div className="pt-4 flex justify-end gap-2 border-t border-zinc-800">
351
- <button
352
- type="button"
353
- onClick={() => setIsAddOpen(false)}
354
- className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
355
- >
356
- Cancel
357
- </button>
358
- <button
359
- type="submit"
360
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg"
361
- >
362
- Connect Mailbox
363
- </button>
364
- </div>
365
- </form>
366
- )}
367
-
368
- {(activeSetupTab === "google" || activeSetupTab === "outlook") && (
369
- <div className="py-8 text-center space-y-4">
370
- <div className="p-4 bg-zinc-900 rounded-xl border border-zinc-800 max-w-sm mx-auto">
371
- <div className="text-2xl mb-2 font-bold">{activeSetupTab === "google" ? "Google Workspace" : "Microsoft 365"}</div>
372
- <p className="text-zinc-400 text-xs">
373
- Authorize direct OAuth access to dispatch outbound sequences with verified sender tokens.
374
- </p>
375
- <button
376
- type="button"
377
- onClick={() => {
378
- toast.success(`Connected ${activeSetupTab} account`);
379
- setIsAddOpen(false);
380
- refetch();
381
- }}
382
- className="mt-4 w-full py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg shadow-md"
383
- >
384
- Authorize with {activeSetupTab === "google" ? "Google" : "Microsoft"}
385
- </button>
386
- </div>
387
- </div>
388
- )}
389
- </div>
390
- </FormDrawer>
391
- </div>
392
- );
393
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Gate.tsx DELETED
@@ -1,262 +0,0 @@
1
- import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
2
- import { useNavigate } from "react-router-dom";
3
- import { useApiKey } from "../context/ApiKeyContext";
4
- import "./gate.css";
5
-
6
- type Phase = "idle" | "handshake" | "cleared";
7
-
8
- const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
9
-
10
- function delay(ms: number) {
11
- return new Promise((resolve) => setTimeout(resolve, ms));
12
- }
13
-
14
- function maskKey(key: string): string {
15
- const trimmed = key.trim();
16
- if (trimmed.length <= 8) return "••••";
17
- return `${trimmed.slice(0, 4)}••••${trimmed.slice(-4)}`;
18
- }
19
-
20
- function stampDate(): string {
21
- return new Date()
22
- .toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
23
- .toUpperCase();
24
- }
25
-
26
- export default function Gate() {
27
- const { apiKeys, activeKeyId, addApiKey, setActiveKeyId } = useApiKey();
28
- const navigate = useNavigate();
29
-
30
- const prefersReducedMotion = useMemo(
31
- () => window.matchMedia(REDUCED_MOTION_QUERY).matches,
32
- []
33
- );
34
-
35
- const hasStoredKeys = apiKeys.length > 0;
36
- const [enterMode, setEnterMode] = useState<"resume" | "fresh">(
37
- hasStoredKeys ? "resume" : "fresh"
38
- );
39
-
40
- const [workspaceName, setWorkspaceName] = useState("");
41
- const [apiKey, setApiKey] = useState("");
42
- const [showKey, setShowKey] = useState(false);
43
- const [resumeKeyId, setResumeKeyId] = useState(activeKeyId ?? apiKeys[0]?.id ?? "");
44
- const [error, setError] = useState<string | null>(null);
45
- const [phase, setPhase] = useState<Phase>("idle");
46
-
47
- const resumeKey = apiKeys.find((k) => k.id === resumeKeyId);
48
-
49
- const aliveRef = useRef(true);
50
- const timersRef = useRef<number[]>([]);
51
- useEffect(() => {
52
- aliveRef.current = true;
53
- return () => {
54
- aliveRef.current = false;
55
- timersRef.current.forEach((t) => clearTimeout(t));
56
- };
57
- }, []);
58
-
59
- function finish(opts: { name: string; key: string; resumeId?: string }) {
60
- if (!aliveRef.current) return;
61
- if (opts.resumeId) {
62
- setActiveKeyId(opts.resumeId);
63
- } else {
64
- addApiKey(opts.name, opts.key, true);
65
- }
66
- navigate("/", { replace: true });
67
- }
68
-
69
- async function runHandshake(opts: { name: string; key: string; resumeId?: string }) {
70
- setPhase("handshake");
71
- setError(null);
72
-
73
- if (prefersReducedMotion) {
74
- finish(opts);
75
- return;
76
- }
77
-
78
- await delay(620);
79
- if (!aliveRef.current) return;
80
- setPhase("cleared");
81
- const t = window.setTimeout(() => {
82
- finish(opts);
83
- }, 900);
84
- timersRef.current.push(t);
85
- }
86
-
87
- function handleSubmit(event: FormEvent<HTMLFormElement>) {
88
- event.preventDefault();
89
- if (phase !== "idle") return;
90
-
91
- if (enterMode === "resume" && resumeKey) {
92
- void runHandshake({
93
- name: resumeKey.name,
94
- key: resumeKey.key,
95
- resumeId: resumeKey.id,
96
- });
97
- return;
98
- }
99
-
100
- const name = workspaceName.trim();
101
- const key = apiKey.trim();
102
- if (!name) {
103
- setError("Name your workspace — anything you will recognize later.");
104
- return;
105
- }
106
- if (!key) {
107
- setError("Paste your Smartlead API key to open the module.");
108
- return;
109
- }
110
- void runHandshake({ name, key });
111
- }
112
-
113
- function enterDemo() {
114
- if (phase !== "idle") return;
115
- setEnterMode("fresh");
116
- setWorkspaceName("Demo Workspace");
117
- setApiKey("demo_bridge_e");
118
- setError(null);
119
- void runHandshake({ name: "Demo Workspace", key: "demo_bridge_e" });
120
- }
121
-
122
- const busy = phase !== "idle";
123
- const buttonLabel =
124
- phase === "idle"
125
- ? "OPEN MODULE"
126
- : phase === "handshake"
127
- ? "OPENING…"
128
- : "CLEARED";
129
-
130
- return (
131
- <div className="gate">
132
- <h1 className="gate-sr">BRIDGE Modul E — connect a Smartlead workspace</h1>
133
-
134
- <span className="gate-cropmark tl" aria-hidden="true" />
135
- <span className="gate-cropmark tr" aria-hidden="true" />
136
- <span className="gate-cropmark bl" aria-hidden="true" />
137
- <span className="gate-cropmark br" aria-hidden="true" />
138
-
139
- <div className="gate-formwrap">
140
- {phase === "cleared" && (
141
- <svg className="gate-stamp" viewBox="0 0 120 120" aria-hidden="true">
142
- <circle cx="60" cy="60" r="56" fill="none" stroke="currentColor" strokeWidth="2.5" />
143
- <circle cx="60" cy="60" r="47" fill="none" stroke="currentColor" strokeWidth="1.2" />
144
- <path id="gate-stamp-arc" d="M 24 60 A 36 36 0 0 1 96 60" fill="none" />
145
- <text fontSize="10.5" fontWeight="700" letterSpacing="2.5" fill="currentColor">
146
- <textPath href="#gate-stamp-arc" startOffset="50%" textAnchor="middle">
147
- CLEARED FOR DISPATCH
148
- </textPath>
149
- </text>
150
- <text x="60" y="63" textAnchor="middle" fontSize="15" fontWeight="800" letterSpacing="1.5" fill="currentColor">
151
- MODUL·E
152
- </text>
153
- <text x="60" y="78" textAnchor="middle" fontSize="8.5" fontWeight="600" letterSpacing="2" fill="currentColor">
154
- {stampDate()}
155
- </text>
156
- </svg>
157
- )}
158
-
159
- <div className="gate-card">
160
- <form onSubmit={handleSubmit} noValidate>
161
- {enterMode === "resume" && hasStoredKeys ? (
162
- <div className="gate-field">
163
- <label htmlFor="gate-resume">Stored workspace</label>
164
- <select
165
- id="gate-resume"
166
- value={resumeKeyId}
167
- onChange={(e) => setResumeKeyId(e.target.value)}
168
- disabled={busy}
169
- >
170
- {apiKeys.map((k) => (
171
- <option key={k.id} value={k.id}>
172
- {k.name} — {maskKey(k.key)}
173
- </option>
174
- ))}
175
- </select>
176
- </div>
177
- ) : (
178
- <>
179
- <div className="gate-field">
180
- <label htmlFor="gate-name">Workspace name</label>
181
- <input
182
- id="gate-name"
183
- type="text"
184
- value={workspaceName}
185
- onChange={(e) => setWorkspaceName(e.target.value)}
186
- placeholder="e.g. Acme growth pod"
187
- autoComplete="off"
188
- disabled={busy}
189
- />
190
- </div>
191
- <div className="gate-field">
192
- <label htmlFor="gate-key">Smartlead API key</label>
193
- <div className="gate-key-row">
194
- <input
195
- id="gate-key"
196
- type={showKey ? "text" : "password"}
197
- value={apiKey}
198
- onChange={(e) => setApiKey(e.target.value)}
199
- placeholder="Paste your Smartlead key"
200
- autoComplete="off"
201
- spellCheck={false}
202
- disabled={busy}
203
- className="gate-key-input"
204
- />
205
- <button
206
- type="button"
207
- className="gate-peek"
208
- onClick={() => setShowKey((v) => !v)}
209
- disabled={busy || apiKey.length === 0}
210
- >
211
- {showKey ? "Hide" : "Show"}
212
- </button>
213
- </div>
214
- </div>
215
- </>
216
- )}
217
-
218
- {error && (
219
- <p className="gate-error" role="alert">
220
- {error}
221
- </p>
222
- )}
223
-
224
- <button
225
- type="submit"
226
- className="gate-cta"
227
- data-phase={phase}
228
- disabled={busy}
229
- >
230
- {buttonLabel}
231
- </button>
232
-
233
- {enterMode === "resume" ? (
234
- <button
235
- type="button"
236
- className="gate-switch"
237
- onClick={() => {
238
- setEnterMode("fresh");
239
- setError(null);
240
- }}
241
- disabled={busy}
242
- >
243
- Use a different key
244
- </button>
245
- ) : (
246
- <>
247
- <div className="gate-or">
248
- <span aria-hidden="true" />
249
- OR
250
- <span aria-hidden="true" />
251
- </div>
252
- <button type="button" className="gate-demo" onClick={enterDemo} disabled={busy}>
253
- Enter with demo data
254
- </button>
255
- </>
256
- )}
257
- </form>
258
- </div>
259
- </div>
260
- </div>
261
- );
262
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Inbox.tsx DELETED
@@ -1,307 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- Inbox as InboxIcon,
5
- Search,
6
- Send,
7
- Sparkles,
8
- CheckCircle2,
9
- AlertCircle,
10
- Clock,
11
- Mail,
12
- Calendar,
13
- User,
14
- Building,
15
- ChevronRight,
16
- Paperclip,
17
- Smile,
18
- RefreshCw
19
- } from "lucide-react";
20
- import { useState } from "react";
21
- import DOMPurify from "dompurify";
22
- import StatusBadge from "../components/StatusBadge";
23
- import RichTextEditor from "../components/RichTextEditor";
24
- import toast from "react-hot-toast";
25
-
26
- const CATEGORIES = [
27
- { key: "ALL", label: "All Messages" },
28
- { key: "INTERESTED", label: "Interested" },
29
- { key: "MEETING_BOOKED", label: "Meeting Booked" },
30
- { key: "REPLIED", label: "Replied" },
31
- { key: "NOT_INTERESTED", label: "Not Interested" },
32
- ];
33
-
34
- export default function Inbox() {
35
- const queryClient = useQueryClient();
36
- const [selectedCategory, setSelectedCategory] = useState("ALL");
37
- const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
38
- const [replyBody, setReplyBody] = useState("");
39
-
40
- const { data: messages = [], isLoading, isError, refetch } = useQuery({
41
- queryKey: ["inbox-messages", selectedCategory],
42
- queryFn: async () => {
43
- const res = await apiClient.get("/inbox", {
44
- params: selectedCategory !== "ALL" ? { category: selectedCategory } : {},
45
- });
46
- return res.data;
47
- },
48
- });
49
-
50
- const selectedMessage = (Array.isArray(messages) ? messages : []).find((m: any) => m.id === selectedMessageId) || (Array.isArray(messages) ? messages[0] : null);
51
-
52
- const updateCategoryMutation = useMutation({
53
- mutationFn: async ({ messageId, category }: { messageId: string; category: string }) => {
54
- const res = await apiClient.post("/inbox/categorize", { thread_id: messageId, category });
55
- return res.data;
56
- },
57
- onSuccess: () => {
58
- toast.success("Lead categorization updated");
59
- queryClient.invalidateQueries({ queryKey: ["inbox-messages"] });
60
- },
61
- onError: () => {
62
- toast.error("Failed to update lead category");
63
- },
64
- });
65
-
66
- const sendReplyMutation = useMutation({
67
- mutationFn: async ({ campaignId, messageId, body }: { campaignId: string; messageId: string; body: string }) => {
68
- const res = await apiClient.post(`/campaigns/${campaignId}/reply-email-thread`, { thread_id: messageId, reply_body: body });
69
- return res.data;
70
- },
71
- onSuccess: () => {
72
- toast.success("Reply dispatched to lead");
73
- setReplyBody("");
74
- queryClient.invalidateQueries({ queryKey: ["inbox-messages"] });
75
- },
76
- onError: () => {
77
- toast.error("Failed to send reply");
78
- }
79
- });
80
-
81
- return (
82
- <div className="h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
83
- {/* Top Controls */}
84
- <div className="h-14 border-b border-zinc-800 bg-zinc-950 px-6 flex items-center justify-between shrink-0">
85
- <div className="flex items-center gap-3">
86
- <div className="p-1.5 bg-indigo-500/10 text-indigo-400 rounded-lg">
87
- <InboxIcon className="w-5 h-5" />
88
- </div>
89
- <div>
90
- <h1 className="text-base font-bold text-zinc-100 flex items-center gap-2">
91
- Unified Master Inbox
92
- <span className="text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded-full">
93
- AI Sentiment Active
94
- </span>
95
- </h1>
96
- </div>
97
- </div>
98
-
99
- <div className="flex items-center gap-2">
100
- <button
101
- onClick={() => refetch()}
102
- className="p-1.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 rounded-lg transition-colors"
103
- title="Refresh Inboxes"
104
- >
105
- <RefreshCw className="w-4 h-4" />
106
- </button>
107
- </div>
108
- </div>
109
-
110
- {/* 2-Pane Inbox Workspace */}
111
- <div className="flex-1 flex overflow-hidden">
112
- {/* Left Pane: Message List */}
113
- <div className="w-80 md:w-96 border-r border-zinc-800 flex flex-col bg-zinc-950/60 shrink-0">
114
- {/* Categories Filter Tabs */}
115
- <div className="p-3 border-b border-zinc-800 flex gap-1 overflow-x-auto">
116
- {CATEGORIES.map((cat) => (
117
- <button
118
- key={cat.key}
119
- onClick={() => setSelectedCategory(cat.key)}
120
- className={`px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap transition-colors ${
121
- selectedCategory === cat.key
122
- ? "bg-indigo-600 text-white"
123
- : "bg-zinc-900 text-zinc-400 hover:text-zinc-200"
124
- }`}
125
- >
126
- {cat.label}
127
- </button>
128
- ))}
129
- </div>
130
-
131
- {/* Messages Scroll Area */}
132
- <div className="flex-1 overflow-y-auto divide-y divide-zinc-800/60">
133
- {isLoading && (
134
- <div className="p-8 text-center text-xs text-zinc-400" role="status">Loading message threads…</div>
135
- )}
136
-
137
- {isError && (
138
- <div className="p-8 text-center text-xs text-rose-300" role="alert">Unable to load message threads.</div>
139
- )}
140
-
141
- {!isLoading && !isError && messages.map((msg: any) => {
142
- const isSelected = msg.id === selectedMessage?.id;
143
- return (
144
- <div
145
- key={msg.id}
146
- onClick={() => setSelectedMessageId(msg.id)}
147
- onKeyDown={(event) => {
148
- if (event.key === "Enter" || event.key === " ") setSelectedMessageId(msg.id);
149
- }}
150
- role="button"
151
- tabIndex={0}
152
- aria-pressed={isSelected}
153
- className={`p-4 transition-colors cursor-pointer ${
154
- isSelected ? "bg-indigo-950/30 border-l-2 border-indigo-500" : "hover:bg-zinc-900/40"
155
- }`}
156
- >
157
- <div className="flex items-start justify-between gap-2">
158
- <span className="font-semibold text-xs text-zinc-100 truncate">
159
- {msg.lead_name || msg.lead_email}
160
- </span>
161
- <span className="text-[10px] text-zinc-500 shrink-0">
162
- {new Date(msg.received_at || Date.now()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
163
- </span>
164
- </div>
165
-
166
- <h4 className="text-xs font-medium text-zinc-300 mt-1 truncate">
167
- {msg.subject}
168
- </h4>
169
-
170
- <p className="text-[11px] text-zinc-500 mt-1 line-clamp-2 leading-relaxed">
171
- {msg.messages?.at(-1)?.body_html?.replace(/<[^>]*>?/gm, "") || "No message preview available"}
172
- </p>
173
-
174
- <div className="mt-2.5 flex items-center justify-between">
175
- <StatusBadge status={msg.category || "REPLIED"} size="sm" />
176
- <span className="text-[10px] text-zinc-400 font-mono">
177
- {msg.company_name}
178
- </span>
179
- </div>
180
- </div>
181
- );
182
- })}
183
-
184
- {messages.length === 0 && (
185
- <div className="p-8 text-center text-xs text-zinc-500">
186
- No replies found for this category filter.
187
- </div>
188
- )}
189
- </div>
190
- </div>
191
-
192
- {/* Right Pane: Thread View & Response Composer */}
193
- {selectedMessage ? (
194
- <div className="flex-1 flex flex-col bg-zinc-950 overflow-hidden">
195
- {/* Thread Header */}
196
- <div className="p-5 border-b border-zinc-800 bg-zinc-900/30 flex items-center justify-between shrink-0">
197
- <div className="flex items-center gap-3">
198
- <div className="w-9 h-9 rounded-full bg-indigo-600/20 text-indigo-400 flex items-center justify-center font-bold text-sm border border-indigo-500/30">
199
- {selectedMessage.lead_name ? selectedMessage.lead_name[0] : "L"}
200
- </div>
201
- <div>
202
- <h3 className="text-sm font-bold text-zinc-100 flex items-center gap-2">
203
- {selectedMessage.subject}
204
- </h3>
205
- <div className="text-xs text-zinc-400 flex items-center gap-2 mt-0.5">
206
- <span>{selectedMessage.lead_name} &lt;{selectedMessage.lead_email}&gt;</span>
207
- <span>•</span>
208
- <span className="font-mono text-zinc-500">{selectedMessage.company_name}</span>
209
- </div>
210
- </div>
211
- </div>
212
-
213
- {/* Categorization Dropdown */}
214
- <div className="flex items-center gap-2">
215
- <span className="text-xs text-zinc-500">AI Intent:</span>
216
- <select
217
- value={selectedMessage.category || "REPLIED"}
218
- onChange={(e) =>
219
- updateCategoryMutation.mutate({
220
- messageId: selectedMessage.id,
221
- category: e.target.value,
222
- })
223
- }
224
- className="bg-zinc-900 border border-zinc-800 rounded-lg py-1.5 px-3 text-xs text-zinc-200 focus:outline-none focus:border-indigo-500"
225
- >
226
- <option value="INTERESTED">Interested</option>
227
- <option value="MEETING_BOOKED">Meeting Booked</option>
228
- <option value="REPLIED">Replied</option>
229
- <option value="NOT_INTERESTED">Not Interested</option>
230
- </select>
231
- </div>
232
- </div>
233
-
234
- {/* Conversation Messages */}
235
- <div className="flex-1 overflow-y-auto p-6 space-y-4">
236
- {(selectedMessage.messages || []).map((message: any) => {
237
- const isInbound = message.direction === "INBOUND";
238
- return (
239
- <div key={message.id} className={`max-w-2xl ${isInbound ? "ml-auto bg-indigo-950/40 border-indigo-500/30" : "bg-zinc-900 border-zinc-800"} border rounded-xl p-4 text-xs space-y-2`}>
240
- <div className={`flex items-center justify-between ${isInbound ? "text-indigo-300 border-indigo-500/20" : "text-zinc-400 border-zinc-800"} pb-2 border-b`}>
241
- <span className="font-semibold">{isInbound ? "Prospect reply" : "Outbound message"}</span>
242
- <span className="text-[10px] text-zinc-400">{new Date(message.timestamp).toLocaleString()}</span>
243
- </div>
244
- <div
245
- className="text-zinc-200 leading-relaxed prose prose-invert max-w-none text-xs"
246
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(message.body_html || "") }}
247
- />
248
- </div>
249
- );
250
- })}
251
- </div>
252
-
253
- {/* Reply Composer Bottom Section */}
254
- <div className="p-4 border-t border-zinc-800 bg-zinc-950 shrink-0 space-y-3">
255
- <RichTextEditor
256
- content={replyBody}
257
- onChange={setReplyBody}
258
- placeholder="Compose your reply or insert calendar meeting booking link..."
259
- />
260
-
261
- <div className="flex items-center justify-between">
262
- <div className="flex items-center gap-2">
263
- <button
264
- type="button"
265
- onClick={() => setReplyBody("<p>Hi, how does Tuesday at 2 PM EST work for a quick 15-min call? Here is my calendar: https://cal.com/alex/15min</p>")}
266
- className="px-2.5 py-1 bg-zinc-900 hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 border border-zinc-800 rounded-md text-[11px] transition-colors"
267
- >
268
- + Insert Booking Link
269
- </button>
270
- <button
271
- type="button"
272
- onClick={() => setReplyBody("<p>Thanks for getting back to me! I have attached our case study PDF here.</p>")}
273
- className="px-2.5 py-1 bg-zinc-900 hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 border border-zinc-800 rounded-md text-[11px] transition-colors"
274
- >
275
- + Insert Case Study
276
- </button>
277
- </div>
278
-
279
- <button
280
- type="button"
281
- onClick={() => {
282
- if (!replyBody) {
283
- toast.error("Please write a reply message");
284
- return;
285
- }
286
- sendReplyMutation.mutate({
287
- campaignId: selectedMessage.campaign_id,
288
- messageId: selectedMessage.id,
289
- body: replyBody,
290
- });
291
- }}
292
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs rounded-lg transition-colors flex items-center gap-2 shadow-md"
293
- >
294
- <Send className="w-3.5 h-3.5" /> Send Reply
295
- </button>
296
- </div>
297
- </div>
298
- </div>
299
- ) : (
300
- <div className="flex-1 flex items-center justify-center text-xs text-zinc-500">
301
- Select a message thread from the left pane to view conversation.
302
- </div>
303
- )}
304
- </div>
305
- </div>
306
- );
307
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Leads.tsx DELETED
@@ -1,622 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- Users,
5
- Search,
6
- Plus,
7
- ShieldAlert,
8
- Trash2,
9
- Mail,
10
- Building,
11
- Globe,
12
- MapPin,
13
- Linkedin,
14
- Phone,
15
- Filter,
16
- Clock,
17
- CheckCircle2,
18
- Ban,
19
- ArrowUpRight
20
- } from "lucide-react";
21
- import { useState } from "react";
22
- import StatusBadge from "../components/StatusBadge";
23
- import FormDrawer from "../components/FormDrawer";
24
- import CsvExportButton from "../components/CsvExportButton";
25
- import ConfirmModal from "../components/ConfirmModal";
26
- import toast from "react-hot-toast";
27
-
28
- const STATUSES = [
29
- "ALL",
30
- "INTERESTED",
31
- "MEETING_BOOKED",
32
- "MEETING_DONE",
33
- "REPLIED",
34
- "NOT_INTERESTED",
35
- "UNSUBSCRIBED",
36
- "BOUNCED",
37
- "DO_NOT_CONTACT"
38
- ];
39
-
40
- export default function Leads() {
41
- const queryClient = useQueryClient();
42
- const [activeTab, setActiveTab] = useState<"leads" | "blocklist">("leads");
43
- const [searchQuery, setSearchQuery] = useState("");
44
- const [selectedStatus, setSelectedStatus] = useState("ALL");
45
- const [selectedLead, setSelectedLead] = useState<any | null>(null);
46
- const [isAddLeadOpen, setIsAddLeadOpen] = useState(false);
47
- const [isAddBlocklistOpen, setIsAddBlocklistOpen] = useState(false);
48
- const [blocklistType, setBlocklistType] = useState<"email" | "domain">("email");
49
-
50
- const { data: leads = [], isLoading } = useQuery({
51
- queryKey: ["leads-search", searchQuery, selectedStatus],
52
- queryFn: async () => {
53
- const res = await apiClient.get("/leads/search", {
54
- params: { q: searchQuery, status: selectedStatus },
55
- });
56
- return res.data;
57
- },
58
- });
59
-
60
- const { data: emailBlockList = [], refetch: refetchEmailBlockList } = useQuery({
61
- queryKey: ["blocklist-emails"],
62
- queryFn: async () => {
63
- const res = await apiClient.get("/leads/block-list");
64
- return res.data;
65
- },
66
- });
67
-
68
- const { data: domainBlockList = [], refetch: refetchDomainBlockList } = useQuery({
69
- queryKey: ["blocklist-domains"],
70
- queryFn: async () => {
71
- const res = await apiClient.get("/leads/domain-block-list");
72
- return res.data;
73
- },
74
- });
75
-
76
- const updateLeadStatusMutation = useMutation({
77
- mutationFn: async ({ campaignId, leadId, status }: { campaignId: string; leadId: string; status: string }) => {
78
- const res = await apiClient.post(`/campaigns/${campaignId}/leads/status`, { lead_id: leadId, status });
79
- return res.data;
80
- },
81
- onSuccess: () => {
82
- toast.success("Lead status updated");
83
- queryClient.invalidateQueries({ queryKey: ["leads-search"] });
84
- },
85
- });
86
-
87
- const deleteLeadMutation = useMutation({
88
- mutationFn: async ({ campaignId, leadId }: { campaignId: string; leadId: string }) => {
89
- const res = await apiClient.delete(`/campaigns/${campaignId}/leads/${leadId}`);
90
- return res.data;
91
- },
92
- onSuccess: () => {
93
- toast.success("Lead removed");
94
- setSelectedLead(null);
95
- queryClient.invalidateQueries({ queryKey: ["leads-search"] });
96
- },
97
- });
98
-
99
- return (
100
- <div className="p-6 max-w-7xl mx-auto space-y-6">
101
- {/* Header */}
102
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
103
- <div>
104
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
105
- <Users className="w-6 h-6 text-teal-400" />
106
- Lead Intelligence Database
107
- </h1>
108
- <p className="text-zinc-400 text-sm mt-1">
109
- Global repository of targeted prospects, responses, and suppression blocklists.
110
- </p>
111
- </div>
112
-
113
- <div className="flex items-center gap-3">
114
- <button
115
- onClick={() => setIsAddLeadOpen(true)}
116
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-2"
117
- >
118
- <Plus className="w-4 h-4" /> Add Prospect
119
- </button>
120
- </div>
121
- </div>
122
-
123
- {/* Main Tabs */}
124
- <div className="border-b border-zinc-800 flex items-center gap-2 text-sm font-medium">
125
- <button
126
- onClick={() => setActiveTab("leads")}
127
- className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors ${
128
- activeTab === "leads"
129
- ? "border-indigo-500 text-indigo-400 bg-indigo-500/5"
130
- : "border-transparent text-zinc-400 hover:text-zinc-200"
131
- }`}
132
- >
133
- <Users className="w-4 h-4" /> Global Leads ({leads.length})
134
- </button>
135
- <button
136
- onClick={() => setActiveTab("blocklist")}
137
- className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors ${
138
- activeTab === "blocklist"
139
- ? "border-indigo-500 text-indigo-400 bg-indigo-500/5"
140
- : "border-transparent text-zinc-400 hover:text-zinc-200"
141
- }`}
142
- >
143
- <ShieldAlert className="w-4 h-4" /> Suppression Blocklists
144
- </button>
145
- </div>
146
-
147
- {activeTab === "leads" && (
148
- <div className="space-y-4">
149
- {/* Filter Bar */}
150
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
151
- <div className="relative flex-1 max-w-md">
152
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" />
153
- <input
154
- type="text"
155
- placeholder="Search by name, email, or company..."
156
- value={searchQuery}
157
- onChange={(e) => setSearchQuery(e.target.value)}
158
- className="w-full bg-black/40 border border-white/5 rounded-lg py-2 pl-9 pr-4 text-xs text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] transition-all"
159
- />
160
- </div>
161
-
162
- <div className="flex items-center gap-3">
163
- <select
164
- value={selectedStatus}
165
- onChange={(e) => setSelectedStatus(e.target.value)}
166
- className="bg-black/40 border border-white/5 rounded-lg py-2 px-3 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] transition-all"
167
- >
168
- {STATUSES.map((st) => (
169
- <option key={st} value={st} className="bg-zinc-900">
170
- Status: {st.replace(/_/g, " ")}
171
- </option>
172
- ))}
173
- </select>
174
- <CsvExportButton data={leads} filename="smartlead-global-leads.csv" />
175
- </div>
176
- </div>
177
-
178
- {/* Leads Table */}
179
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl overflow-hidden shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
180
- <div className="overflow-x-auto">
181
- <table className="w-full text-sm text-left">
182
- <thead className="bg-black/20 border-b border-white/5 text-zinc-400 text-xs uppercase font-sans tracking-wider">
183
- <tr>
184
- <th className="px-6 py-3.5 font-semibold">Prospect</th>
185
- <th className="px-6 py-3.5 font-semibold">Company</th>
186
- <th className="px-6 py-3.5 font-semibold">Status</th>
187
- <th className="px-6 py-3.5 font-semibold">Engagement</th>
188
- <th className="px-6 py-3.5 font-semibold">Location</th>
189
- <th className="px-6 py-3.5 font-semibold text-right">Actions</th>
190
- </tr>
191
- </thead>
192
- <tbody className="divide-y divide-white/5">
193
- {leads.map((lead: any) => (
194
- <tr
195
- key={lead.id}
196
- onClick={() => setSelectedLead(lead)}
197
- className="hover:bg-white/5 transition-colors cursor-pointer"
198
- >
199
- <td className="px-6 py-4">
200
- <div className="font-semibold text-zinc-100 flex items-center gap-2">
201
- {lead.first_name} {lead.last_name}
202
- </div>
203
- <div className="text-xs text-zinc-400 font-mono flex items-center gap-1 mt-0.5">
204
- <Mail className="w-3 h-3 text-zinc-500" /> {lead.email}
205
- </div>
206
- </td>
207
- <td className="px-6 py-4 text-zinc-300">
208
- <div className="font-medium text-zinc-200">{lead.company_name}</div>
209
- {lead.website && (
210
- <div className="text-[11px] text-zinc-500 font-mono truncate max-w-[150px]">
211
- {lead.website.replace("https://", "")}
212
- </div>
213
- )}
214
- </td>
215
- <td className="px-6 py-4">
216
- <StatusBadge status={lead.status} size="sm" />
217
- </td>
218
- <td className="px-6 py-4">
219
- <div className="text-xs text-zinc-300 font-mono">
220
- <span className="text-emerald-400 drop-shadow-[0_0_8px_rgba(63,156,132,0.3)]">{lead.open_count || 0}</span> opens •{" "}
221
- <span className="text-indigo-400 drop-shadow-[0_0_8px_rgba(37,125,104,0.3)]">{lead.click_count || 0}</span> clicks
222
- </div>
223
- </td>
224
- <td className="px-6 py-4 text-xs text-zinc-400 font-sans">
225
- {lead.location || "United States"}
226
- </td>
227
- <td className="px-6 py-4 text-right">
228
- <button
229
- onClick={(e) => {
230
- e.stopPropagation();
231
- setSelectedLead(lead);
232
- }}
233
- className="px-3 py-1.5 bg-white/5 hover:bg-white/10 text-zinc-300 text-xs font-medium rounded-lg border border-white/5 transition-all shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]"
234
- >
235
- View Profile
236
- </button>
237
- </td>
238
- </tr>
239
- ))}
240
-
241
- {leads.length === 0 && !isLoading && (
242
- <tr>
243
- <td colSpan={6} className="py-16 text-center text-xs text-zinc-500">
244
- No prospect leads matching current filters.
245
- </td>
246
- </tr>
247
- )}
248
- </tbody>
249
- </table>
250
- </div>
251
- </div>
252
- </div>
253
- )}
254
-
255
- {/* SUB-TAB: BLOCKLIST MANAGER */}
256
- {activeTab === "blocklist" && (
257
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
258
- {/* Email Blocklist */}
259
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)] space-y-4">
260
- <div className="flex items-center justify-between">
261
- <div>
262
- <h3 className="text-sm font-semibold text-zinc-100 flex items-center gap-2">
263
- <Mail className="w-4 h-4 text-rose-400 drop-shadow-[0_0_8px_rgba(203,117,80,0.5)]" />
264
- Email Address Blocklist
265
- </h3>
266
- <p className="text-xs text-zinc-400 mt-0.5">Suppressed emails never receive outreach.</p>
267
- </div>
268
- <button
269
- onClick={() => {
270
- setBlocklistType("email");
271
- setIsAddBlocklistOpen(true);
272
- }}
273
- className="px-2.5 py-1.5 bg-white/5 hover:bg-white/10 text-zinc-200 text-xs font-medium rounded-md border border-white/5 transition-all shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]"
274
- >
275
- + Add Email
276
- </button>
277
- </div>
278
-
279
- <div className="space-y-2">
280
- {emailBlockList.map((item: any) => (
281
- <div
282
- key={item.id}
283
- className="flex items-center justify-between p-3 bg-black/40 border border-white/5 rounded-lg text-xs hover:bg-white/5 transition-colors"
284
- >
285
- <div>
286
- <span className="font-mono text-zinc-200 font-medium">{item.email}</span>
287
- <div className="text-[10px] text-zinc-500 font-sans mt-0.5">{item.reason}</div>
288
- </div>
289
- <button
290
- onClick={async () => {
291
- await apiClient.delete(`/leads/block-list/${item.id}`);
292
- toast.success("Removed from blocklist");
293
- refetchEmailBlockList();
294
- }}
295
- className="text-zinc-500 hover:text-rose-400 p-1.5 rounded-md hover:bg-rose-500/10 transition-colors"
296
- >
297
- <Trash2 className="w-3.5 h-3.5" />
298
- </button>
299
- </div>
300
- ))}
301
- </div>
302
- </div>
303
-
304
- {/* Domain Blocklist */}
305
- <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)] space-y-4">
306
- <div className="flex items-center justify-between">
307
- <div>
308
- <h3 className="text-sm font-semibold text-zinc-100 flex items-center gap-2">
309
- <Globe className="w-4 h-4 text-amber-400 drop-shadow-[0_0_8px_rgba(194,160,74,0.5)]" />
310
- Domain Suppression List
311
- </h3>
312
- <p className="text-xs text-zinc-400 mt-0.5">Blocks all emails under specified root domains.</p>
313
- </div>
314
- <button
315
- onClick={() => {
316
- setBlocklistType("domain");
317
- setIsAddBlocklistOpen(true);
318
- }}
319
- className="px-2.5 py-1.5 bg-white/5 hover:bg-white/10 text-zinc-200 text-xs font-medium rounded-md border border-white/5 transition-all shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]"
320
- >
321
- + Add Domain
322
- </button>
323
- </div>
324
-
325
- <div className="space-y-2">
326
- {domainBlockList.map((item: any) => (
327
- <div
328
- key={item.id}
329
- className="flex items-center justify-between p-3 bg-black/40 border border-white/5 rounded-lg text-xs hover:bg-white/5 transition-colors"
330
- >
331
- <div>
332
- <span className="font-mono text-zinc-200 font-medium">@{item.domain}</span>
333
- <div className="text-[10px] text-zinc-500 font-sans mt-0.5">{item.reason}</div>
334
- </div>
335
- <button
336
- onClick={async () => {
337
- await apiClient.delete(`/leads/domain-block-list/${item.id}`);
338
- toast.success("Domain removed from blocklist");
339
- refetchDomainBlockList();
340
- }}
341
- className="text-zinc-500 hover:text-rose-400 p-1.5 rounded-md hover:bg-rose-500/10 transition-colors"
342
- >
343
- <Trash2 className="w-3.5 h-3.5" />
344
- </button>
345
- </div>
346
- ))}
347
- </div>
348
- </div>
349
- </div>
350
- )}
351
-
352
- {/* Lead Detail Slide-Over Drawer */}
353
- <FormDrawer
354
- isOpen={Boolean(selectedLead)}
355
- onClose={() => setSelectedLead(null)}
356
- title="Prospect Dossier"
357
- subtitle={selectedLead ? `${selectedLead.first_name} ${selectedLead.last_name} (${selectedLead.email})` : ""}
358
- >
359
- {selectedLead && (
360
- <div className="space-y-6 text-xs">
361
- {/* Status Change Card */}
362
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
363
- <label className="block font-medium text-zinc-400 mb-2">Lead Outreach Status</label>
364
- <select
365
- value={selectedLead.status}
366
- onChange={(e) => {
367
- updateLeadStatusMutation.mutate({
368
- campaignId: selectedLead.campaign_id,
369
- leadId: selectedLead.id,
370
- status: e.target.value,
371
- });
372
- setSelectedLead({ ...selectedLead, status: e.target.value });
373
- }}
374
- className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2 text-zinc-100 font-medium"
375
- >
376
- {STATUSES.filter((s) => s !== "ALL").map((st) => (
377
- <option key={st} value={st}>
378
- {st.replace(/_/g, " ")}
379
- </option>
380
- ))}
381
- </select>
382
- </div>
383
-
384
- {/* Profile Fields */}
385
- <div className="space-y-3 bg-zinc-900/60 border border-zinc-800 rounded-xl p-4">
386
- <h4 className="font-semibold text-zinc-300 uppercase tracking-wider text-[10px]">Contact Info</h4>
387
- <div className="grid grid-cols-2 gap-3">
388
- <div>
389
- <span className="text-zinc-500 block">Company</span>
390
- <span className="text-zinc-200 font-medium">{selectedLead.company_name}</span>
391
- </div>
392
- <div>
393
- <span className="text-zinc-500 block">Location</span>
394
- <span className="text-zinc-200 font-medium">{selectedLead.location || "N/A"}</span>
395
- </div>
396
- <div>
397
- <span className="text-zinc-500 block">Phone</span>
398
- <span className="text-zinc-200 font-medium">{selectedLead.phone_number || "N/A"}</span>
399
- </div>
400
- <div>
401
- <span className="text-zinc-500 block">LinkedIn</span>
402
- {selectedLead.linkedin_profile ? (
403
- <a
404
- href={selectedLead.linkedin_profile}
405
- target="_blank"
406
- rel="noreferrer"
407
- className="text-indigo-400 hover:underline"
408
- >
409
- View LinkedIn Profile
410
- </a>
411
- ) : (
412
- <span className="text-zinc-500">Not provided</span>
413
- )}
414
- </div>
415
- </div>
416
- </div>
417
-
418
- {/* Custom Fields */}
419
- {selectedLead.custom_fields && Object.keys(selectedLead.custom_fields).length > 0 && (
420
- <div className="space-y-2 bg-zinc-900/60 border border-zinc-800 rounded-xl p-4">
421
- <h4 className="font-semibold text-zinc-300 uppercase tracking-wider text-[10px]">Custom Attributes</h4>
422
- <div className="grid grid-cols-2 gap-2 font-mono text-[11px]">
423
- {Object.entries(selectedLead.custom_fields).map(([key, val]) => (
424
- <div key={key} className="bg-zinc-950 p-2 rounded border border-zinc-800">
425
- <span className="text-zinc-500 block text-[10px]">{key}</span>
426
- <span className="text-zinc-200 font-semibold">{String(val)}</span>
427
- </div>
428
- ))}
429
- </div>
430
- </div>
431
- )}
432
-
433
- {/* Timeline */}
434
- <div className="space-y-3 bg-zinc-900/60 border border-zinc-800 rounded-xl p-4">
435
- <h4 className="font-semibold text-zinc-300 uppercase tracking-wider text-[10px]">Activity Timeline</h4>
436
- <div className="space-y-3">
437
- <div className="flex items-start gap-2.5">
438
- <div className="w-2 h-2 rounded-full bg-emerald-400 mt-1" />
439
- <div>
440
- <span className="text-zinc-300 font-medium">Email Delivered & Opened</span>
441
- <div className="text-[10px] text-zinc-500">2 hours ago</div>
442
- </div>
443
- </div>
444
- <div className="flex items-start gap-2.5">
445
- <div className="w-2 h-2 rounded-full bg-indigo-400 mt-1" />
446
- <div>
447
- <span className="text-zinc-300 font-medium">Enrolled in Enterprise SaaS Sequence</span>
448
- <div className="text-[10px] text-zinc-500">3 days ago</div>
449
- </div>
450
- </div>
451
- </div>
452
- </div>
453
-
454
- {/* Delete Lead Button */}
455
- <div className="pt-2 flex justify-end">
456
- <button
457
- onClick={() => {
458
- deleteLeadMutation.mutate({
459
- campaignId: selectedLead.campaign_id,
460
- leadId: selectedLead.id,
461
- });
462
- }}
463
- className="px-3 py-1.5 bg-rose-600/20 hover:bg-rose-600 text-rose-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
464
- >
465
- <Trash2 className="w-3.5 h-3.5" /> Remove Lead
466
- </button>
467
- </div>
468
- </div>
469
- )}
470
- </FormDrawer>
471
-
472
- {/* Add Lead Slide-Over */}
473
- <FormDrawer
474
- isOpen={isAddLeadOpen}
475
- onClose={() => setIsAddLeadOpen(false)}
476
- title="Add Global Prospect Lead"
477
- subtitle="Input contact info, company details, and custom variables for personalization."
478
- >
479
- <form
480
- onSubmit={async (e) => {
481
- e.preventDefault();
482
- const form = e.target as HTMLFormElement;
483
- const newLead = {
484
- first_name: (form.elements.namedItem("first_name") as HTMLInputElement).value,
485
- last_name: (form.elements.namedItem("last_name") as HTMLInputElement).value,
486
- email: (form.elements.namedItem("email") as HTMLInputElement).value,
487
- phone_number: (form.elements.namedItem("phone_number") as HTMLInputElement).value,
488
- company_name: (form.elements.namedItem("company_name") as HTMLInputElement).value,
489
- website: (form.elements.namedItem("website") as HTMLInputElement).value,
490
- location: (form.elements.namedItem("location") as HTMLInputElement).value,
491
- linkedin_profile: (form.elements.namedItem("linkedin_profile") as HTMLInputElement).value,
492
- status: "INTERESTED",
493
- };
494
-
495
- try {
496
- await apiClient.post("/campaigns/camp_9901/leads", newLead);
497
- toast.success("Prospect added to database");
498
- setIsAddLeadOpen(false);
499
- queryClient.invalidateQueries({ queryKey: ["leads-search"] });
500
- } catch (err: any) {
501
- toast.error("Failed to add prospect");
502
- }
503
- }}
504
- className="space-y-3.5 text-xs"
505
- >
506
- <div className="grid grid-cols-2 gap-3">
507
- <div>
508
- <label className="block font-medium text-zinc-300 mb-1">First Name *</label>
509
- <input name="first_name" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
510
- </div>
511
- <div>
512
- <label className="block font-medium text-zinc-300 mb-1">Last Name</label>
513
- <input name="last_name" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
514
- </div>
515
- </div>
516
- <div>
517
- <label className="block font-medium text-zinc-300 mb-1">Email Address *</label>
518
- <input type="email" name="email" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
519
- </div>
520
- <div className="grid grid-cols-2 gap-3">
521
- <div>
522
- <label className="block font-medium text-zinc-300 mb-1">Company Name</label>
523
- <input name="company_name" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
524
- </div>
525
- <div>
526
- <label className="block font-medium text-zinc-300 mb-1">Phone Number</label>
527
- <input name="phone_number" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
528
- </div>
529
- </div>
530
- <div>
531
- <label className="block font-medium text-zinc-300 mb-1">Website URL</label>
532
- <input name="website" placeholder="https://" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
533
- </div>
534
- <div className="grid grid-cols-2 gap-3">
535
- <div>
536
- <label className="block font-medium text-zinc-300 mb-1">Location</label>
537
- <input name="location" placeholder="e.g. San Francisco, CA" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
538
- </div>
539
- <div>
540
- <label className="block font-medium text-zinc-300 mb-1">LinkedIn URL</label>
541
- <input name="linkedin_profile" placeholder="https://linkedin.com/in/..." className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
542
- </div>
543
- </div>
544
-
545
- <div className="pt-4 flex justify-end gap-2">
546
- <button
547
- type="button"
548
- onClick={() => setIsAddLeadOpen(false)}
549
- className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
550
- >
551
- Cancel
552
- </button>
553
- <button
554
- type="submit"
555
- className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium shadow"
556
- >
557
- Save Prospect
558
- </button>
559
- </div>
560
- </form>
561
- </FormDrawer>
562
-
563
- {/* Add Blocklist Modal */}
564
- <FormDrawer
565
- isOpen={isAddBlocklistOpen}
566
- onClose={() => setIsAddBlocklistOpen(false)}
567
- title={blocklistType === "email" ? "Add Email to Suppression Blocklist" : "Add Domain to Suppression Blocklist"}
568
- subtitle="Blocked records will never receive any emails from any campaign."
569
- >
570
- <form
571
- onSubmit={async (e) => {
572
- e.preventDefault();
573
- const form = e.target as HTMLFormElement;
574
- const val = (form.elements.namedItem("val") as HTMLInputElement).value;
575
- const reason = (form.elements.namedItem("reason") as HTMLInputElement).value;
576
-
577
- try {
578
- if (blocklistType === "email") {
579
- await apiClient.post("/leads/block-list", { email: val, reason });
580
- refetchEmailBlockList();
581
- } else {
582
- await apiClient.post("/leads/domain-block-list", { domain: val, reason });
583
- refetchDomainBlockList();
584
- }
585
- toast.success("Suppression entry saved");
586
- setIsAddBlocklistOpen(false);
587
- } catch (err: any) {
588
- toast.error("Failed to add entry");
589
- }
590
- }}
591
- className="space-y-4 text-xs"
592
- >
593
- <div>
594
- <label className="block font-medium text-zinc-300 mb-1">
595
- {blocklistType === "email" ? "Email Address" : "Domain (e.g. competitor.com)"}
596
- </label>
597
- <input name="val" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
598
- </div>
599
- <div>
600
- <label className="block font-medium text-zinc-300 mb-1">Reason for Suppression</label>
601
- <input name="reason" placeholder="e.g. Legal opt-out, Competitor" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
602
- </div>
603
- <div className="pt-4 flex justify-end gap-2">
604
- <button
605
- type="button"
606
- onClick={() => setIsAddBlocklistOpen(false)}
607
- className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
608
- >
609
- Cancel
610
- </button>
611
- <button
612
- type="submit"
613
- className="px-4 py-2 bg-rose-600 text-white rounded-lg font-medium"
614
- >
615
- Block Record
616
- </button>
617
- </div>
618
- </form>
619
- </FormDrawer>
620
- </div>
621
- );
622
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Settings.tsx DELETED
@@ -1,434 +0,0 @@
1
- import {
2
- KeyRound,
3
- Plus,
4
- Trash2,
5
- Copy,
6
- CheckCircle2,
7
- AlertCircle,
8
- ShieldCheck,
9
- Sparkles,
10
- Globe,
11
- Clock,
12
- Sliders,
13
- Terminal,
14
- Activity,
15
- Database
16
- } from "lucide-react";
17
- import { useState, useEffect } from "react";
18
- import { useApiKey } from "../context/ApiKeyContext";
19
- import { apiClient } from "../lib/apiClient";
20
- import FormDrawer from "../components/FormDrawer";
21
- import ConfirmModal from "../components/ConfirmModal";
22
- import toast from "react-hot-toast";
23
- import { supabase, isSupabaseConfigured, supabaseProjectRef, supabaseDashboardUrl } from "../lib/supabaseClient";
24
-
25
- export default function Settings() {
26
- const { apiKeys, activeKeyId, setActiveKeyId, addApiKey, removeApiKey, renameApiKey } = useApiKey();
27
- const [activeTab, setActiveTab] = useState<"keys" | "preferences" | "sandbox" | "database">("keys");
28
- const [isAddKeyOpen, setIsAddKeyOpen] = useState(false);
29
- const [keyToDelete, setKeyToDelete] = useState<string | null>(null);
30
- const [testResult, setTestResult] = useState<{ status: "idle" | "testing" | "success" | "error"; message?: string; latency?: number }>({ status: "idle" });
31
- const [supabaseStatus, setSupabaseStatus] = useState<"testing" | "connected" | "error" | "unconfigured">("testing");
32
-
33
- useEffect(() => {
34
- async function checkSupabase() {
35
- if (!isSupabaseConfigured() || !supabase) {
36
- setSupabaseStatus("unconfigured");
37
- return;
38
- }
39
- try {
40
- // Simple ping to check if credentials are valid
41
- // Using auth.getSession() as a lightweight check
42
- const { error } = await supabase.auth.getSession();
43
- if (error) throw error;
44
- setSupabaseStatus("connected");
45
- } catch (err) {
46
- console.error("Supabase connection error:", err);
47
- setSupabaseStatus("error");
48
- }
49
- }
50
-
51
- if (activeTab === "database") {
52
- checkSupabase();
53
- }
54
- }, [activeTab]);
55
-
56
- const testActiveConnection = async () => {
57
- setTestResult({ status: "testing" });
58
- const startTime = Date.now();
59
-
60
- try {
61
- const res = await apiClient.get("/campaigns");
62
- const latency = Date.now() - startTime;
63
- setTestResult({
64
- status: "success",
65
- message: `Connected successfully. Response latency: ${latency}ms. Found ${res.data?.length || 0} campaigns.`,
66
- latency,
67
- });
68
- toast.success("API Key verified successfully");
69
- } catch (err: any) {
70
- setTestResult({
71
- status: "error",
72
- message: err.response?.data?.error || "Failed to reach BRIDGEModulE API gateway",
73
- });
74
- toast.error("API connection test failed");
75
- }
76
- };
77
-
78
- return (
79
- <div className="p-6 max-w-7xl mx-auto space-y-6">
80
- {/* Header */}
81
- <div>
82
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
83
- <KeyRound className="w-6 h-6 text-indigo-400" />
84
- Settings & API Credentials
85
- </h1>
86
- <p className="text-zinc-400 text-sm mt-1">
87
- Manage BRIDGEModulE API keys, system preferences, and test live gateway endpoints.
88
- </p>
89
- </div>
90
-
91
- {/* Tabs */}
92
- <div className="border-b border-zinc-800 flex items-center gap-2 text-sm font-medium">
93
- <button
94
- onClick={() => setActiveTab("keys")}
95
- className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors ${
96
- activeTab === "keys"
97
- ? "border-indigo-500 text-indigo-400 bg-indigo-500/5"
98
- : "border-transparent text-zinc-400 hover:text-zinc-200"
99
- }`}
100
- >
101
- <KeyRound className="w-4 h-4" /> API Key Manager ({apiKeys.length})
102
- </button>
103
- <button
104
- onClick={() => setActiveTab("preferences")}
105
- className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors ${
106
- activeTab === "preferences"
107
- ? "border-indigo-500 text-indigo-400 bg-indigo-500/5"
108
- : "border-transparent text-zinc-400 hover:text-zinc-200"
109
- }`}
110
- >
111
- <Sliders className="w-4 h-4" /> System Preferences
112
- </button>
113
- <button
114
- onClick={() => setActiveTab("sandbox")}
115
- className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors ${
116
- activeTab === "sandbox"
117
- ? "border-indigo-500 text-indigo-400 bg-indigo-500/5"
118
- : "border-transparent text-zinc-400 hover:text-zinc-200"
119
- }`}
120
- >
121
- <Terminal className="w-4 h-4" /> API Sandbox & Architecture
122
- </button>
123
- <button
124
- onClick={() => setActiveTab("database")}
125
- className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors ${
126
- activeTab === "database"
127
- ? "border-emerald-500 text-emerald-400 bg-emerald-500/5"
128
- : "border-transparent text-zinc-400 hover:text-zinc-200"
129
- }`}
130
- >
131
- <Database className="w-4 h-4" /> Supabase Connection
132
- </button>
133
- </div>
134
-
135
- {/* TAB 1: API KEYS */}
136
- {activeTab === "keys" && (
137
- <div className="space-y-6 max-w-4xl">
138
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 shadow-sm space-y-4">
139
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
140
- <div>
141
- <h3 className="text-base font-semibold text-zinc-100">Configured BRIDGEModulE API Keys</h3>
142
- <p className="text-xs text-zinc-400 mt-0.5">
143
- Store multiple keys for distinct environments or client workspaces.
144
- </p>
145
- </div>
146
-
147
- <div className="flex items-center gap-2">
148
- <button
149
- onClick={testActiveConnection}
150
- disabled={testResult.status === "testing"}
151
- className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 border border-zinc-700"
152
- >
153
- <Activity className="w-3.5 h-3.5 text-emerald-400" />
154
- {testResult.status === "testing" ? "Testing..." : "Test Connection"}
155
- </button>
156
- <button
157
- onClick={() => setIsAddKeyOpen(true)}
158
- className="px-3.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5"
159
- >
160
- <Plus className="w-3.5 h-3.5" /> Add API Key
161
- </button>
162
- </div>
163
- </div>
164
-
165
- {/* Test Connection Banner */}
166
- {testResult.status === "success" && (
167
- <div className="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-lg flex items-center gap-2.5 text-xs text-emerald-400">
168
- <CheckCircle2 className="w-4 h-4 shrink-0" />
169
- <span>{testResult.message}</span>
170
- </div>
171
- )}
172
- {testResult.status === "error" && (
173
- <div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-lg flex items-center gap-2.5 text-xs text-rose-400">
174
- <AlertCircle className="w-4 h-4 shrink-0" />
175
- <span>{testResult.message}</span>
176
- </div>
177
- )}
178
-
179
- {/* Keys List */}
180
- <div className="space-y-3 pt-2">
181
- {apiKeys.map((k) => {
182
- const isActive = k.id === activeKeyId;
183
- const maskedKey = k.key.length > 8
184
- ? `${k.key.substring(0, 4)}••••••••••••••••${k.key.substring(k.key.length - 4)}`
185
- : "••••••••••••";
186
-
187
- return (
188
- <div
189
- key={k.id}
190
- className={`p-4 rounded-xl border transition-all flex flex-col sm:flex-row sm:items-center justify-between gap-4 ${
191
- isActive
192
- ? "bg-indigo-950/20 border-indigo-500/40 shadow-sm"
193
- : "bg-zinc-950/60 border-zinc-800/80 hover:border-zinc-700"
194
- }`}
195
- >
196
- <div>
197
- <div className="flex items-center gap-2">
198
- <span className="font-semibold text-xs text-zinc-100">{k.name}</span>
199
- {isActive && (
200
- <span className="text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded-full">
201
- Active Key
202
- </span>
203
- )}
204
- </div>
205
- <div className="font-mono text-xs text-zinc-400 mt-1 flex items-center gap-2">
206
- <span>{maskedKey}</span>
207
- <button
208
- onClick={() => {
209
- navigator.clipboard.writeText(k.key);
210
- toast.success("API key copied to clipboard");
211
- }}
212
- className="text-zinc-500 hover:text-zinc-300 p-1"
213
- >
214
- <Copy className="w-3.5 h-3.5" />
215
- </button>
216
- </div>
217
- </div>
218
-
219
- <div className="flex items-center gap-2">
220
- {!isActive && (
221
- <button
222
- onClick={() => {
223
- setActiveKeyId(k.id);
224
- toast.success(`Switched active key to "${k.name}"`);
225
- }}
226
- className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-medium rounded-md transition-colors"
227
- >
228
- Set as Active
229
- </button>
230
- )}
231
- <button
232
- onClick={() => setKeyToDelete(k.id)}
233
- className="p-1.5 text-zinc-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-md transition-colors"
234
- >
235
- <Trash2 className="w-4 h-4" />
236
- </button>
237
- </div>
238
- </div>
239
- );
240
- })}
241
-
242
- {apiKeys.length === 0 && (
243
- <div className="p-8 text-center text-xs text-zinc-500 bg-zinc-950/40 rounded-xl border border-dashed border-zinc-800">
244
- No custom API keys saved. The app is currently operating in high-fidelity mock database mode.
245
- </div>
246
- )}
247
- </div>
248
- </div>
249
- </div>
250
- )}
251
-
252
- {/* TAB 2: PREFERENCES */}
253
- {activeTab === "preferences" && (
254
- <div className="space-y-6 max-w-2xl bg-zinc-900 border border-zinc-800 rounded-xl p-6">
255
- <h3 className="text-base font-semibold text-zinc-100 mb-4">Workspace Outreach Defaults</h3>
256
- <div className="space-y-4 text-xs">
257
- <div>
258
- <label className="block font-medium text-zinc-300 mb-1">Default Sending Timezone</label>
259
- <select
260
- defaultValue="America/New_York"
261
- className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-zinc-100"
262
- >
263
- <option value="America/New_York">America/New_York (EST)</option>
264
- <option value="America/Chicago">America/Chicago (CST)</option>
265
- <option value="America/Los_Angeles">America/Los_Angeles (PST)</option>
266
- <option value="Europe/London">Europe/London (GMT)</option>
267
- </select>
268
- </div>
269
-
270
- <div>
271
- <label className="block font-medium text-zinc-300 mb-1">Global Mailbox Rate Limiting</label>
272
- <input
273
- type="number"
274
- defaultValue="50"
275
- className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-zinc-100"
276
- />
277
- <span className="text-[11px] text-zinc-500 mt-1 block">Maximum outbound emails per mailbox / 24 hours</span>
278
- </div>
279
-
280
- <div className="pt-4 flex justify-end">
281
- <button
282
- type="button"
283
- onClick={() => toast.success("Preferences updated")}
284
- className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium"
285
- >
286
- Save Preferences
287
- </button>
288
- </div>
289
- </div>
290
- </div>
291
- )}
292
-
293
- {/* TAB 3: SANDBOX */}
294
- {activeTab === "sandbox" && (
295
- <div className="space-y-4 max-w-4xl bg-zinc-900 border border-zinc-800 rounded-xl p-6">
296
- <h3 className="text-base font-semibold text-zinc-100 flex items-center gap-2">
297
- <Terminal className="w-5 h-5 text-indigo-400" /> Backend Gateway Architecture
298
- </h3>
299
- <p className="text-xs text-zinc-400 leading-relaxed">
300
- All requests from the frontend client route securely through the Node.js/Express proxy (`/api/*`).
301
- The proxy attaches the active API key as a query parameter (`?api_key=...`) to the core REST API endpoint (`https://server.smartlead.ai/api/v1`).
302
- </p>
303
-
304
- <div className="p-4 bg-zinc-950 border border-zinc-800 rounded-lg font-mono text-xs text-zinc-300 space-y-2">
305
- <span className="text-emerald-400"># Example Proxy Request</span><br />
306
- GET /api/campaigns → https://server.smartlead.ai/api/v1/campaigns?api_key=&#123;KEY&#125;<br />
307
- POST /api/leads/search → https://server.smartlead.ai/api/v1/leads/?api_key=&#123;KEY&#125;
308
- </div>
309
- </div>
310
- )}
311
-
312
- {/* TAB 4: DATABASE / SUPABASE */}
313
- {activeTab === "database" && (
314
- <div className="space-y-6 max-w-4xl">
315
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 shadow-sm space-y-6">
316
- <div>
317
- <h3 className="text-base font-semibold text-zinc-100 flex items-center gap-2">
318
- <Database className="w-5 h-5 text-emerald-400" /> Supabase Integration Status
319
- </h3>
320
- <p className="text-xs text-zinc-400 mt-1">
321
- Check the connection to your Supabase project instance ({supabaseProjectRef()}).
322
- To configure, provide your URL and Anon Key in your environment variables.
323
- </p>
324
- </div>
325
-
326
- <div className={`p-4 rounded-xl border flex items-start gap-4 transition-colors ${
327
- supabaseStatus === "connected" ? "bg-emerald-500/10 border-emerald-500/30" :
328
- supabaseStatus === "unconfigured" ? "bg-amber-500/10 border-amber-500/30" :
329
- supabaseStatus === "error" ? "bg-rose-500/10 border-rose-500/30" :
330
- "bg-zinc-950/60 border-zinc-800"
331
- }`}>
332
- {supabaseStatus === "connected" && <CheckCircle2 className="w-6 h-6 text-emerald-400 shrink-0" />}
333
- {supabaseStatus === "unconfigured" && <AlertCircle className="w-6 h-6 text-amber-400 shrink-0" />}
334
- {supabaseStatus === "error" && <AlertCircle className="w-6 h-6 text-rose-400 shrink-0" />}
335
- {supabaseStatus === "testing" && <Activity className="w-6 h-6 text-zinc-400 shrink-0 animate-pulse" />}
336
-
337
- <div className="space-y-1">
338
- <h4 className={`text-sm font-semibold ${
339
- supabaseStatus === "connected" ? "text-emerald-400" :
340
- supabaseStatus === "unconfigured" ? "text-amber-400" :
341
- supabaseStatus === "error" ? "text-rose-400" :
342
- "text-zinc-300"
343
- }`}>
344
- {supabaseStatus === "connected" ? "Connection Established" :
345
- supabaseStatus === "unconfigured" ? "Missing Credentials" :
346
- supabaseStatus === "error" ? "Connection Failed" :
347
- "Testing Connection..."}
348
- </h4>
349
- <p className="text-xs text-zinc-400 leading-relaxed">
350
- {supabaseStatus === "connected" ? `Successfully communicating with Supabase project ${supabaseProjectRef()}.` :
351
- supabaseStatus === "unconfigured" ? "Cannot connect to Supabase. Please ensure VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are set in your environment variables (.env file)." :
352
- supabaseStatus === "error" ? "Failed to establish a connection with the provided credentials. Please verify your Project URL and Anon Key." :
353
- "Pinging Supabase endpoints..."}
354
- </p>
355
- </div>
356
- </div>
357
-
358
- <div className="space-y-3 pt-2">
359
- <h4 className="text-sm font-semibold text-zinc-200">Configuration Guide</h4>
360
- <ol className="list-decimal list-inside space-y-2 text-xs text-zinc-400">
361
- <li>Go to your Supabase Dashboard: <a href={supabaseDashboardUrl()} target="_blank" rel="noreferrer" className="text-emerald-400 hover:underline">Project {supabaseProjectRef()}</a></li>
362
- <li>Navigate to Project Settings {"->"} API.</li>
363
- <li>Copy the <strong>Project URL</strong> and add it to <code className="bg-zinc-950 px-1 py-0.5 rounded text-zinc-300">VITE_SUPABASE_URL</code>.</li>
364
- <li>Copy the <strong>anon public</strong> key and add it to <code className="bg-zinc-950 px-1 py-0.5 rounded text-zinc-300">VITE_SUPABASE_ANON_KEY</code>.</li>
365
- </ol>
366
- </div>
367
- </div>
368
- </div>
369
- )}
370
-
371
- {/* Add Key Drawer */}
372
- <FormDrawer
373
- isOpen={isAddKeyOpen}
374
- onClose={() => setIsAddKeyOpen(false)}
375
- title="Add BRIDGEModulE API Key"
376
- subtitle="Retrieve your API key from Settings -> Profile -> API Keys"
377
- >
378
- <form
379
- onSubmit={(e) => {
380
- e.preventDefault();
381
- const form = e.target as HTMLFormElement;
382
- const name = (form.elements.namedItem("name") as HTMLInputElement).value;
383
- const key = (form.elements.namedItem("key") as HTMLInputElement).value;
384
-
385
- addApiKey(name, key);
386
- toast.success("API Key saved & activated");
387
- setIsAddKeyOpen(false);
388
- }}
389
- className="space-y-4 text-xs"
390
- >
391
- <div>
392
- <label className="block font-medium text-zinc-300 mb-1">Key Friendly Name *</label>
393
- <input name="name" required placeholder="e.g. Production Main Key" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
394
- </div>
395
- <div>
396
- <label className="block font-medium text-zinc-300 mb-1">API Key Token *</label>
397
- <input name="key" type="password" required placeholder="sl_live_..." className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 font-mono" />
398
- </div>
399
-
400
- <div className="pt-4 flex justify-end gap-2 border-t border-zinc-800">
401
- <button
402
- type="button"
403
- onClick={() => setIsAddKeyOpen(false)}
404
- className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
405
- >
406
- Cancel
407
- </button>
408
- <button
409
- type="submit"
410
- className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium shadow"
411
- >
412
- Save Key
413
- </button>
414
- </div>
415
- </form>
416
- </FormDrawer>
417
-
418
- {/* Delete Key Modal */}
419
- <ConfirmModal
420
- isOpen={Boolean(keyToDelete)}
421
- onClose={() => setKeyToDelete(null)}
422
- onConfirm={() => {
423
- if (keyToDelete) {
424
- removeApiKey(keyToDelete);
425
- toast.success("API key deleted");
426
- setKeyToDelete(null);
427
- }
428
- }}
429
- title="Delete API Key"
430
- description="Are you sure you want to remove this stored API key?"
431
- />
432
- </div>
433
- );
434
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Warmup.tsx DELETED
@@ -1,266 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- Flame,
5
- ShieldCheck,
6
- TrendingUp,
7
- Sliders,
8
- CheckCircle2,
9
- AlertTriangle,
10
- Zap,
11
- Mail,
12
- Play,
13
- Pause,
14
- ArrowUpRight
15
- } from "lucide-react";
16
- import { useState } from "react";
17
- import StatusBadge from "../components/StatusBadge";
18
- import FormDrawer from "../components/FormDrawer";
19
- import toast from "react-hot-toast";
20
-
21
- export default function Warmup() {
22
- const queryClient = useQueryClient();
23
- const [selectedWarmupAccount, setSelectedWarmupAccount] = useState<any | null>(null);
24
- const [isSettingsOpen, setIsSettingsOpen] = useState(false);
25
-
26
- const { data: accounts = [], isLoading, refetch } = useQuery({
27
- queryKey: ["email-accounts"],
28
- queryFn: async () => {
29
- const res = await apiClient.get("/email-accounts");
30
- return res.data;
31
- },
32
- });
33
-
34
- const toggleWarmupMutation = useMutation({
35
- mutationFn: async ({ id, status }: { id: string; status: "ACTIVE" | "PAUSED" }) => {
36
- const action = status === "ACTIVE" ? "enable" : "disable";
37
- const res = await apiClient.post(`/email-accounts/${id}/warmup/${action}`);
38
- return res.data;
39
- },
40
- onSuccess: (_, variables) => {
41
- toast.success(`Warmup ${variables.status === "ACTIVE" ? "activated" : "paused"}`);
42
- refetch();
43
- },
44
- onError: () => {
45
- toast.error("Failed to update warmup status");
46
- }
47
- });
48
-
49
- const activeWarmups = accounts.filter((a: any) => a.warmup_enabled).length;
50
- const dailyWarmupVolume = accounts.reduce((total: number, account: any) => total + (account.warmup_daily_count || 0), 0);
51
- const replyRates = accounts.map((account: any) => account.reply_rate_percentage).filter((rate: any) => typeof rate === "number");
52
- const averageReplyRate = replyRates.length ? (replyRates.reduce((total: number, rate: number) => total + rate, 0) / replyRates.length).toFixed(1) : "—";
53
- const scores = accounts.map((account: any) => account.warmup_score).filter((score: any) => typeof score === "number");
54
- const averageScore = scores.length ? (scores.reduce((total: number, score: number) => total + score, 0) / scores.length).toFixed(1) : "—";
55
-
56
- return (
57
- <div className="p-6 max-w-7xl mx-auto space-y-6">
58
- {/* Header */}
59
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
60
- <div>
61
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
62
- <Flame className="w-6 h-6 text-amber-400" />
63
- Autonomous Warmup Engine
64
- </h1>
65
- <p className="text-zinc-400 text-sm mt-1">
66
- Gradually ramps mailbox sending volume, interacts with peer networks, and rescues emails from spam folders.
67
- </p>
68
- </div>
69
-
70
- <div className="flex items-center gap-3">
71
- <button
72
- onClick={() => {
73
- accounts.forEach((a: any) => {
74
- toggleWarmupMutation.mutate({ id: a.id, status: "ACTIVE" });
75
- });
76
- toast.success("Warmup activated on all connected mailboxes");
77
- }}
78
- className="px-3.5 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5"
79
- >
80
- <Play className="w-3.5 h-3.5 text-emerald-400" /> Bulk Enable All
81
- </button>
82
- <button
83
- onClick={() => setIsSettingsOpen(true)}
84
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-1.5"
85
- >
86
- <Sliders className="w-4 h-4" /> Global Warmup Rules
87
- </button>
88
- </div>
89
- </div>
90
-
91
- {/* KPI Overview */}
92
- <div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
93
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
94
- <span className="text-xs text-zinc-400">Warmup Coverage</span>
95
- <div className="text-2xl font-bold text-zinc-100 mt-1">
96
- {activeWarmups} / {accounts.length}
97
- </div>
98
- <span className="text-[11px] text-emerald-400 font-medium">Inboxes in active pool</span>
99
- </div>
100
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
101
- <span className="text-xs text-zinc-400">Daily Warmup Volume</span>
102
- <div className="text-2xl font-bold text-amber-400 mt-1">{dailyWarmupVolume} / day</div>
103
- <span className="text-[11px] text-zinc-500">Auto-incrementing daily</span>
104
- </div>
105
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
106
- <span className="text-xs text-zinc-400">Warmup Reply Rate</span>
107
- <div className="text-2xl font-bold text-indigo-400 mt-1">{averageReplyRate}%</div>
108
- <span className="text-[11px] text-zinc-500">Autonomous peer response</span>
109
- </div>
110
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
111
- <span className="text-xs text-zinc-400">Inbox Health Score</span>
112
- <div className="text-2xl font-bold text-emerald-400 mt-1">{averageScore} / 100</div>
113
- <span className="text-[11px] text-emerald-400 font-medium">Excellent deliverability</span>
114
- </div>
115
- </div>
116
-
117
- {/* Warmup Mailboxes Table */}
118
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-sm">
119
- <div className="overflow-x-auto">
120
- <table className="w-full text-sm text-left">
121
- <thead className="bg-zinc-950 border-b border-zinc-800 text-zinc-400 text-xs uppercase">
122
- <tr>
123
- <th className="px-6 py-3.5 font-semibold">Mailbox</th>
124
- <th className="px-6 py-3.5 font-semibold">Warmup Status</th>
125
- <th className="px-6 py-3.5 font-semibold">Ramp Day</th>
126
- <th className="px-6 py-3.5 font-semibold">Reputation Score</th>
127
- <th className="px-6 py-3.5 font-semibold">Warmup Volume</th>
128
- <th className="px-6 py-3.5 font-semibold text-right">Warmup Toggle</th>
129
- </tr>
130
- </thead>
131
- <tbody className="divide-y divide-zinc-800/80">
132
- {accounts.map((acc: any) => {
133
- const isWarmupActive = Boolean(acc.warmup_enabled);
134
- const rampDay = acc.ramp_day || "—";
135
- const score = acc.warmup_score || 0;
136
-
137
- return (
138
- <tr key={acc.id} className="hover:bg-zinc-800/30 transition-colors">
139
- <td className="px-6 py-4">
140
- <div className="font-semibold text-zinc-100 flex items-center gap-2">
141
- {acc.from_name}
142
- </div>
143
- <div className="text-xs text-zinc-400 font-mono mt-0.5">{acc.from_email}</div>
144
- </td>
145
- <td className="px-6 py-4">
146
- <StatusBadge status={isWarmupActive ? "ACTIVE" : "PAUSED"} size="sm" />
147
- </td>
148
- <td className="px-6 py-4 text-zinc-300 font-mono text-xs">
149
- Day {rampDay}
150
- </td>
151
- <td className="px-6 py-4">
152
- <div className="flex items-center gap-2">
153
- <span className="text-xs font-bold text-emerald-400">{score}%</span>
154
- <div className="w-20 h-1.5 bg-zinc-950 rounded-full overflow-hidden border border-zinc-800">
155
- <div className="h-full bg-emerald-400 rounded-full" style={{ width: `${score}%` }} />
156
- </div>
157
- </div>
158
- </td>
159
- <td className="px-6 py-4 text-xs font-mono text-zinc-300">
160
- <span className="text-amber-400 font-semibold">{acc.warmup_daily_count || 0}</span> warm emails sent today
161
- </td>
162
- <td className="px-6 py-4 text-right">
163
- <button
164
- onClick={() =>
165
- toggleWarmupMutation.mutate({
166
- id: acc.id,
167
- status: isWarmupActive ? "PAUSED" : "ACTIVE",
168
- })
169
- }
170
- className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
171
- isWarmupActive
172
- ? "bg-rose-500/10 text-rose-400 hover:bg-rose-500/20 border border-rose-500/30"
173
- : "bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 border border-emerald-500/30"
174
- }`}
175
- >
176
- {isWarmupActive ? "Pause Warmup" : "Start Warmup"}
177
- </button>
178
- </td>
179
- </tr>
180
- );
181
- })}
182
- </tbody>
183
- </table>
184
- </div>
185
- </div>
186
-
187
- {/* Global Warmup Rules Drawer */}
188
- <FormDrawer
189
- isOpen={isSettingsOpen}
190
- onClose={() => setIsSettingsOpen(false)}
191
- title="Autonomous Warmup Settings"
192
- subtitle="Fine-tune daily ramping schedules and simulated reply rates."
193
- >
194
- <div className="space-y-4 text-xs">
195
- <div>
196
- <label className="block font-medium text-zinc-300 mb-1">Target Daily Warmup Max</label>
197
- <input
198
- type="number"
199
- defaultValue="40"
200
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100"
201
- />
202
- <span className="text-[11px] text-zinc-500 mt-1 block">Maximum simulated emails per inbox daily</span>
203
- </div>
204
-
205
- <div>
206
- <label className="block font-medium text-zinc-300 mb-1">Daily Ramp Increment</label>
207
- <input
208
- type="number"
209
- defaultValue="4"
210
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100"
211
- />
212
- <span className="text-[11px] text-zinc-500 mt-1 block">Additional emails added each consecutive day</span>
213
- </div>
214
-
215
- <div>
216
- <label className="block font-medium text-zinc-300 mb-1">Simulated Reply Rate</label>
217
- <input
218
- type="range"
219
- min="10"
220
- max="60"
221
- defaultValue="35"
222
- className="w-full accent-indigo-500"
223
- />
224
- <div className="flex justify-between text-[11px] text-zinc-400">
225
- <span>Conservative (15%)</span>
226
- <span className="text-indigo-400 font-semibold">Target: 35%</span>
227
- <span>Aggressive (60%)</span>
228
- </div>
229
- </div>
230
-
231
- <div className="flex items-center justify-between p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
232
- <div>
233
- <span className="font-medium text-zinc-200 block text-xs">Spam Rescue Protocol</span>
234
- <span className="text-[11px] text-zinc-500">Automatically moves warm emails out of spam folders</span>
235
- </div>
236
- <input
237
- type="checkbox"
238
- defaultChecked
239
- className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 bg-zinc-950 border-zinc-700"
240
- />
241
- </div>
242
-
243
- <div className="pt-4 flex justify-end gap-2 border-t border-zinc-800">
244
- <button
245
- type="button"
246
- onClick={() => setIsSettingsOpen(false)}
247
- className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
248
- >
249
- Cancel
250
- </button>
251
- <button
252
- type="button"
253
- onClick={() => {
254
- toast.success("Warmup parameters saved");
255
- setIsSettingsOpen(false);
256
- }}
257
- className="px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg"
258
- >
259
- Save Configuration
260
- </button>
261
- </div>
262
- </div>
263
- </FormDrawer>
264
- </div>
265
- );
266
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//Webhooks.tsx DELETED
@@ -1,316 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { apiClient } from "../lib/apiClient";
3
- import {
4
- Webhook,
5
- Plus,
6
- Play,
7
- Trash2,
8
- CheckCircle2,
9
- AlertCircle,
10
- Code,
11
- ExternalLink,
12
- Copy,
13
- Terminal
14
- } from "lucide-react";
15
- import { useState } from "react";
16
- import StatusBadge from "../components/StatusBadge";
17
- import FormDrawer from "../components/FormDrawer";
18
- import ConfirmModal from "../components/ConfirmModal";
19
- import toast from "react-hot-toast";
20
-
21
- const AVAILABLE_EVENTS = [
22
- { id: "EMAIL_REPLIED", label: "Email Replied" },
23
- { id: "EMAIL_OPENED", label: "Email Opened" },
24
- { id: "EMAIL_CLICKED", label: "Link Clicked" },
25
- { id: "EMAIL_BOUNCED", label: "Email Bounced" },
26
- { id: "LEAD_CATEGORY_UPDATED", label: "Lead Category Updated" },
27
- { id: "EMAIL_UNSUBSCRIBED", label: "Lead Unsubscribed" },
28
- { id: "CAMPAIGN_COMPLETED", label: "Campaign Completed" },
29
- ];
30
-
31
- export default function Webhooks() {
32
- const queryClient = useQueryClient();
33
- const [isCreateOpen, setIsCreateOpen] = useState(false);
34
- const [selectedForDelete, setSelectedForDelete] = useState<string | null>(null);
35
- const [testExecutionResult, setTestExecutionResult] = useState<any | null>(null);
36
-
37
- const { data: webhooks = [], isLoading, refetch } = useQuery({
38
- queryKey: ["webhooks"],
39
- queryFn: async () => {
40
- const res = await apiClient.get("/webhooks");
41
- return res.data;
42
- },
43
- });
44
-
45
- const { data: campaigns = [] } = useQuery({
46
- queryKey: ["campaigns"],
47
- queryFn: async () => {
48
- const res = await apiClient.get("/campaigns");
49
- return res.data;
50
- },
51
- });
52
-
53
- const testFireMutation = useMutation({
54
- mutationFn: async (id: string) => {
55
- const res = await apiClient.post(`/webhooks/${id}/test`);
56
- return res.data;
57
- },
58
- onSuccess: (data) => {
59
- toast.success("Webhook test payload dispatched");
60
- setTestExecutionResult(data);
61
- },
62
- onError: () => {
63
- toast.error("Failed to fire test webhook");
64
- },
65
- });
66
-
67
- const deleteMutation = useMutation({
68
- mutationFn: async (id: string) => {
69
- const res = await apiClient.delete(`/webhooks/${id}`);
70
- return res.data;
71
- },
72
- onSuccess: () => {
73
- toast.success("Webhook endpoint removed");
74
- setSelectedForDelete(null);
75
- refetch();
76
- },
77
- });
78
-
79
- return (
80
- <div className="p-6 max-w-7xl mx-auto space-y-6">
81
- {/* Header */}
82
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
83
- <div>
84
- <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
85
- <Webhook className="w-6 h-6 text-purple-400" />
86
- Event Webhooks & Integrations
87
- </h1>
88
- <p className="text-zinc-400 text-sm mt-1">
89
- Stream real-time outreach events directly to your CRM, Slack, Zapier, or backend server.
90
- </p>
91
- </div>
92
-
93
- <button
94
- onClick={() => setIsCreateOpen(true)}
95
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-2"
96
- >
97
- <Plus className="w-4 h-4" /> Add Webhook
98
- </button>
99
- </div>
100
-
101
- {/* Webhooks Table */}
102
- <div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-sm">
103
- <div className="overflow-x-auto">
104
- <table className="w-full text-sm text-left">
105
- <thead className="bg-zinc-950 border-b border-zinc-800 text-zinc-400 text-xs uppercase">
106
- <tr>
107
- <th className="px-6 py-3.5 font-semibold">Endpoint URL</th>
108
- <th className="px-6 py-3.5 font-semibold">Campaign Scope</th>
109
- <th className="px-6 py-3.5 font-semibold">Subscribed Events</th>
110
- <th className="px-6 py-3.5 font-semibold">Status</th>
111
- <th className="px-6 py-3.5 font-semibold text-right">Actions</th>
112
- </tr>
113
- </thead>
114
- <tbody className="divide-y divide-zinc-800/80">
115
- {webhooks.map((hook: any) => (
116
- <tr key={hook.id} className="hover:bg-zinc-800/30 transition-colors">
117
- <td className="px-6 py-4">
118
- <div className="flex items-center gap-2">
119
- <span className="font-mono text-xs text-zinc-200 font-medium">
120
- {hook.webhook_url}
121
- </span>
122
- <button
123
- onClick={() => {
124
- navigator.clipboard.writeText(hook.webhook_url);
125
- toast.success("Copied webhook URL");
126
- }}
127
- className="text-zinc-500 hover:text-zinc-300 p-1"
128
- >
129
- <Copy className="w-3.5 h-3.5" />
130
- </button>
131
- </div>
132
- </td>
133
- <td className="px-6 py-4 text-xs text-zinc-300">
134
- {hook.campaign_id ? `Campaign: ${hook.campaign_id}` : "Global (All Campaigns)"}
135
- </td>
136
- <td className="px-6 py-4">
137
- <div className="flex flex-wrap gap-1 max-w-xs">
138
- {hook.event_types?.map((ev: string) => (
139
- <span
140
- key={ev}
141
- className="px-2 py-0.5 bg-zinc-950 text-indigo-300 border border-indigo-500/20 rounded text-[10px] font-mono"
142
- >
143
- {ev}
144
- </span>
145
- ))}
146
- </div>
147
- </td>
148
- <td className="px-6 py-4">
149
- <StatusBadge status={hook.status || "ACTIVE"} size="sm" />
150
- </td>
151
- <td className="px-6 py-4 text-right">
152
- <div className="flex items-center justify-end gap-2">
153
- <button
154
- onClick={() => testFireMutation.mutate(hook.id)}
155
- disabled={testFireMutation.isPending}
156
- className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium rounded-md transition-colors flex items-center gap-1"
157
- >
158
- <Play className="w-3 h-3 text-emerald-400" /> Test Fire
159
- </button>
160
- <button
161
- onClick={() => setSelectedForDelete(hook.id)}
162
- className="p-1.5 text-zinc-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-md transition-colors"
163
- >
164
- <Trash2 className="w-3.5 h-3.5" />
165
- </button>
166
- </div>
167
- </td>
168
- </tr>
169
- ))}
170
-
171
- {webhooks.length === 0 && (
172
- <tr>
173
- <td colSpan={5} className="py-16 text-center text-xs text-zinc-500">
174
- No webhooks configured yet. Create one to receive real-time JSON payloads.
175
- </td>
176
- </tr>
177
- )}
178
- </tbody>
179
- </table>
180
- </div>
181
- </div>
182
-
183
- {/* Test Fire Response Inspector */}
184
- {testExecutionResult && (
185
- <div className="bg-zinc-950 border border-zinc-800 rounded-xl p-5 shadow-lg space-y-3">
186
- <div className="flex items-center justify-between">
187
- <h3 className="text-xs font-semibold text-emerald-400 flex items-center gap-2 uppercase tracking-wider">
188
- <Terminal className="w-4 h-4" /> Live Webhook Delivery Simulation (200 OK)
189
- </h3>
190
- <button
191
- onClick={() => setTestExecutionResult(null)}
192
- className="text-xs text-zinc-500 hover:text-zinc-300"
193
- >
194
- Close Inspector
195
- </button>
196
- </div>
197
- <pre className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg text-xs font-mono text-zinc-300 overflow-x-auto">
198
- {JSON.stringify(testExecutionResult, null, 2)}
199
- </pre>
200
- </div>
201
- )}
202
-
203
- {/* Delete Confirmation */}
204
- <ConfirmModal
205
- isOpen={Boolean(selectedForDelete)}
206
- onClose={() => setSelectedForDelete(null)}
207
- onConfirm={() => selectedForDelete && deleteMutation.mutate(selectedForDelete)}
208
- title="Delete Webhook"
209
- description="Are you sure you want to delete this webhook endpoint? No further notifications will be dispatched to this URL."
210
- />
211
-
212
- {/* Create Webhook Drawer */}
213
- <FormDrawer
214
- isOpen={isCreateOpen}
215
- onClose={() => setIsCreateOpen(false)}
216
- title="Create Webhook Subscription"
217
- subtitle="Specify the destination HTTPS endpoint and target event triggers."
218
- >
219
- <form
220
- onSubmit={async (e) => {
221
- e.preventDefault();
222
- const form = e.target as HTMLFormElement;
223
- const target_url = (form.elements.namedItem("target_url") as HTMLInputElement).value;
224
- const campaign_id = (form.elements.namedItem("campaign_id") as HTMLSelectElement).value;
225
- const checkedEvents = AVAILABLE_EVENTS.filter(
226
- (ev) => (form.elements.namedItem(ev.id) as HTMLInputElement)?.checked
227
- ).map((ev) => ev.id);
228
-
229
- if (checkedEvents.length === 0) {
230
- toast.error("Please select at least one event trigger");
231
- return;
232
- }
233
-
234
- try {
235
- await apiClient.post("/webhooks", {
236
- webhook_url: target_url,
237
- campaign_id: campaign_id === "ALL" ? null : campaign_id,
238
- event_types: checkedEvents,
239
- });
240
- toast.success("Webhook configured successfully");
241
- setIsCreateOpen(false);
242
- refetch();
243
- } catch (err: any) {
244
- toast.error("Failed to create webhook");
245
- }
246
- }}
247
- className="space-y-4 text-xs"
248
- >
249
- <div>
250
- <label className="block font-medium text-zinc-300 mb-1">Target Endpoint URL *</label>
251
- <input
252
- name="target_url"
253
- type="url"
254
- required
255
- placeholder="https://api.yourdomain.com/webhooks/smartlead"
256
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 font-mono"
257
- />
258
- </div>
259
-
260
- <div>
261
- <label className="block font-medium text-zinc-300 mb-1">Campaign Scope</label>
262
- <select
263
- name="campaign_id"
264
- defaultValue="ALL"
265
- className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100"
266
- >
267
- <option value="ALL">All Campaigns (Global Scope)</option>
268
- {campaigns.map((c: any) => (
269
- <option key={c.id} value={c.id}>
270
- {c.name}
271
- </option>
272
- ))}
273
- </select>
274
- </div>
275
-
276
- <div className="space-y-2 pt-2 border-t border-zinc-800">
277
- <label className="block font-medium text-zinc-300 mb-2">Subscribe to Events *</label>
278
- <div className="grid grid-cols-1 gap-2">
279
- {AVAILABLE_EVENTS.map((ev) => (
280
- <label
281
- key={ev.id}
282
- className="flex items-center gap-2 p-2.5 bg-zinc-900 border border-zinc-800 rounded-lg cursor-pointer hover:bg-zinc-800/60"
283
- >
284
- <input
285
- type="checkbox"
286
- name={ev.id}
287
- defaultChecked
288
- className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 bg-zinc-950 border-zinc-700"
289
- />
290
- <span className="text-zinc-200 font-medium">{ev.label}</span>
291
- <span className="text-[10px] text-zinc-500 font-mono ml-auto">{ev.id}</span>
292
- </label>
293
- ))}
294
- </div>
295
- </div>
296
-
297
- <div className="pt-4 flex justify-end gap-2 border-t border-zinc-800">
298
- <button
299
- type="button"
300
- onClick={() => setIsCreateOpen(false)}
301
- className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
302
- >
303
- Cancel
304
- </button>
305
- <button
306
- type="submit"
307
- className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium shadow"
308
- >
309
- Register Webhook
310
- </button>
311
- </div>
312
- </form>
313
- </FormDrawer>
314
- </div>
315
- );
316
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//pages//gate.css DELETED
@@ -1,420 +0,0 @@
1
- /*
2
- * BRIDGE Modul E — gate, "Moss & Ember Press" system
3
- * Scoped strictly under .gate; global app tokens untouched.
4
- *
5
- * Token source (user-specified palette):
6
- * moss ramp #394319 #637330 #8CA14A #ABBB77 #C7D0A9 #D6DCC1 #E4E8D9
7
- * forest ink #13301E
8
- * ember #C66306 (vibrant accent)
9
- * Text-contrast notes (verified):
10
- * #13301E on #E4E8D9 ≈ 11.5:1 (AAA) · #394319 on #E4E8D9 ≈ 8.5:1 (AAA)
11
- * #9C4A03 on #E4E8D9 ≈ 5.0:1 (AA small text) · #C66306 reserved for
12
- * fills / focus / large marks · white on #B45A05 ≈ 4.8:1 (AA)
13
- */
14
-
15
- .gate {
16
- --g-paper: #e4e8d9;
17
- --g-mist: #d6dcc1;
18
- --g-sage: #c7d0a9;
19
- --g-moss-l: #abbb77;
20
- --g-moss: #8ca14a;
21
- --g-olive: #637330;
22
- --g-olive-d: #394319;
23
- --g-ink: #13301e;
24
- --g-ember: #c66306;
25
- --g-ember-press: #b45a05;
26
- --g-ember-text: #9c4a03;
27
- --g-card: color-mix(in srgb, #e4e8d9 52%, #ffffff 48%);
28
-
29
- --g-pad: 30px;
30
-
31
- display: grid;
32
- place-items: center;
33
- min-height: 100vh;
34
- min-height: 100dvh;
35
- padding: 24px;
36
- background:
37
- radial-gradient(52rem 30rem at 86% -10%, color-mix(in srgb, var(--g-ember) 13%, transparent), transparent 62%),
38
- radial-gradient(46rem 32rem at -10% 108%, color-mix(in srgb, var(--g-moss) 20%, transparent), transparent 58%),
39
- repeating-linear-gradient(0deg, transparent 0 31px, color-mix(in srgb, var(--g-sage) 40%, transparent) 31px 32px),
40
- repeating-linear-gradient(90deg, transparent 0 31px, color-mix(in srgb, var(--g-sage) 40%, transparent) 31px 32px),
41
- linear-gradient(170deg, var(--g-paper) 0%, var(--g-mist) 100%);
42
- color: var(--g-ink);
43
- font-family: var(--font-sans);
44
- }
45
-
46
- .gate ::selection {
47
- background: var(--g-moss);
48
- color: var(--g-paper);
49
- }
50
-
51
- /* ---------------------------------- screen-reader-only h1 ---------------------------------- */
52
-
53
- .gate-sr {
54
- position: absolute;
55
- width: 1px;
56
- height: 1px;
57
- padding: 0;
58
- margin: -1px;
59
- overflow: hidden;
60
- clip: rect(0 0 0 0);
61
- white-space: nowrap;
62
- border: 0;
63
- }
64
-
65
- /* ---------------------------------- desk trim ---------------------------------- */
66
-
67
- .gate-cropmark {
68
- position: fixed;
69
- width: 18px;
70
- height: 18px;
71
- pointer-events: none;
72
- opacity: 0.55;
73
- }
74
-
75
- .gate-cropmark.tl {
76
- top: 16px;
77
- left: 16px;
78
- border-top: 1.5px solid var(--g-olive);
79
- border-left: 1.5px solid var(--g-olive);
80
- }
81
-
82
- .gate-cropmark.tr {
83
- top: 16px;
84
- right: 16px;
85
- border-top: 1.5px solid var(--g-olive);
86
- border-right: 1.5px solid var(--g-olive);
87
- }
88
-
89
- .gate-cropmark.bl {
90
- bottom: 16px;
91
- left: 16px;
92
- border-bottom: 1.5px solid var(--g-olive);
93
- border-left: 1.5px solid var(--g-olive);
94
- }
95
-
96
- .gate-cropmark.br {
97
- bottom: 16px;
98
- right: 16px;
99
- border-bottom: 1.5px solid var(--g-olive);
100
- border-right: 1.5px solid var(--g-olive);
101
- }
102
-
103
- /* ---------------------------------- the slip ---------------------------------- */
104
-
105
- .gate-formwrap {
106
- position: relative;
107
- width: min(430px, 100%);
108
- }
109
-
110
- .gate-card {
111
- position: relative;
112
- background: var(--g-card);
113
- border: 1px solid color-mix(in srgb, var(--g-sage) 80%, transparent);
114
- border-radius: 10px;
115
- padding: var(--g-pad);
116
- box-shadow:
117
- 0 1px 0 rgba(19, 48, 30, 0.04),
118
- 0 24px 48px -28px rgba(19, 48, 30, 0.35);
119
- }
120
-
121
- /* ---------------------------------- fields ---------------------------------- */
122
-
123
- .gate-field {
124
- margin-bottom: 20px;
125
- }
126
-
127
- .gate-field label {
128
- display: block;
129
- font-family: var(--font-mono);
130
- font-size: 10.5px;
131
- font-weight: 600;
132
- letter-spacing: 0.18em;
133
- text-transform: uppercase;
134
- color: var(--g-olive);
135
- margin-bottom: 4px;
136
- }
137
-
138
- .gate-field input,
139
- .gate-field select {
140
- width: 100%;
141
- background: transparent;
142
- border: 0;
143
- border-bottom: 1.5px solid color-mix(in srgb, var(--g-olive) 55%, transparent);
144
- border-radius: 0;
145
- padding: 8px 1px;
146
- font-family: var(--font-sans);
147
- font-size: 15px;
148
- color: var(--g-ink);
149
- caret-color: var(--g-ink);
150
- }
151
-
152
- .gate-field select {
153
- appearance: none;
154
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' stroke='%2313301e' stroke-width='1.8' fill='none'/%3E%3C/svg%3E");
155
- background-repeat: no-repeat;
156
- background-position: right 2px center;
157
- padding-right: 24px;
158
- cursor: pointer;
159
- }
160
-
161
- .gate-field input.gate-key-input {
162
- font-family: var(--font-mono);
163
- font-size: 13.5px;
164
- }
165
-
166
- .gate-field input::placeholder {
167
- color: color-mix(in srgb, var(--g-olive) 75%, transparent);
168
- }
169
-
170
- .gate-field input:focus,
171
- .gate-field select:focus {
172
- outline: none;
173
- border-bottom-color: var(--g-ember);
174
- box-shadow: 0 1.5px 0 var(--g-ember);
175
- }
176
-
177
- .gate-field input:disabled,
178
- .gate-field select:disabled {
179
- opacity: 0.55;
180
- }
181
-
182
- .gate-key-row {
183
- display: flex;
184
- align-items: center;
185
- gap: 10px;
186
- }
187
-
188
- .gate-key-row input {
189
- flex: 1;
190
- min-width: 0;
191
- }
192
-
193
- .gate-peek {
194
- flex: none;
195
- border: 0;
196
- background: none;
197
- padding: 2px 0;
198
- font-family: var(--font-mono);
199
- font-size: 10.5px;
200
- font-weight: 600;
201
- letter-spacing: 0.14em;
202
- color: var(--g-olive);
203
- cursor: pointer;
204
- border-bottom: 1px solid transparent;
205
- }
206
-
207
- .gate-peek:hover:not(:disabled) {
208
- color: var(--g-ink);
209
- border-bottom-color: color-mix(in srgb, var(--g-olive) 70%, transparent);
210
- }
211
-
212
- /* ---------------------------------- actions ---------------------------------- */
213
-
214
- .gate-cta {
215
- width: 100%;
216
- border: 0;
217
- border-radius: 7px;
218
- padding: 13px 16px;
219
- background: var(--g-ink);
220
- color: var(--g-paper);
221
- font-family: var(--font-sans);
222
- font-size: 14px;
223
- font-weight: 600;
224
- letter-spacing: 0.05em;
225
- cursor: pointer;
226
- transition: background 160ms ease, box-shadow 160ms ease, transform 80ms ease;
227
- }
228
-
229
- .gate-cta:hover:not(:disabled) {
230
- background: var(--g-olive-d);
231
- box-shadow: 0 8px 20px -10px color-mix(in srgb, var(--g-ink) 70%, transparent);
232
- }
233
-
234
- .gate-cta:active:not(:disabled) {
235
- transform: translateY(1px);
236
- }
237
-
238
- .gate-cta:disabled {
239
- cursor: default;
240
- opacity: 0.75;
241
- }
242
-
243
- .gate-cta[data-phase="cleared"] {
244
- background: var(--g-ember-press);
245
- }
246
-
247
- .gate-demo {
248
- width: 100%;
249
- border: 1.5px solid var(--g-ember);
250
- border-radius: 7px;
251
- padding: 12px 16px;
252
- background: transparent;
253
- color: var(--g-ember-text);
254
- font-family: var(--font-sans);
255
- font-size: 13.5px;
256
- font-weight: 600;
257
- cursor: pointer;
258
- transition: background 160ms ease, border-color 160ms ease;
259
- }
260
-
261
- .gate-demo:hover:not(:disabled) {
262
- background: color-mix(in srgb, var(--g-ember) 9%, transparent);
263
- border-color: var(--g-ember-press);
264
- }
265
-
266
- .gate-demo:disabled {
267
- cursor: default;
268
- opacity: 0.55;
269
- }
270
-
271
- .gate-or {
272
- display: flex;
273
- align-items: center;
274
- gap: 12px;
275
- margin: 18px 0;
276
- font-family: var(--font-mono);
277
- font-size: 10px;
278
- letter-spacing: 0.22em;
279
- color: var(--g-olive);
280
- }
281
-
282
- .gate-or span {
283
- flex: 1;
284
- height: 1px;
285
- background: color-mix(in srgb, var(--g-olive) 28%, transparent);
286
- }
287
-
288
- .gate-switch {
289
- display: block;
290
- margin: 16px auto 0;
291
- border: 0;
292
- background: none;
293
- padding: 2px 0;
294
- font-family: var(--font-mono);
295
- font-size: 11px;
296
- font-weight: 500;
297
- letter-spacing: 0.08em;
298
- color: var(--g-olive);
299
- cursor: pointer;
300
- border-bottom: 1px solid transparent;
301
- transition: color 140ms ease;
302
- }
303
-
304
- .gate-switch:hover:not(:disabled) {
305
- color: var(--g-ember-text);
306
- border-bottom-color: color-mix(in srgb, var(--g-ember-text) 60%, transparent);
307
- }
308
-
309
- .gate-switch:disabled {
310
- opacity: 0.55;
311
- cursor: default;
312
- }
313
-
314
- /* ---------------------------------- error ---------------------------------- */
315
-
316
- .gate-error {
317
- margin: 0 0 18px;
318
- padding-left: 10px;
319
- border-left: 2.5px solid var(--g-ember);
320
- font-family: var(--font-mono);
321
- font-size: 11.5px;
322
- line-height: 1.6;
323
- color: var(--g-ember-text);
324
- }
325
-
326
- /* ---------------------------------- clearance stamp ---------------------------------- */
327
-
328
- .gate-stamp {
329
- position: absolute;
330
- top: -30px;
331
- right: -20px;
332
- width: 130px;
333
- height: 130px;
334
- pointer-events: none;
335
- color: var(--g-ember);
336
- transform: rotate(-12deg);
337
- mix-blend-mode: multiply;
338
- z-index: 2;
339
- }
340
-
341
- /* ---------------------------------- focus + motion ---------------------------------- */
342
-
343
- .gate button:focus-visible {
344
- outline: 2px solid var(--g-ember);
345
- outline-offset: 3px;
346
- }
347
-
348
- @media (prefers-reduced-motion: no-preference) {
349
- .gate-card {
350
- animation: gate-rise 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
351
- }
352
-
353
- .gate-cropmark {
354
- animation: gate-fade 900ms ease 200ms both;
355
- }
356
-
357
- .gate-stamp {
358
- animation: gate-stamp-in 560ms cubic-bezier(0.16, 1.3, 0.3, 1) both;
359
- }
360
- }
361
-
362
- @keyframes gate-rise {
363
- from {
364
- opacity: 0;
365
- transform: translateY(14px);
366
- }
367
- to {
368
- opacity: 1;
369
- transform: translateY(0);
370
- }
371
- }
372
-
373
- @keyframes gate-fade {
374
- from {
375
- opacity: 0;
376
- }
377
- to {
378
- opacity: 0.55;
379
- }
380
- }
381
-
382
- @keyframes gate-stamp-in {
383
- 0% {
384
- opacity: 0;
385
- transform: scale(2.1) rotate(-22deg);
386
- }
387
- 55% {
388
- opacity: 1;
389
- transform: scale(0.95) rotate(-10deg);
390
- }
391
- 75% {
392
- transform: scale(1.04) rotate(-13deg);
393
- }
394
- 100% {
395
- transform: scale(1) rotate(-12deg);
396
- }
397
- }
398
-
399
- /* ---------------------------------- responsive ---------------------------------- */
400
-
401
- @media (max-width: 560px) {
402
- .gate {
403
- padding: 20px 16px;
404
- }
405
-
406
- .gate-card {
407
- --g-pad: 22px;
408
- }
409
-
410
- .gate-stamp {
411
- width: 102px;
412
- height: 102px;
413
- top: -22px;
414
- right: -10px;
415
- }
416
-
417
- .gate-cropmark {
418
- display: none;
419
- }
420
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//server//middleware//auth.ts DELETED
@@ -1,144 +0,0 @@
1
- // src/server/middleware/auth.ts
2
- //
3
- // Supabase JWT verification + license gating for the paid API.
4
- //
5
- // The central Supabase project issues the session JWT; this backend verifies
6
- // it against the project's PUBLIC JWKS endpoint (no shared database needed).
7
- // License rows are read from the central project via its service-role REST
8
- // API (server-side only).
9
- //
10
- // Enforcement is OFF by default (LICENSE_ENFORCEMENT != "true") so existing
11
- // open/demo deployments keep working. Turn it on at go-live:
12
- //
13
- // SUPABASE_URL=https://ciytvulujiqemqljablq.supabase.co
14
- // SUPABASE_SERVICE_ROLE_KEY=<service role key, server only>
15
- // LICENSE_ENFORCEMENT=true
16
- //
17
- // Frontend equivalent lives in the FortyDN website repo (src/lib/productApi.ts).
18
- // Ported from BRIDGE Modul-X (src/server/middleware/auth.ts) — same contract.
19
-
20
- import type { Request, Response, NextFunction } from "express";
21
- import { createRemoteJWKSet, jwtVerify } from "jose";
22
-
23
- export const CENTRAL_SUPABASE_URL =
24
- process.env.SUPABASE_URL || "https://ciytvulujiqemqljablq.supabase.co";
25
-
26
- const JWKS_URL = new URL(`${CENTRAL_SUPABASE_URL}/auth/v1/.well-known/jwks.json`);
27
-
28
- let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
29
-
30
- function getJwks() {
31
- if (!jwks) jwks = createRemoteJWKSet(JWKS_URL);
32
- return jwks;
33
- }
34
-
35
- function enforcementOn(): boolean {
36
- return process.env.LICENSE_ENFORCEMENT === "true";
37
- }
38
-
39
- export interface AuthClaims {
40
- userId: string;
41
- email?: string;
42
- }
43
-
44
- function bearerToken(req: Request): string | null {
45
- const header = req.headers.authorization || "";
46
- if (!header.startsWith("Bearer ")) return null;
47
- const token = header.slice(7).trim();
48
- return token.length > 0 ? token : null;
49
- }
50
-
51
- export async function verifySupabaseJwt(token: string): Promise<AuthClaims> {
52
- const { payload } = await jwtVerify(token, getJwks(), {
53
- audience: "authenticated",
54
- });
55
- const sub = payload.sub;
56
- if (!sub) throw new Error("JWT has no sub claim");
57
- return {
58
- userId: sub,
59
- email: typeof payload.email === "string" ? payload.email : undefined,
60
- };
61
- }
62
-
63
- /** Modul-E is unlocked directly or via the Full Bridge combo. */
64
- const MODULE_GRANTS = new Set([
65
- "modul-e",
66
- "combo-full-bridge",
67
- ]);
68
-
69
- export async function hasModulELicense(userId: string): Promise<boolean> {
70
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
71
- if (!serviceKey) {
72
- console.warn("[auth] SUPABASE_SERVICE_ROLE_KEY not set — cannot check licenses.");
73
- return false;
74
- }
75
- const params = new URLSearchParams({
76
- user_id: `eq.${userId}`,
77
- status: "eq.active",
78
- select: "product",
79
- });
80
- const res = await fetch(`${CENTRAL_SUPABASE_URL}/rest/v1/licenses?${params}`, {
81
- headers: {
82
- apikey: serviceKey,
83
- Authorization: `Bearer ${serviceKey}`,
84
- },
85
- });
86
- if (!res.ok) {
87
- const body = await res.text().catch(() => "");
88
- // Missing table (migrations not applied yet) => fail closed with a hint.
89
- console.warn(`[auth] license lookup HTTP ${res.status}: ${body.slice(0, 200)}`);
90
- return false;
91
- }
92
- const rows = (await res.json()) as { product?: string }[];
93
- return (
94
- Array.isArray(rows) && rows.some((r) => r.product && MODULE_GRANTS.has(r.product))
95
- );
96
- }
97
-
98
- /**
99
- * Requires a valid Supabase JWT. No-op (with a one-time warning) while
100
- * LICENSE_ENFORCEMENT is off so demo deployments keep working.
101
- */
102
- export function requireAuth(req: Request, res: Response, next: NextFunction) {
103
- if (!enforcementOn()) return next();
104
- void (async () => {
105
- const token = bearerToken(req);
106
- if (!token) {
107
- return res.status(401).json({ detail: "Missing bearer token — sign in first." });
108
- }
109
- try {
110
- const claims = await verifySupabaseJwt(token);
111
- (req as Request & { auth?: AuthClaims }).auth = claims;
112
- return next();
113
- } catch {
114
- return res.status(401).json({ detail: "Invalid or expired token." });
115
- }
116
- })();
117
- }
118
-
119
- /**
120
- * Requires a valid JWT AND an active Modul-E license (direct or Full Bridge).
121
- * No-op while LICENSE_ENFORCEMENT is off.
122
- */
123
- export function requireModulELicense(req: Request, res: Response, next: NextFunction) {
124
- if (!enforcementOn()) return next();
125
- void (async () => {
126
- const token = bearerToken(req);
127
- if (!token) {
128
- return res.status(401).json({ detail: "Missing bearer token — sign in first." });
129
- }
130
- try {
131
- const claims = await verifySupabaseJwt(token);
132
- (req as Request & { auth?: AuthClaims }).auth = claims;
133
- const ok = await hasModulELicense(claims.userId);
134
- if (!ok) {
135
- return res
136
- .status(402)
137
- .json({ detail: "No active Modul-E license — see /pricing." });
138
- }
139
- return next();
140
- } catch {
141
- return res.status(401).json({ detail: "Invalid or expired token." });
142
- }
143
- })();
144
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src//utils//cn.ts DELETED
@@ -1,6 +0,0 @@
1
- import { clsx, type ClassValue } from "clsx";
2
- import { twMerge } from "tailwind-merge";
3
-
4
- export function cn(...inputs: ClassValue[]) {
5
- return twMerge(clsx(inputs));
6
- }
 
 
 
 
 
 
 
src//vite-env.d.ts DELETED
@@ -1 +0,0 @@
1
- /// <reference types="vite/client" />