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- src//App.tsx +0 -82
- src//components//AnimatedLogo.test.tsx +0 -16
- src//components//AnimatedLogo.tsx +0 -69
- src//components//BackgroundElements.tsx +0 -18
- src//components//ConfirmModal.tsx +0 -113
- src//components//CsvExportButton.test.tsx +0 -11
- src//components//CsvExportButton.tsx +0 -47
- src//components//FormDrawer.tsx +0 -78
- src//components//GeometricBackground.test.tsx +0 -12
- src//components//GeometricBackground.tsx +0 -62
- src//components//GlassCard.tsx +0 -15
- src//components//Logo.tsx +0 -14
- src//components//RichTextEditor.tsx +0 -152
- src//components//Sidebar.tsx +0 -66
- src//components//StatCard.tsx +0 -149
- src//components//StatusBadge.test.tsx +0 -22
- src//components//StatusBadge.tsx +0 -83
- src//components//TopBar.tsx +0 -64
- src//context//ApiKeyContext.tsx +0 -93
- src//index.css +0 -522
- src//layouts//AppLayout.tsx +0 -37
- src//lib//aiService.ts +0 -45
- src//lib//apiClient.ts +0 -25
- src//lib//supabaseClient.ts +0 -27
- src//main.tsx +0 -10
- src//pages//Analytics.test.tsx +0 -83
- src//pages//Analytics.tsx +0 -247
- src//pages//CampaignDetail.tsx +0 -727
- src//pages//Campaigns.tsx +0 -441
- src//pages//Clients.tsx +0 -214
- src//pages//Dashboard.tsx +0 -551
- src//pages//EmailAccounts.tsx +0 -393
- src//pages//Gate.tsx +0 -262
- src//pages//Inbox.tsx +0 -307
- src//pages//Leads.tsx +0 -622
- src//pages//Settings.tsx +0 -434
- src//pages//Warmup.tsx +0 -266
- src//pages//Webhooks.tsx +0 -316
- src//pages//gate.css +0 -420
- src//server//middleware//auth.ts +0 -144
- src//utils//cn.ts +0 -6
- 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 |
-
{{{v}}}
|
| 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 (< 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} <{selectedMessage.lead_email}></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={KEY}<br />
|
| 307 |
-
POST /api/leads/search → https://server.smartlead.ai/api/v1/leads/?api_key={KEY}
|
| 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" />
|
|
|
|
|
|