Skip to content

Commit 2b58c07

Browse files
committed
feat(campaigns): responsive, on-brand UI redesign
- Header icon and Launch/Resume buttons were green (#10b981), clashing with the Catalyst gold brand — recolored to brand gold - Add a summary stat row (campaigns, active, sent, avg open rate) - Responsive: card list on mobile/tablet (< lg), table on desktop — no more forced horizontal scroll on phones - Actions are always visible (were opacity-0 hover-only, so unusable on touch devices) via a shared renderCampaignActions helper - Recolor audience-picker avatars/rows from emerald to brand gold - Branded empty state
1 parent 8d62c94 commit 2b58c07

1 file changed

Lines changed: 115 additions & 51 deletions

File tree

  • src/app/(protected)/flywheel/campaigns

src/app/(protected)/flywheel/campaigns/page.tsx

Lines changed: 115 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -338,36 +338,88 @@ export default function FlywheelCampaigns() {
338338
};
339339

340340

341+
const renderCampaignActions = (c: Campaign) => (
342+
<>
343+
{c.status === 'DRAFT' && (
344+
<button onClick={() => handleDispatch(c.id)} disabled={dispatching}
345+
className="px-3 py-1.5 rounded-md text-xs font-semibold text-white transition-opacity flex items-center gap-1 disabled:opacity-60"
346+
style={{ background: brand.primaryColor }}>
347+
<IconSend size={12} /> Launch
348+
</button>
349+
)}
350+
{c.status === 'ACTIVE' && (
351+
<button onClick={() => handlePause(c.id, c.status)} disabled={mutating}
352+
className="px-3 py-1.5 bg-amber-50 border border-amber-200 text-amber-700 rounded-md text-xs font-semibold hover:bg-amber-100 transition-colors flex items-center gap-1">
353+
<IconPause size={12} /> Pause
354+
</button>
355+
)}
356+
{c.status === 'PAUSED' && (
357+
<button onClick={() => handlePause(c.id, c.status)} disabled={mutating}
358+
className="px-3 py-1.5 rounded-md text-xs font-semibold text-white transition-opacity flex items-center gap-1"
359+
style={{ background: brand.primaryColor }}>
360+
<IconPlay size={12} /> Resume
361+
</button>
362+
)}
363+
<button onClick={() => setSelectedCampaign(c)}
364+
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-600 rounded-md text-xs font-medium hover:border-slate-300 transition-colors flex items-center gap-1">
365+
<IconEye size={12} /> Details
366+
</button>
367+
<button onClick={() => handleDelete(c.id)} disabled={mutating}
368+
className="px-3 py-1.5 bg-red-50 border border-red-200 text-red-600 rounded-md text-xs font-medium hover:bg-red-100 transition-colors flex items-center gap-1">
369+
<IconX size={12} /> Delete
370+
</button>
371+
</>
372+
);
373+
374+
const summaryStats = [
375+
{ label: 'Campaigns', value: String(campaigns.length), color: 'text-slate-900' },
376+
{ label: 'Active', value: String(campaigns.filter(c => c.status === 'ACTIVE').length), color: 'text-emerald-600' },
377+
{ label: 'Emails Sent', value: campaigns.reduce((s, c) => s + (c.stats?.sent || 0), 0).toLocaleString(), color: 'text-blue-600' },
378+
{ label: 'Avg Open Rate', value: (() => { const sent = campaigns.reduce((s, c) => s + (c.stats?.sent || 0), 0); const op = campaigns.reduce((s, c) => s + (c.stats?.opens || 0), 0); return sent > 0 ? `${Math.round((op / sent) * 100)}%` : '—'; })(), color: 'text-amber-600' },
379+
];
380+
341381
return (
342382
<AppShell>
343-
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-8 pb-16">
383+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-6 sm:pt-8 pb-16">
344384

345385
{/* Header */}
346-
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
347-
<div>
348-
<div className="flex items-center gap-3 mb-1">
349-
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #10b981, #059669)' }}>
350-
<IconMail size={20} style={{ color: '#fff' }} />
351-
</div>
352-
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Campaigns</h1>
386+
<div className="flex flex-col md:flex-row md:justify-between md:items-center mb-6 gap-4">
387+
<div className="flex items-center gap-3">
388+
<div className="w-11 h-11 rounded-xl flex items-center justify-center shadow-sm flex-shrink-0" style={{ background: brand.gradient }}>
389+
<IconMail size={20} style={{ color: '#fff' }} />
390+
</div>
391+
<div>
392+
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-slate-900">Campaigns</h1>
393+
<p className="text-sm text-slate-500">Create, send, and track email marketing.</p>
353394
</div>
354-
<p className="text-slate-500 mt-1 ml-[52px]">Create, manage, and track email marketing campaigns.</p>
355395
</div>
356-
<div className="flex items-center gap-3">
396+
<div className="flex items-center gap-2 w-full md:w-auto">
357397
<button
358398
onClick={handleProcessNow}
359399
disabled={processing}
360-
className="flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm border border-slate-200 bg-white text-slate-700 hover:bg-slate-50 transition-all disabled:opacity-60"
400+
className="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm border border-slate-200 bg-white text-slate-700 hover:bg-slate-50 transition-all disabled:opacity-60"
361401
>
362402
<IconRefresh size={15} className={processing ? 'animate-spin' : ''} />
363-
{processing ? 'Processing…' : 'Process Now'}
403+
<span className="whitespace-nowrap">{processing ? 'Processing…' : 'Process Now'}</span>
364404
</button>
365-
<button onClick={() => { setWizardOpen(true); resetWizard(); }} className="flex items-center gap-2 px-5 py-2.5 rounded-lg text-white font-medium text-sm shadow-md transition-all hover:shadow-lg hover:-translate-y-0.5" style={{ background: brand.gradient }}>
366-
<IconPlus size={16} /> New Campaign
405+
<button onClick={() => { setWizardOpen(true); resetWizard(); }} className="flex-1 md:flex-none flex items-center justify-center gap-2 px-5 py-2.5 rounded-lg text-white font-medium text-sm shadow-md transition-all hover:shadow-lg" style={{ background: brand.gradient }}>
406+
<IconPlus size={16} /> <span className="whitespace-nowrap">New Campaign</span>
367407
</button>
368408
</div>
369409
</div>
370410

411+
{/* Summary stats */}
412+
{!loading && campaigns.length > 0 && (
413+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
414+
{summaryStats.map(s => (
415+
<div key={s.label} className="bg-white border border-slate-200 rounded-xl px-4 py-3">
416+
<p className="text-xs text-slate-400 font-medium">{s.label}</p>
417+
<p className={`text-lg sm:text-xl font-bold mt-0.5 ${s.color}`}>{s.value}</p>
418+
</div>
419+
))}
420+
</div>
421+
)}
422+
371423
{processResult && (
372424
<div className={`mb-6 px-5 py-3.5 rounded-xl border text-sm font-medium flex items-center justify-between ${
373425
processResult.failedCount > 0
@@ -387,8 +439,50 @@ export default function FlywheelCampaigns() {
387439
</div>
388440
)}
389441

390-
{/* Campaign Table */}
391-
<div className="card overflow-hidden">
442+
{/* Empty state (shared) */}
443+
{!loading && campaigns.length === 0 && (
444+
<div className="bg-white border border-slate-200 rounded-2xl px-6 py-16 text-center">
445+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4" style={{ background: brand.primaryLight, color: brand.primaryColor }}><IconMail size={28} /></div>
446+
<h3 className="text-lg font-semibold text-slate-900 mb-1">No campaigns yet</h3>
447+
<p className="text-slate-500 text-sm mb-5 max-w-sm mx-auto">Create your first campaign — start from a ready-made template or build your own.</p>
448+
<button onClick={() => { setWizardOpen(true); resetWizard(); }} className="px-5 py-2.5 text-white rounded-lg text-sm font-semibold inline-flex items-center gap-1.5" style={{ background: brand.primaryColor }}>
449+
<IconPlus size={15} /> Create Campaign
450+
</button>
451+
</div>
452+
)}
453+
454+
{/* ── Mobile / tablet: card list ── */}
455+
<div className="lg:hidden space-y-3">
456+
{loading
457+
? Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-32 bg-slate-100 rounded-2xl animate-pulse" />)
458+
: campaigns.map(c => {
459+
const meta = STATUS_META[c.status] || STATUS_META.DRAFT;
460+
return (
461+
<div key={c.id} className="bg-white border border-slate-200 rounded-2xl p-4 cursor-pointer hover:border-slate-300 transition-colors" onClick={() => setSelectedCampaign(c)}>
462+
<div className="flex items-start justify-between gap-3 mb-3">
463+
<div className="min-w-0">
464+
<p className="font-semibold text-slate-900 truncate">{c.name}</p>
465+
<p className="text-xs text-slate-400 mt-0.5">{c.type.replace('_', ' ')} · {new Date(c.createdAt).toLocaleDateString()}</p>
466+
</div>
467+
<span className="flex-shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold" style={{ background: meta.bg, color: meta.color }}>{meta.label}</span>
468+
</div>
469+
<div className="grid grid-cols-4 gap-2 mb-3 text-center bg-slate-50 rounded-xl py-2.5">
470+
<div><p className="text-[10px] text-slate-400 uppercase tracking-wide">Audience</p><p className="text-sm font-bold text-slate-700 mt-0.5">{c._count?.leads || 0}</p></div>
471+
<div><p className="text-[10px] text-slate-400 uppercase tracking-wide">Sent</p><p className="text-sm font-bold text-blue-600 mt-0.5">{c.stats?.sent || 0}</p></div>
472+
<div><p className="text-[10px] text-slate-400 uppercase tracking-wide">Opens</p><p className="text-sm font-bold text-violet-600 mt-0.5">{c.stats?.opens || 0}</p></div>
473+
<div><p className="text-[10px] text-slate-400 uppercase tracking-wide">Rate</p><p className="text-sm font-bold text-amber-600 mt-0.5">{c.stats?.openRate || 0}%</p></div>
474+
</div>
475+
<div className="flex flex-wrap gap-1.5" onClick={e => e.stopPropagation()}>
476+
{renderCampaignActions(c)}
477+
</div>
478+
</div>
479+
);
480+
})}
481+
</div>
482+
483+
{/* ── Desktop: table ── */}
484+
{!(!loading && campaigns.length === 0) && (
485+
<div className="hidden lg:block card overflow-hidden">
392486
<div className="overflow-x-auto">
393487
<table className="w-full text-left text-sm">
394488
<thead className="bg-slate-50/80 border-b border-slate-200 text-xs uppercase font-semibold text-slate-500">
@@ -409,17 +503,6 @@ export default function FlywheelCampaigns() {
409503
Array.from({ length: 5 }).map((_, i) => (
410504
<tr key={i}><td colSpan={9} className="px-5 py-4"><div className="skeleton h-4 rounded" style={{ width: `${50 + Math.random() * 40}%` }} /></td></tr>
411505
))
412-
) : campaigns.length === 0 ? (
413-
<tr>
414-
<td colSpan={9} className="px-6 py-16 text-center">
415-
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-50 text-emerald-300 mb-4 border border-emerald-100"><IconMail size={28} /></div>
416-
<h3 className="text-lg font-semibold text-slate-900 mb-1">No campaigns yet</h3>
417-
<p className="text-slate-500 text-sm mb-4">Create your first campaign to start engaging your audience.</p>
418-
<button onClick={() => { setWizardOpen(true); resetWizard(); }} className="px-4 py-2 text-white rounded-lg text-sm font-medium" style={{ background: brand.primaryColor }}>
419-
<IconPlus size={14} className="inline mr-1" /> Create Campaign
420-
</button>
421-
</td>
422-
</tr>
423506
) : campaigns.map(c => {
424507
const meta = STATUS_META[c.status] || STATUS_META.DRAFT;
425508
return (
@@ -437,28 +520,8 @@ export default function FlywheelCampaigns() {
437520
<td className="px-5 py-4 text-center font-semibold text-amber-600">{c.stats?.openRate || 0}%</td>
438521
<td className="px-5 py-4 text-center text-xs text-slate-400">{new Date(c.createdAt).toLocaleDateString()}</td>
439522
<td className="px-5 py-4 text-right" onClick={e => e.stopPropagation()}>
440-
<div className="flex justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
441-
{c.status === 'DRAFT' && (
442-
<button onClick={() => handleDispatch(c.id)} disabled={dispatching} className="px-3 py-1.5 bg-emerald-50 border border-emerald-200 text-emerald-700 rounded-md text-xs font-semibold hover:bg-emerald-100 transition-colors flex items-center gap-1">
443-
<IconSend size={12} /> Launch
444-
</button>
445-
)}
446-
{c.status === 'ACTIVE' && (
447-
<button onClick={() => handlePause(c.id, c.status)} disabled={mutating} className="px-3 py-1.5 bg-amber-50 border border-amber-200 text-amber-700 rounded-md text-xs font-semibold hover:bg-amber-100 transition-colors flex items-center gap-1">
448-
<IconPause size={12} /> Pause
449-
</button>
450-
)}
451-
{c.status === 'PAUSED' && (
452-
<button onClick={() => handlePause(c.id, c.status)} disabled={mutating} className="px-3 py-1.5 bg-emerald-50 border border-emerald-200 text-emerald-700 rounded-md text-xs font-semibold hover:bg-emerald-100 transition-colors flex items-center gap-1">
453-
<IconPlay size={12} /> Resume
454-
</button>
455-
)}
456-
<button onClick={() => setSelectedCampaign(c)} className="px-3 py-1.5 bg-white border border-slate-200 text-slate-600 rounded-md text-xs font-medium hover:text-blue-600 transition-colors flex items-center gap-1">
457-
<IconEye size={12} /> Details
458-
</button>
459-
<button onClick={() => handleDelete(c.id)} disabled={mutating} className="px-3 py-1.5 bg-red-50 border border-red-200 text-red-600 rounded-md text-xs font-medium hover:bg-red-100 transition-colors flex items-center gap-1">
460-
<IconX size={12} /> Delete
461-
</button>
523+
<div className="flex justify-end gap-1.5">
524+
{renderCampaignActions(c)}
462525
</div>
463526
</td>
464527
</tr>
@@ -468,6 +531,7 @@ export default function FlywheelCampaigns() {
468531
</table>
469532
</div>
470533
</div>
534+
)}
471535
</div>
472536

473537
{/* ── CAMPAIGN WIZARD (Multi-step) ── */}
@@ -563,7 +627,7 @@ export default function FlywheelCampaigns() {
563627
{leadSearchResults.map(l => (
564628
<button key={l.id} type="button" onClick={() => { setPickedLeads(p => [...p, l]); setLeadSearch(''); setLeadSearchResults([]); }}
565629
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-slate-50 transition-colors">
566-
<div className="w-7 h-7 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center text-xs font-bold flex-shrink-0">
630+
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" style={{ background: brand.primaryLight, color: brand.primaryColor }}>
567631
{l.name[0]?.toUpperCase()}
568632
</div>
569633
<div>
@@ -578,7 +642,7 @@ export default function FlywheelCampaigns() {
578642
<div className="space-y-1.5">
579643
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Selected ({pickedLeads.length})</p>
580644
{pickedLeads.map(l => (
581-
<div key={l.id} className="flex items-center justify-between px-3 py-2 bg-emerald-50 border border-emerald-100 rounded-lg">
645+
<div key={l.id} className="flex items-center justify-between px-3 py-2 bg-[#FBF8F3] border border-[#F0EAE0] rounded-lg">
582646
<div>
583647
<span className="text-sm font-semibold text-slate-800">{l.name}</span>
584648
<span className="text-xs text-slate-400 ml-2">{l.email}</span>

0 commit comments

Comments
 (0)