Seth commited on
Commit
31d48c9
·
1 Parent(s): edcff68
frontend/src/components/campaigns/CampaignsDashboardTab.jsx CHANGED
@@ -5,9 +5,9 @@ import {
5
  Play,
6
  Pause,
7
  Pencil,
8
- Trash2,
9
- TrendingUp,
10
- FolderOpen,
11
  } from 'lucide-react';
12
  import { Button } from '@/components/ui/button';
13
  import CreateCampaignWizard from '@/components/campaigns/CreateCampaignWizard';
@@ -34,6 +34,12 @@ const DEMO_CAMPAIGNS = [
34
  lastEditedLabel: null,
35
  generationComplete: true,
36
  generationProgressPercent: 100,
 
 
 
 
 
 
37
  },
38
  {
39
  id: 'demo_2',
@@ -46,6 +52,8 @@ const DEMO_CAMPAIGNS = [
46
  lastEditedLabel: null,
47
  generationComplete: true,
48
  generationProgressPercent: 100,
 
 
49
  },
50
  {
51
  id: 'demo_3',
@@ -58,6 +66,8 @@ const DEMO_CAMPAIGNS = [
58
  lastEditedLabel: 'Last edited 2 hours ago',
59
  generationComplete: true,
60
  generationProgressPercent: 100,
 
 
61
  },
62
  ];
63
 
@@ -71,9 +81,26 @@ function normalizeCampaign(c) {
71
  100,
72
  Math.round(c.generationProgressPercent ?? (genDone ? 100 : hasFile ? 0 : 100))
73
  ),
 
74
  };
75
  }
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  function loadCampaigns() {
78
  try {
79
  const raw = localStorage.getItem(STORAGE_KEY);
@@ -109,45 +136,48 @@ function progressFromGenerationStatus(status) {
109
  return exp ? Math.min(100, Math.round((done / exp) * 100)) : 0;
110
  }
111
 
112
- function StatusDot({ status }) {
113
- const map = {
114
- running: 'bg-emerald-500',
115
- paused: 'bg-amber-500',
116
- draft: 'bg-slate-400',
117
- };
118
- return <span className={cn('inline-block h-2 w-2 rounded-full', map[status] || map.draft)} />;
119
  }
120
 
121
- function MetricBar({ value, colorClass }) {
122
- const v = Math.min(100, Math.max(0, value));
123
- return (
124
- <div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-100">
125
- <div className={cn('h-full rounded-full transition-all', colorClass)} style={{ width: `${v}%` }} />
126
- </div>
127
- );
128
  }
129
 
130
  /** Circular ring; hover shows native tooltip with percent (title). */
131
- function CampaignGenerationRing({ percent, complete, fileId }) {
132
  if (!fileId || complete) return null;
133
  const p = Math.min(100, Math.max(0, percent));
134
- const r = 17;
 
 
 
135
  const c = 2 * Math.PI * r;
136
  const offset = c * (1 - p / 100);
137
  return (
138
  <div
139
- className="relative h-11 w-11 shrink-0 cursor-default"
140
  title={`Content generation: ${Math.round(p)}%`}
141
  >
142
- <svg width="44" height="44" className="-rotate-90" viewBox="0 0 44 44" aria-hidden>
143
- <circle cx="22" cy="22" r={r} fill="none" stroke="#e2e8f0" strokeWidth="3.5" />
 
 
 
 
 
 
144
  <circle
145
- cx="22"
146
- cy="22"
147
  r={r}
148
  fill="none"
149
  stroke="#7c3aed"
150
- strokeWidth="3.5"
151
  strokeLinecap="round"
152
  strokeDasharray={c}
153
  strokeDashoffset={offset}
@@ -155,11 +185,14 @@ function CampaignGenerationRing({ percent, complete, fileId }) {
155
  />
156
  </svg>
157
  <span
158
- className="pointer-events-none absolute inset-0 flex items-center justify-center gap-0 px-0.5 text-center text-[10px] font-bold tabular-nums leading-none text-violet-800"
 
 
 
159
  aria-hidden
160
  >
161
  {Math.round(p)}
162
- <span className="text-[8px] font-semibold">%</span>
163
  </span>
164
  </div>
165
  );
@@ -170,6 +203,8 @@ export default function CampaignsDashboardTab() {
170
  const [wizardOpen, setWizardOpen] = useState(false);
171
  const [wizardCampaign, setWizardCampaign] = useState(null);
172
  const [menuOpenId, setMenuOpenId] = useState(null);
 
 
173
  const campaignsRef = useRef(campaigns);
174
 
175
  useEffect(() => {
@@ -233,24 +268,35 @@ export default function CampaignsDashboardTab() {
233
  return () => clearInterval(iv);
234
  }, []);
235
 
236
- const metrics = useMemo(() => {
237
- const withOpen = campaigns.filter((c) => c.openRate != null);
238
- const avgOpen =
239
- withOpen.length > 0
240
- ? withOpen.reduce((s, c) => s + (c.openRate || 0), 0) / withOpen.length
241
- : 0;
242
- const withReply = campaigns.filter((c) => c.replyRate != null);
243
- const avgReply =
244
- withReply.length > 0
245
- ? withReply.reduce((s, c) => s + (c.replyRate || 0), 0) / withReply.length
246
- : 0;
247
- const totalReach = campaigns.reduce((s, c) => s + (c.contacts || 0), 0);
248
- return {
249
- totalReach,
250
- avgOpen,
251
- avgReply,
252
- };
253
- }, [campaigns]);
 
 
 
 
 
 
 
 
 
 
 
254
 
255
  const toggleStatus = useCallback((id) => {
256
  setCampaigns((prev) =>
@@ -301,6 +347,7 @@ export default function CampaignsDashboardTab() {
301
  replyRate: prevRow.replyRate ?? null,
302
  teamExtra: prevRow.teamExtra ?? 0,
303
  lastEditedLabel: 'Just now',
 
304
  });
305
  const idx = prev.findIndex((c) => c.id === id);
306
  if (idx >= 0) {
@@ -355,6 +402,7 @@ export default function CampaignsDashboardTab() {
355
  replyRate: base.replyRate ?? null,
356
  teamExtra: base.teamExtra ?? 0,
357
  lastEditedLabel: 'Just now',
 
358
  });
359
  if (idx >= 0) {
360
  return prev.map((c, i) => (i === idx ? row : c));
@@ -380,53 +428,27 @@ export default function CampaignsDashboardTab() {
380
  }, []);
381
 
382
  return (
383
- <div className="space-y-8">
384
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
385
- <p className="text-sm text-slate-600 sm:max-w-xl">
386
- Create campaigns from your wizard, then track delivery and content generation here. Open a
387
- campaign to review or adjust its setup.
388
- </p>
 
 
 
389
  <Button
390
  type="button"
391
- className="shrink-0 bg-violet-600 text-white hover:bg-violet-700"
392
  onClick={openWizardForCreate}
393
  >
394
  <Plus className="mr-2 h-4 w-4" />
395
- Create New Campaign
396
  </Button>
397
  </div>
398
 
399
- <div className="grid gap-4 md:grid-cols-3">
400
- <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
401
- <div className="pointer-events-none absolute -right-4 -top-4 opacity-[0.07]">
402
- <TrendingUp className="h-24 w-24 text-violet-600" />
403
- </div>
404
- <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Active reach</p>
405
- <p className="mt-2 text-3xl font-bold tabular-nums text-violet-600">
406
- {metrics.totalReach.toLocaleString()}
407
- </p>
408
- <span className="mt-2 inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">
409
- ↑ 14% vs last month
410
- </span>
411
- </div>
412
- <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
413
- <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Avg. open rate</p>
414
- <p className="mt-2 text-3xl font-bold tabular-nums text-slate-900">
415
- {metrics.avgOpen > 0 ? `${metrics.avgOpen.toFixed(1)}%` : '—'}
416
- </p>
417
- <MetricBar value={metrics.avgOpen} colorClass="bg-violet-500" />
418
- </div>
419
- <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
420
- <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Response goal</p>
421
- <p className="mt-2 text-3xl font-bold tabular-nums text-slate-900">
422
- {metrics.avgReply > 0 ? `${metrics.avgReply.toFixed(1)}%` : '—'}
423
- </p>
424
- <MetricBar value={metrics.avgReply} colorClass="bg-amber-700/80" />
425
- </div>
426
- </div>
427
-
428
  {campaigns.length === 0 ? (
429
- <div className="rounded-2xl border border-dashed border-slate-200 bg-white/60 py-16 text-center">
430
  <p className="text-slate-600">No campaigns yet. Create your first campaign to get started.</p>
431
  <Button
432
  type="button"
@@ -434,153 +456,269 @@ export default function CampaignsDashboardTab() {
434
  onClick={openWizardForCreate}
435
  >
436
  <Plus className="mr-2 h-4 w-4" />
437
- Create New Campaign
438
  </Button>
439
  </div>
440
  ) : (
441
- <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
442
- {campaigns.map((c) => (
443
- <article
444
- key={c.id}
445
- className="relative flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-violet-200 hover:shadow-md"
446
- >
447
- <div className="absolute right-4 top-4">
448
- <CampaignGenerationRing
449
- percent={c.generationProgressPercent ?? 0}
450
- complete={!!c.generationComplete}
451
- fileId={c.fileId}
452
- />
453
- </div>
454
-
455
- <div className="flex items-start justify-between gap-2 pr-12">
456
- <div className="flex items-center gap-2">
457
- <StatusDot status={c.status} />
458
- <span className="text-[11px] font-semibold uppercase tracking-wide text-slate-600">
459
- {c.status === 'running'
460
- ? 'Running'
461
- : c.status === 'paused'
462
- ? 'Paused'
463
- : 'Draft'}
464
- </span>
465
- </div>
466
- <div className="relative">
467
- <button
468
- type="button"
469
- className="rounded-md p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
470
- aria-label="Campaign menu"
471
- onClick={() => setMenuOpenId((id) => (id === c.id ? null : c.id))}
472
- >
473
- <MoreVertical className="h-4 w-4" />
474
- </button>
475
- {menuOpenId === c.id ? (
476
- <>
477
- <button
478
- type="button"
479
- className="fixed inset-0 z-10 cursor-default"
480
- aria-label="Close menu"
481
- onClick={() => setMenuOpenId(null)}
482
- />
483
- <div className="absolute right-0 z-20 mt-1 w-44 rounded-lg border border-slate-200 bg-white py-1 text-sm shadow-lg">
484
- <button
485
- type="button"
486
- className="block w-full px-3 py-2 text-left hover:bg-slate-50"
487
- onClick={() => openWizardForEdit(c)}
488
- >
489
- Edit in wizard
490
- </button>
491
- <button
492
- type="button"
493
- className="block w-full px-3 py-2 text-left text-red-600 hover:bg-red-50"
494
- onClick={() => deleteCampaign(c.id)}
495
- >
496
- Delete
497
- </button>
498
- </div>
499
- </>
500
- ) : null}
501
- </div>
502
- </div>
503
-
504
- <h3 className="mt-2 pr-10 text-lg font-semibold leading-snug text-slate-900">{c.name}</h3>
505
- {c.prospectFileName ? (
506
- <p className="mt-1 truncate text-xs text-slate-500" title={c.prospectFileName}>
507
- {c.prospectFileName}
508
- </p>
509
- ) : null}
510
-
511
- <div className="mt-4 grid grid-cols-3 gap-2 text-center">
512
- <div>
513
- <p className="text-[11px] font-medium uppercase text-slate-500">Contacts</p>
514
- <p className="mt-0.5 font-semibold tabular-nums text-slate-900">
515
- {(c.contacts || 0).toLocaleString()}
516
- </p>
517
- </div>
518
- <div title="Personalized rows written for this upload">
519
- <p className="text-[11px] font-medium uppercase text-slate-500">Content</p>
520
- <p className="mt-0.5 font-semibold tabular-nums text-slate-900">
521
- {c.generationComplete ? '100%' : `${Math.round(c.generationProgressPercent ?? 0)}%`}
522
- </p>
523
- </div>
524
- <div>
525
- <p className="text-[11px] font-medium uppercase text-slate-500">Open</p>
526
- <p className="mt-0.5 font-semibold tabular-nums text-slate-900">
527
- {c.openRate != null ? `${c.openRate}%` : '—'}
528
- </p>
529
- </div>
530
- </div>
531
-
532
- <div className="mt-4 flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4">
533
- <Button
534
- type="button"
535
- size="sm"
536
- variant="outline"
537
- className="border-violet-200 text-violet-800 hover:bg-violet-50"
538
- onClick={() => openWizardForEdit(c)}
539
- >
540
- <FolderOpen className="mr-1.5 h-4 w-4" />
541
- Open
542
- </Button>
543
- {c.status === 'running' ? (
544
- <button
545
- type="button"
546
- onClick={() => toggleStatus(c.id)}
547
- className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
548
- title="Pause"
549
- >
550
- <Pause className="h-4 w-4" />
551
- </button>
552
- ) : c.status === 'paused' ? (
553
- <button
554
- type="button"
555
- onClick={() => toggleStatus(c.id)}
556
- className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
557
- title="Resume"
558
- >
559
- <Play className="h-4 w-4" />
560
- </button>
561
- ) : null}
562
- <button
563
- type="button"
564
- className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
565
- title="Edit in wizard"
566
- onClick={() => openWizardForEdit(c)}
567
- >
568
- <Pencil className="h-4 w-4" />
569
- </button>
570
  <button
 
571
  type="button"
572
- onClick={() => deleteCampaign(c.id)}
573
- className="rounded-lg p-2 text-slate-500 hover:bg-red-50 hover:text-red-600"
574
- title="Delete"
 
 
 
 
575
  >
576
- <Trash2 className="h-4 w-4" />
577
  </button>
578
- {c.lastEditedLabel ? (
579
- <span className="ml-auto text-xs text-slate-500">{c.lastEditedLabel}</span>
580
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  </div>
582
- </article>
583
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  </div>
585
  )}
586
 
 
5
  Play,
6
  Pause,
7
  Pencil,
8
+ Search,
9
+ SlidersHorizontal,
10
+ ExternalLink,
11
  } from 'lucide-react';
12
  import { Button } from '@/components/ui/button';
13
  import CreateCampaignWizard from '@/components/campaigns/CreateCampaignWizard';
 
34
  lastEditedLabel: null,
35
  generationComplete: true,
36
  generationProgressPercent: 100,
37
+ createdAt: new Date(Date.now() - 86400000 * 12).toISOString(),
38
+ sequence: [
39
+ { type: 'action', channel: 'gmail', id: '1' },
40
+ { type: 'action', channel: 'gmail', id: '2' },
41
+ { type: 'action', channel: 'linkedin', id: '3' },
42
+ ],
43
  },
44
  {
45
  id: 'demo_2',
 
52
  lastEditedLabel: null,
53
  generationComplete: true,
54
  generationProgressPercent: 100,
55
+ createdAt: new Date(Date.now() - 86400000 * 5).toISOString(),
56
+ sequence: [{ type: 'action', channel: 'gmail', id: '1' }],
57
  },
58
  {
59
  id: 'demo_3',
 
66
  lastEditedLabel: 'Last edited 2 hours ago',
67
  generationComplete: true,
68
  generationProgressPercent: 100,
69
+ createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
70
+ sequence: [],
71
  },
72
  ];
73
 
 
81
  100,
82
  Math.round(c.generationProgressPercent ?? (genDone ? 100 : hasFile ? 0 : 100))
83
  ),
84
+ sequence: Array.isArray(c.sequence) ? c.sequence : [],
85
  };
86
  }
87
 
88
+ function countSequenceActions(sequence) {
89
+ if (!Array.isArray(sequence)) return 0;
90
+ return sequence.filter((s) => s && s.type === 'action').length;
91
+ }
92
+
93
+ function formatCreatedAt(iso) {
94
+ if (!iso || typeof iso !== 'string') return '—';
95
+ try {
96
+ const d = new Date(iso);
97
+ if (Number.isNaN(d.getTime())) return '—';
98
+ return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
99
+ } catch {
100
+ return '—';
101
+ }
102
+ }
103
+
104
  function loadCampaigns() {
105
  try {
106
  const raw = localStorage.getItem(STORAGE_KEY);
 
136
  return exp ? Math.min(100, Math.round((done / exp) * 100)) : 0;
137
  }
138
 
139
+ function statusBadgeClass(status) {
140
+ if (status === 'running') return 'bg-emerald-50 text-emerald-800 ring-1 ring-emerald-200';
141
+ if (status === 'paused') return 'bg-amber-50 text-amber-900 ring-1 ring-amber-200';
142
+ return 'bg-slate-100 text-slate-700 ring-1 ring-slate-200';
 
 
 
143
  }
144
 
145
+ function statusLabel(status) {
146
+ if (status === 'running') return 'Active';
147
+ if (status === 'paused') return 'Paused';
148
+ return 'Draft';
 
 
 
149
  }
150
 
151
  /** Circular ring; hover shows native tooltip with percent (title). */
152
+ function CampaignGenerationRing({ percent, complete, fileId, compact = false }) {
153
  if (!fileId || complete) return null;
154
  const p = Math.min(100, Math.max(0, percent));
155
+ const r = compact ? 14 : 17;
156
+ const stroke = compact ? 3 : 3.5;
157
+ const size = compact ? 36 : 44;
158
+ const cx = size / 2;
159
  const c = 2 * Math.PI * r;
160
  const offset = c * (1 - p / 100);
161
  return (
162
  <div
163
+ className={cn('relative shrink-0 cursor-default', compact ? 'h-9 w-9' : 'h-11 w-11')}
164
  title={`Content generation: ${Math.round(p)}%`}
165
  >
166
+ <svg
167
+ width={size}
168
+ height={size}
169
+ className="-rotate-90"
170
+ viewBox={`0 0 ${size} ${size}`}
171
+ aria-hidden
172
+ >
173
+ <circle cx={cx} cy={cx} r={r} fill="none" stroke="#e2e8f0" strokeWidth={stroke} />
174
  <circle
175
+ cx={cx}
176
+ cy={cx}
177
  r={r}
178
  fill="none"
179
  stroke="#7c3aed"
180
+ strokeWidth={stroke}
181
  strokeLinecap="round"
182
  strokeDasharray={c}
183
  strokeDashoffset={offset}
 
185
  />
186
  </svg>
187
  <span
188
+ className={cn(
189
+ 'pointer-events-none absolute inset-0 flex items-center justify-center gap-0 px-0.5 text-center font-bold tabular-nums leading-none text-violet-800',
190
+ compact ? 'text-[9px]' : 'text-[10px]'
191
+ )}
192
  aria-hidden
193
  >
194
  {Math.round(p)}
195
+ <span className={cn('font-semibold', compact ? 'text-[7px]' : 'text-[8px]')}>%</span>
196
  </span>
197
  </div>
198
  );
 
203
  const [wizardOpen, setWizardOpen] = useState(false);
204
  const [wizardCampaign, setWizardCampaign] = useState(null);
205
  const [menuOpenId, setMenuOpenId] = useState(null);
206
+ const [listTab, setListTab] = useState('all');
207
+ const [searchQuery, setSearchQuery] = useState('');
208
  const campaignsRef = useRef(campaigns);
209
 
210
  useEffect(() => {
 
268
  return () => clearInterval(iv);
269
  }, []);
270
 
271
+ const tabCounts = useMemo(
272
+ () => ({
273
+ all: campaigns.length,
274
+ running: campaigns.filter((c) => c.status === 'running').length,
275
+ paused: campaigns.filter((c) => c.status === 'paused').length,
276
+ draft: campaigns.filter((c) => c.status === 'draft').length,
277
+ }),
278
+ [campaigns]
279
+ );
280
+
281
+ const filteredCampaigns = useMemo(() => {
282
+ let list = campaigns;
283
+ if (listTab === 'running') list = list.filter((c) => c.status === 'running');
284
+ else if (listTab === 'paused') list = list.filter((c) => c.status === 'paused');
285
+ else if (listTab === 'draft') list = list.filter((c) => c.status === 'draft');
286
+ const q = searchQuery.trim().toLowerCase();
287
+ if (q) {
288
+ list = list.filter(
289
+ (c) =>
290
+ String(c.name || '')
291
+ .toLowerCase()
292
+ .includes(q) ||
293
+ String(c.prospectFileName || '')
294
+ .toLowerCase()
295
+ .includes(q)
296
+ );
297
+ }
298
+ return list;
299
+ }, [campaigns, listTab, searchQuery]);
300
 
301
  const toggleStatus = useCallback((id) => {
302
  setCampaigns((prev) =>
 
347
  replyRate: prevRow.replyRate ?? null,
348
  teamExtra: prevRow.teamExtra ?? 0,
349
  lastEditedLabel: 'Just now',
350
+ createdAt: prevRow.createdAt || new Date().toISOString(),
351
  });
352
  const idx = prev.findIndex((c) => c.id === id);
353
  if (idx >= 0) {
 
402
  replyRate: base.replyRate ?? null,
403
  teamExtra: base.teamExtra ?? 0,
404
  lastEditedLabel: 'Just now',
405
+ createdAt: base.createdAt || new Date().toISOString(),
406
  });
407
  if (idx >= 0) {
408
  return prev.map((c, i) => (i === idx ? row : c));
 
428
  }, []);
429
 
430
  return (
431
+ <div className="space-y-6">
432
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
433
+ <div>
434
+ <h1 className="text-2xl font-semibold tracking-tight text-slate-900">Email campaigns</h1>
435
+ <p className="mt-1 max-w-2xl text-sm text-slate-600">
436
+ Manage and analyze your outreach campaigns. Open a row to continue setup, edit sequence and
437
+ prompts, or track content generation progress.
438
+ </p>
439
+ </div>
440
  <Button
441
  type="button"
442
+ className="shrink-0 bg-violet-600 px-5 text-white hover:bg-violet-700"
443
  onClick={openWizardForCreate}
444
  >
445
  <Plus className="mr-2 h-4 w-4" />
446
+ Create campaign
447
  </Button>
448
  </div>
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  {campaigns.length === 0 ? (
451
+ <div className="rounded-xl border border-dashed border-slate-200 bg-white py-16 text-center shadow-sm">
452
  <p className="text-slate-600">No campaigns yet. Create your first campaign to get started.</p>
453
  <Button
454
  type="button"
 
456
  onClick={openWizardForCreate}
457
  >
458
  <Plus className="mr-2 h-4 w-4" />
459
+ Create campaign
460
  </Button>
461
  </div>
462
  ) : (
463
+ <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
464
+ <div className="flex flex-col gap-4 border-b border-slate-100 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
465
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-2">
466
+ {[
467
+ { id: 'all', label: 'All', count: tabCounts.all },
468
+ { id: 'running', label: 'Active', count: tabCounts.running },
469
+ { id: 'paused', label: 'Paused', count: tabCounts.paused },
470
+ { id: 'draft', label: 'Draft', count: tabCounts.draft },
471
+ ].map((t) => (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  <button
473
+ key={t.id}
474
  type="button"
475
+ onClick={() => setListTab(t.id)}
476
+ className={cn(
477
+ 'border-b-2 pb-2 text-xs font-semibold uppercase tracking-wide transition',
478
+ listTab === t.id
479
+ ? 'border-violet-600 text-violet-700'
480
+ : 'border-transparent text-slate-500 hover:text-slate-800'
481
+ )}
482
  >
483
+ {t.label} ({t.count})
484
  </button>
485
+ ))}
486
+ </div>
487
+ <div className="flex flex-wrap items-center gap-2">
488
+ <Button
489
+ type="button"
490
+ variant="outline"
491
+ size="sm"
492
+ className="text-slate-600"
493
+ disabled
494
+ title="Coming soon"
495
+ >
496
+ <SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" />
497
+ Filter
498
+ </Button>
499
+ <div className="relative min-w-[200px] flex-1 sm:max-w-xs">
500
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
501
+ <input
502
+ type="search"
503
+ placeholder="Search campaigns…"
504
+ value={searchQuery}
505
+ onChange={(e) => setSearchQuery(e.target.value)}
506
+ className="h-9 w-full rounded-lg border border-slate-200 bg-slate-50/80 py-1.5 pl-9 pr-3 text-sm text-slate-900 outline-none ring-violet-200 transition placeholder:text-slate-400 focus:border-violet-300 focus:bg-white focus:ring-2"
507
+ />
508
  </div>
509
+ </div>
510
+ </div>
511
+
512
+ <div className="overflow-x-auto">
513
+ <table className="w-full min-w-[900px] border-collapse text-left text-sm">
514
+ <thead>
515
+ <tr className="border-b border-slate-100 bg-slate-50/90 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
516
+ <th className="w-10 px-3 py-3">
517
+ <span className="sr-only">Select</span>
518
+ <input
519
+ type="checkbox"
520
+ disabled
521
+ className="rounded border-slate-300 text-violet-600"
522
+ aria-label="Select all (coming soon)"
523
+ />
524
+ </th>
525
+ <th className="min-w-[280px] px-3 py-3">Campaign</th>
526
+ <th className="px-3 py-3 text-right tabular-nums">Leads</th>
527
+ <th className="px-3 py-3 text-right tabular-nums">Content</th>
528
+ <th className="px-3 py-3 text-right tabular-nums">Sent</th>
529
+ <th className="px-3 py-3 text-right tabular-nums">Opened</th>
530
+ <th className="px-3 py-3 text-right tabular-nums">Clicked</th>
531
+ <th className="px-3 py-3 text-right tabular-nums">Replied</th>
532
+ <th className="w-28 px-3 py-3 text-right">Actions</th>
533
+ </tr>
534
+ </thead>
535
+ <tbody>
536
+ {filteredCampaigns.length === 0 ? (
537
+ <tr>
538
+ <td
539
+ colSpan={9}
540
+ className="px-4 py-12 text-center text-sm text-slate-500"
541
+ >
542
+ No campaigns match this filter or search.
543
+ </td>
544
+ </tr>
545
+ ) : (
546
+ filteredCampaigns.map((c) => {
547
+ const seqN = countSequenceActions(c.sequence);
548
+ const metaSeq =
549
+ seqN > 0
550
+ ? `${seqN} sequence${seqN === 1 ? '' : 's'}`
551
+ : 'Sequence not saved';
552
+ const created = formatCreatedAt(c.createdAt);
553
+ const contentPct = c.generationComplete
554
+ ? '100%'
555
+ : `${Math.round(c.generationProgressPercent ?? 0)}%`;
556
+ return (
557
+ <tr
558
+ key={c.id}
559
+ className="border-b border-slate-100 transition hover:bg-slate-50/80"
560
+ >
561
+ <td className="px-3 py-3 align-middle">
562
+ <input
563
+ type="checkbox"
564
+ disabled
565
+ className="rounded border-slate-300 text-violet-600"
566
+ aria-label={`Select ${c.name}`}
567
+ />
568
+ </td>
569
+ <td className="max-w-[360px] px-3 py-3 align-middle">
570
+ <div className="flex items-start gap-3">
571
+ <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center">
572
+ {c.fileId && !c.generationComplete ? (
573
+ <CampaignGenerationRing
574
+ percent={c.generationProgressPercent ?? 0}
575
+ complete={false}
576
+ fileId={c.fileId}
577
+ compact
578
+ />
579
+ ) : c.fileId && c.generationComplete ? (
580
+ <span
581
+ className="flex h-9 w-9 items-center justify-center rounded-full bg-emerald-50 text-[10px] font-bold text-emerald-700 ring-1 ring-emerald-200"
582
+ title="Content generation complete"
583
+ >
584
+ 100%
585
+ </span>
586
+ ) : (
587
+ <span
588
+ className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-[10px] font-medium text-slate-400"
589
+ title="No upload"
590
+ >
591
+
592
+ </span>
593
+ )}
594
+ </div>
595
+ <div className="min-w-0 flex-1">
596
+ <button
597
+ type="button"
598
+ onClick={() => openWizardForEdit(c)}
599
+ className="group flex items-center gap-1.5 text-left font-semibold text-slate-900 hover:text-violet-700"
600
+ >
601
+ <span className="truncate">{c.name}</span>
602
+ <ExternalLink className="h-3.5 w-3.5 shrink-0 text-slate-400 opacity-0 transition group-hover:opacity-100 group-hover:text-violet-600" />
603
+ </button>
604
+ <div className="mt-1 flex flex-wrap items-center gap-2">
605
+ <span
606
+ className={cn(
607
+ 'inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold',
608
+ statusBadgeClass(c.status)
609
+ )}
610
+ >
611
+ {statusLabel(c.status)}
612
+ </span>
613
+ <span className="text-xs text-slate-500">
614
+ {metaSeq} · Created {created}
615
+ </span>
616
+ </div>
617
+ {c.prospectFileName ? (
618
+ <p
619
+ className="mt-0.5 truncate text-xs text-slate-400"
620
+ title={c.prospectFileName}
621
+ >
622
+ {c.prospectFileName}
623
+ </p>
624
+ ) : null}
625
+ </div>
626
+ </div>
627
+ </td>
628
+ <td className="px-3 py-3 text-right tabular-nums text-slate-800">
629
+ {(c.contacts || 0).toLocaleString()}
630
+ </td>
631
+ <td className="px-3 py-3 text-right tabular-nums font-medium text-violet-700">
632
+ {contentPct}
633
+ </td>
634
+ <td className="px-3 py-3 text-right tabular-nums text-slate-500">
635
+
636
+ </td>
637
+ <td className="px-3 py-3 text-right tabular-nums text-fuchsia-700">
638
+ {c.openRate != null ? `— / ${c.openRate}%` : '—'}
639
+ </td>
640
+ <td className="px-3 py-3 text-right tabular-nums text-amber-700">
641
+
642
+ </td>
643
+ <td className="px-3 py-3 text-right tabular-nums text-teal-700">
644
+ {c.replyRate != null ? `— / ${c.replyRate}%` : '—'}
645
+ </td>
646
+ <td className="px-3 py-3 text-right align-middle">
647
+ <div className="flex items-center justify-end gap-0.5">
648
+ {c.status === 'running' ? (
649
+ <button
650
+ type="button"
651
+ onClick={() => toggleStatus(c.id)}
652
+ className="rounded-md p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
653
+ title="Pause"
654
+ >
655
+ <Pause className="h-4 w-4" />
656
+ </button>
657
+ ) : c.status === 'paused' ? (
658
+ <button
659
+ type="button"
660
+ onClick={() => toggleStatus(c.id)}
661
+ className="rounded-md p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
662
+ title="Resume"
663
+ >
664
+ <Play className="h-4 w-4" />
665
+ </button>
666
+ ) : null}
667
+ <button
668
+ type="button"
669
+ className="rounded-md p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
670
+ title="Edit"
671
+ onClick={() => openWizardForEdit(c)}
672
+ >
673
+ <Pencil className="h-4 w-4" />
674
+ </button>
675
+ <div className="relative">
676
+ <button
677
+ type="button"
678
+ className="rounded-md p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
679
+ aria-label="More"
680
+ onClick={() =>
681
+ setMenuOpenId((id) => (id === c.id ? null : c.id))
682
+ }
683
+ >
684
+ <MoreVertical className="h-4 w-4" />
685
+ </button>
686
+ {menuOpenId === c.id ? (
687
+ <>
688
+ <button
689
+ type="button"
690
+ className="fixed inset-0 z-10 cursor-default"
691
+ aria-label="Close menu"
692
+ onClick={() => setMenuOpenId(null)}
693
+ />
694
+ <div className="absolute right-0 z-20 mt-1 w-44 rounded-lg border border-slate-200 bg-white py-1 text-sm shadow-lg">
695
+ <button
696
+ type="button"
697
+ className="block w-full px-3 py-2 text-left hover:bg-slate-50"
698
+ onClick={() => openWizardForEdit(c)}
699
+ >
700
+ Open in wizard
701
+ </button>
702
+ <button
703
+ type="button"
704
+ className="block w-full px-3 py-2 text-left text-red-600 hover:bg-red-50"
705
+ onClick={() => deleteCampaign(c.id)}
706
+ >
707
+ Delete
708
+ </button>
709
+ </div>
710
+ </>
711
+ ) : null}
712
+ </div>
713
+ </div>
714
+ </td>
715
+ </tr>
716
+ );
717
+ })
718
+ )}
719
+ </tbody>
720
+ </table>
721
+ </div>
722
  </div>
723
  )}
724
 
frontend/src/components/campaigns/CreateCampaignWizard.jsx CHANGED
@@ -1052,50 +1052,41 @@ export default function CreateCampaignWizard({
1052
  </span>
1053
  ) : null}
1054
  </div>
1055
- <div className="flex justify-end">
1056
- <Button
1057
- type="button"
1058
- className="bg-violet-600 text-white hover:bg-violet-700"
1059
- onClick={handleLaunch}
1060
- >
1061
- Launch campaign
1062
- </Button>
1063
- </div>
1064
  </div>
1065
  )}
1066
  </div>
1067
 
1068
- <div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-8">
1069
- <button
1070
- type="button"
1071
- onClick={() => requestClose()}
1072
- className="text-sm font-medium text-slate-600 hover:text-slate-900"
1073
- >
1074
- Cancel
1075
- </button>
1076
- <div className="flex flex-wrap items-center gap-2">
1077
- {step > 1 ? (
1078
- <Button type="button" variant="outline" onClick={handleBack}>
1079
- <ArrowLeft className="mr-2 h-4 w-4" />
1080
- Back
1081
- </Button>
1082
- ) : null}
1083
- {step < 4 ? (
1084
- <Button
1085
- type="button"
1086
- disabled={step === 1 && !canContinueStep1}
1087
- className="bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-40"
1088
- onClick={handleContinue}
1089
- >
1090
- {step === 1
1091
- ? 'Continue to Sequence'
1092
- : step === 3
1093
- ? 'Continue to Review & Launch'
1094
- : `Continue to ${STEPS[step]?.label ?? 'next'}`}
1095
- <ArrowRight className="ml-2 h-4 w-4" />
1096
- </Button>
1097
- ) : null}
1098
- </div>
1099
  </div>
1100
  </div>
1101
  </div>
 
1052
  </span>
1053
  ) : null}
1054
  </div>
 
 
 
 
 
 
 
 
 
1055
  </div>
1056
  )}
1057
  </div>
1058
 
1059
+ <div className="flex flex-wrap items-center justify-end gap-2 border-t border-slate-100 bg-white px-5 py-4 sm:px-8">
1060
+ {step === 4 ? (
1061
+ <Button
1062
+ type="button"
1063
+ className="bg-violet-600 text-white hover:bg-violet-700"
1064
+ onClick={handleLaunch}
1065
+ >
1066
+ Launch campaign
1067
+ </Button>
1068
+ ) : null}
1069
+ {step > 1 ? (
1070
+ <Button type="button" variant="outline" onClick={handleBack}>
1071
+ <ArrowLeft className="mr-2 h-4 w-4" />
1072
+ Back
1073
+ </Button>
1074
+ ) : null}
1075
+ {step < 4 ? (
1076
+ <Button
1077
+ type="button"
1078
+ disabled={step === 1 && !canContinueStep1}
1079
+ className="bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-40"
1080
+ onClick={handleContinue}
1081
+ >
1082
+ {step === 1
1083
+ ? 'Continue to Sequence'
1084
+ : step === 3
1085
+ ? 'Continue to Review & Launch'
1086
+ : `Continue to ${STEPS[step]?.label ?? 'next'}`}
1087
+ <ArrowRight className="ml-2 h-4 w-4" />
1088
+ </Button>
1089
+ ) : null}
1090
  </div>
1091
  </div>
1092
  </div>