mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(broadcasts) B: list rates, detail funnel + CSV, save-as-draft
UI polish for the Broadcasts feature. Stacked on PR A (correctness). List page /broadcasts - Replaces raw Sent/Delivered/Read/Failed columns with Delivery Rate + Read Rate cells (percent + inline mini progress bar). Raw counts still available on the detail page. - Sending badge gets a live-looking pulsing dot. - Indeterminate top-of-page progress bar appears while any broadcast has status='sending'. Page polls the broadcasts table every 5 s while that condition holds, and stops polling otherwise. - Mobile: table wrapped in overflow-x-auto. Detail page /broadcasts/[id] - Adds a Replied stat card (was missing). Final grid: Total / Sent / Delivered / Read / Replied / Failed — 6 cards on desktop, 2 on mobile. Color spectrum matches the spec's funnel gradient. - Pure-CSS horizontal funnel chart: Sent → Delivered → Read → Replied with decreasing widths in emerald → teal → blue → indigo. Each bar shows its absolute value and percent of Sent. - Status filter dropdown above the recipients table. Filters client-side; header reflects filtered / total. - "Export CSV" downloads all recipients with status + every milestone timestamp + error_message. RFC-4180 quoting. - Recipients table gains Delivered / Read timestamp columns so the CSV columns are also visible at a glance. Wizard Step 4 - Save as Draft button next to Send Broadcast. Persists a broadcasts row with status='draft' and zero recipients — enough for the user to recognize the draft in the list. Full resume-into-wizard is a future follow-up. - Confirmation modal before send was already in place; kept as-is. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d5c5f0a20
commit
244d04ca93
@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Broadcast, BroadcastRecipient } from '@/types';
|
import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -13,6 +13,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Loader2,
|
Loader2,
|
||||||
@ -21,6 +27,10 @@ import {
|
|||||||
CheckCheck,
|
CheckCheck,
|
||||||
Eye,
|
Eye,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
MessageCircle,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getBroadcastStatus,
|
getBroadcastStatus,
|
||||||
@ -51,6 +61,84 @@ function StatCard({ label, value, total, icon, color }: StatCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FunnelStep {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-CSS funnel chart: decreasing-width rounded bars.
|
||||||
|
* Width is relative to the largest step (typically Sent) so we
|
||||||
|
* always render a full bar at the top and proportional tails.
|
||||||
|
*/
|
||||||
|
function FunnelChart({ steps }: { steps: FunnelStep[] }) {
|
||||||
|
const max = Math.max(...steps.map((s) => s.value), 1);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-white">Funnel</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{steps.map((step) => {
|
||||||
|
const pctOfMax = Math.max(5, Math.round((step.value / max) * 100));
|
||||||
|
const pctOfSent =
|
||||||
|
steps[0].value > 0
|
||||||
|
? Math.round((step.value / steps[0].value) * 100)
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div key={step.label} className="flex items-center gap-3">
|
||||||
|
<span className="w-20 shrink-0 text-xs text-slate-400">
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
<div className="relative h-7 flex-1 rounded-full bg-slate-800">
|
||||||
|
<div
|
||||||
|
className={`h-7 rounded-full ${step.color} transition-[width] duration-500`}
|
||||||
|
style={{ width: `${pctOfMax}%` }}
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-white">
|
||||||
|
{step.value.toLocaleString()}
|
||||||
|
<span className="ml-2 text-slate-300/80">
|
||||||
|
({pctOfSent}%)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECIPIENT_STATUSES: readonly RecipientStatus[] = [
|
||||||
|
'pending',
|
||||||
|
'sent',
|
||||||
|
'delivered',
|
||||||
|
'read',
|
||||||
|
'replied',
|
||||||
|
'failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV export helper — RFC 4180 quoting. Quote every field so
|
||||||
|
* commas/newlines/quotes round-trip cleanly.
|
||||||
|
*/
|
||||||
|
function toCsv(rows: string[][]): string {
|
||||||
|
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||||
|
return rows.map((r) => r.map(escape).join(',')).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BroadcastDetailPage() {
|
export default function BroadcastDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -60,6 +148,9 @@ export default function BroadcastDetailPage() {
|
|||||||
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
|
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>(
|
||||||
|
'all',
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@ -93,6 +184,41 @@ export default function BroadcastDetailPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [broadcastId]);
|
}, [broadcastId]);
|
||||||
|
|
||||||
|
const filteredRecipients = useMemo(
|
||||||
|
() =>
|
||||||
|
statusFilter === 'all'
|
||||||
|
? recipients
|
||||||
|
: recipients.filter((r) => r.status === statusFilter),
|
||||||
|
[recipients, statusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
if (!broadcast) return;
|
||||||
|
const header = [
|
||||||
|
'Contact',
|
||||||
|
'Phone',
|
||||||
|
'Status',
|
||||||
|
'Sent At',
|
||||||
|
'Delivered At',
|
||||||
|
'Read At',
|
||||||
|
'Replied At',
|
||||||
|
'Error',
|
||||||
|
];
|
||||||
|
const rows = recipients.map((r) => [
|
||||||
|
r.contact?.name ?? '',
|
||||||
|
r.contact?.phone ?? '',
|
||||||
|
r.status,
|
||||||
|
r.sent_at ?? '',
|
||||||
|
r.delivered_at ?? '',
|
||||||
|
r.read_at ?? '',
|
||||||
|
r.replied_at ?? '',
|
||||||
|
r.error_message ?? '',
|
||||||
|
]);
|
||||||
|
const csv = toCsv([header, ...rows]);
|
||||||
|
const safeName = broadcast.name.replace(/[^a-z0-9-_]+/gi, '-').toLowerCase();
|
||||||
|
downloadBlob(`broadcast-${safeName}-${broadcastId.slice(0, 8)}.csv`, csv);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
@ -114,6 +240,13 @@ export default function BroadcastDetailPage() {
|
|||||||
|
|
||||||
const status = getBroadcastStatus(broadcast.status);
|
const status = getBroadcastStatus(broadcast.status);
|
||||||
|
|
||||||
|
const funnelSteps: FunnelStep[] = [
|
||||||
|
{ label: 'Sent', value: broadcast.sent_count, color: 'bg-emerald-500' },
|
||||||
|
{ label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' },
|
||||||
|
{ label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' },
|
||||||
|
{ label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -139,14 +272,16 @@ export default function BroadcastDetailPage() {
|
|||||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
|
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
|
||||||
<span>Template: {broadcast.template_name}</span>
|
<span>Template: {broadcast.template_name}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>Created {new Date(broadcast.created_at).toLocaleDateString()}</span>
|
<span>
|
||||||
|
Created {new Date(broadcast.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Recipients"
|
label="Total Recipients"
|
||||||
value={broadcast.total_recipients}
|
value={broadcast.total_recipients}
|
||||||
@ -159,21 +294,28 @@ export default function BroadcastDetailPage() {
|
|||||||
value={broadcast.sent_count}
|
value={broadcast.sent_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<Send className="h-4 w-4" />}
|
icon={<Send className="h-4 w-4" />}
|
||||||
color="bg-blue-500/10 text-blue-400"
|
color="bg-emerald-500/10 text-emerald-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Delivered"
|
label="Delivered"
|
||||||
value={broadcast.delivered_count}
|
value={broadcast.delivered_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<CheckCheck className="h-4 w-4" />}
|
icon={<CheckCheck className="h-4 w-4" />}
|
||||||
color="bg-emerald-500/10 text-emerald-400"
|
color="bg-teal-500/10 text-teal-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Read"
|
label="Read"
|
||||||
value={broadcast.read_count}
|
value={broadcast.read_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<Eye className="h-4 w-4" />}
|
icon={<Eye className="h-4 w-4" />}
|
||||||
color="bg-emerald-500/10 text-emerald-300"
|
color="bg-blue-500/10 text-blue-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Replied"
|
||||||
|
value={broadcast.replied_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
icon={<MessageCircle className="h-4 w-4" />}
|
||||||
|
color="bg-indigo-500/10 text-indigo-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Failed"
|
label="Failed"
|
||||||
@ -184,60 +326,134 @@ export default function BroadcastDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FunnelChart steps={funnelSteps} />
|
||||||
|
|
||||||
{/* Recipients Table */}
|
{/* Recipients Table */}
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||||
<div className="border-b border-slate-800 px-4 py-3">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 px-4 py-3">
|
||||||
<h2 className="text-sm font-medium text-white">
|
<h2 className="text-sm font-medium text-white">
|
||||||
Recipients ({recipients.length})
|
Recipients ({filteredRecipients.length}
|
||||||
|
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
|
||||||
</h2>
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
{statusFilter === 'all'
|
||||||
|
? 'All statuses'
|
||||||
|
: getRecipientStatus(statusFilter).label}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="border-slate-700 bg-slate-900">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={
|
||||||
|
statusFilter === 'all' ? 'text-emerald-400' : 'text-slate-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
All statuses
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{RECIPIENT_STATUSES.map((s) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={s}
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
className={
|
||||||
|
statusFilter === s
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-slate-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getRecipientStatus(s).label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={recipients.length === 0}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{recipients.length === 0 ? (
|
|
||||||
|
{filteredRecipients.length === 0 ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<p className="text-sm text-slate-400">No recipients found.</p>
|
<p className="text-sm text-slate-400">
|
||||||
|
{recipients.length === 0
|
||||||
|
? 'No recipients found.'
|
||||||
|
: 'No recipients match this filter.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<div className="overflow-x-auto">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
<TableHeader>
|
||||||
<TableHead className="text-slate-400">Contact</TableHead>
|
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||||
<TableHead className="text-slate-400">Phone</TableHead>
|
<TableHead className="text-slate-400">Contact</TableHead>
|
||||||
<TableHead className="text-slate-400">Status</TableHead>
|
<TableHead className="text-slate-400">Phone</TableHead>
|
||||||
<TableHead className="text-slate-400">Sent At</TableHead>
|
<TableHead className="text-slate-400">Status</TableHead>
|
||||||
<TableHead className="text-slate-400">Error</TableHead>
|
<TableHead className="text-slate-400">Sent</TableHead>
|
||||||
</TableRow>
|
<TableHead className="text-slate-400">Delivered</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="text-slate-400">Read</TableHead>
|
||||||
<TableBody>
|
<TableHead className="text-slate-400">Error</TableHead>
|
||||||
{recipients.map((recipient) => {
|
</TableRow>
|
||||||
const rStatus = getRecipientStatus(recipient.status);
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
return (
|
{filteredRecipients.map((recipient) => {
|
||||||
<TableRow key={recipient.id} className="border-slate-800">
|
const rStatus = getRecipientStatus(recipient.status);
|
||||||
<TableCell className="font-medium text-white">
|
return (
|
||||||
{recipient.contact?.name ?? 'Unknown'}
|
<TableRow key={recipient.id} className="border-slate-800">
|
||||||
</TableCell>
|
<TableCell className="font-medium text-white">
|
||||||
<TableCell className="text-slate-300">
|
{recipient.contact?.name ?? 'Unknown'}
|
||||||
{recipient.contact?.phone ?? '-'}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-slate-300">
|
||||||
<TableCell>
|
{recipient.contact?.phone ?? '-'}
|
||||||
<span
|
</TableCell>
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
|
<TableCell>
|
||||||
>
|
<span
|
||||||
{rStatus.label}
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
|
||||||
</span>
|
>
|
||||||
</TableCell>
|
{rStatus.label}
|
||||||
<TableCell className="text-slate-400">
|
</span>
|
||||||
{recipient.sent_at
|
</TableCell>
|
||||||
? new Date(recipient.sent_at).toLocaleString()
|
<TableCell className="text-slate-400">
|
||||||
: '-'}
|
{recipient.sent_at
|
||||||
</TableCell>
|
? new Date(recipient.sent_at).toLocaleString()
|
||||||
<TableCell className="max-w-xs truncate text-xs text-red-400">
|
: '-'}
|
||||||
{recipient.error_message ?? '-'}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-slate-400">
|
||||||
</TableRow>
|
{recipient.delivered_at
|
||||||
);
|
? new Date(recipient.delivered_at).toLocaleString()
|
||||||
})}
|
: '-'}
|
||||||
</TableBody>
|
</TableCell>
|
||||||
</Table>
|
<TableCell className="text-slate-400">
|
||||||
|
{recipient.read_at
|
||||||
|
? new Date(recipient.read_at).toLocaleString()
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs truncate text-xs text-red-400">
|
||||||
|
{recipient.error_message ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { MessageTemplate } from '@/types';
|
import { MessageTemplate } from '@/types';
|
||||||
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
||||||
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
|
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
|
||||||
@ -53,6 +55,57 @@ export default function NewBroadcastPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a draft broadcast row — no recipients, no sending. The user
|
||||||
|
* can revisit it via the list page to finish the flow later. We
|
||||||
|
* don't persist the in-progress audience/variable config here
|
||||||
|
* because the current schema doesn't carry it past `audience_filter`
|
||||||
|
* and `template_variables`; those are enough for the user to
|
||||||
|
* recognize the draft but not to exactly round-trip into the wizard.
|
||||||
|
* A full resume-draft UX is a future polish.
|
||||||
|
*/
|
||||||
|
async function handleSaveDraft() {
|
||||||
|
if (!template || !name.trim()) {
|
||||||
|
toast.error('Give the broadcast a name before saving a draft.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const supabase = createClient();
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
const user = session?.user;
|
||||||
|
if (!user) {
|
||||||
|
toast.error('Not signed in.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from('broadcasts').insert({
|
||||||
|
user_id: user.id,
|
||||||
|
name: name.trim(),
|
||||||
|
template_name: template.name,
|
||||||
|
template_language: template.language ?? 'en_US',
|
||||||
|
template_variables: variables,
|
||||||
|
audience_filter: {
|
||||||
|
type: audience.type,
|
||||||
|
tagIds: audience.tagIds,
|
||||||
|
},
|
||||||
|
status: 'draft',
|
||||||
|
total_recipients: 0,
|
||||||
|
sent_count: 0,
|
||||||
|
delivered_count: 0,
|
||||||
|
read_count: 0,
|
||||||
|
replied_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(`Failed to save draft: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Draft saved');
|
||||||
|
router.push('/broadcasts');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-8">
|
<div className="mx-auto max-w-3xl space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -144,6 +197,7 @@ export default function NewBroadcastPage() {
|
|||||||
template={template}
|
template={template}
|
||||||
audience={audience}
|
audience={audience}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
onSaveDraft={handleSaveDraft}
|
||||||
onBack={() => setCurrentStep(2)}
|
onBack={() => setCurrentStep(2)}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Broadcast } from '@/types';
|
import { Broadcast } from '@/types';
|
||||||
@ -16,33 +16,95 @@ import {
|
|||||||
import { Radio, Plus, Loader2 } from 'lucide-react';
|
import { Radio, Plus, Loader2 } from 'lucide-react';
|
||||||
import { getBroadcastStatus } from '@/lib/broadcast-status';
|
import { getBroadcastStatus } from '@/lib/broadcast-status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll cadence while any broadcast is sending. Kept modest so we don't
|
||||||
|
* beat on Supabase — the aggregate trigger in migration 003 keeps
|
||||||
|
* counts consistent; we just need to surface the freshest snapshot.
|
||||||
|
*/
|
||||||
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
function percent(numerator: number, denominator: number): number {
|
||||||
|
if (!denominator) return 0;
|
||||||
|
return Math.round((numerator / denominator) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RateCell({
|
||||||
|
value,
|
||||||
|
total,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
/** Tailwind bg class for the fill, e.g. "bg-emerald-500" */
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
const pct = percent(value, total);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-10 text-right text-xs tabular-nums text-slate-300">
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-slate-800">
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full ${color}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BroadcastsPage() {
|
export default function BroadcastsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Used to kick off polling only while something is actively sending.
|
||||||
async function fetchBroadcasts() {
|
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
try {
|
|
||||||
const supabase = createClient();
|
|
||||||
const { data, error: fetchError } = await supabase
|
|
||||||
.from('broadcasts')
|
|
||||||
.select('*')
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (fetchError) throw fetchError;
|
async function fetchBroadcasts() {
|
||||||
setBroadcasts(data ?? []);
|
try {
|
||||||
} catch (err) {
|
const supabase = createClient();
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load broadcasts');
|
const { data, error: fetchError } = await supabase
|
||||||
} finally {
|
.from('broadcasts')
|
||||||
setLoading(false);
|
.select('*')
|
||||||
}
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
setBroadcasts(data ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load broadcasts');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchBroadcasts();
|
fetchBroadcasts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const anySending = useMemo(
|
||||||
|
() => broadcasts.some((b) => b.status === 'sending'),
|
||||||
|
[broadcasts],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Start / stop the poller based on whether any row is sending.
|
||||||
|
if (anySending && !pollTimer.current) {
|
||||||
|
pollTimer.current = setInterval(fetchBroadcasts, POLL_INTERVAL_MS);
|
||||||
|
} else if (!anySending && pollTimer.current) {
|
||||||
|
clearInterval(pollTimer.current);
|
||||||
|
pollTimer.current = null;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (pollTimer.current) {
|
||||||
|
clearInterval(pollTimer.current);
|
||||||
|
pollTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [anySending]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
@ -64,6 +126,34 @@ export default function BroadcastsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Top indeterminate progress bar: only visible while a broadcast
|
||||||
|
is mid-send. Pure CSS animation so no extra deps. */}
|
||||||
|
{anySending && (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Broadcast in progress"
|
||||||
|
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-slate-800"
|
||||||
|
>
|
||||||
|
<div className="broadcast-indeterminate-bar h-0.5 bg-emerald-500" />
|
||||||
|
<style jsx>{`
|
||||||
|
.broadcast-indeterminate-bar {
|
||||||
|
width: 33%;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: broadcast-slide 1.6s cubic-bezier(0.4, 0, 0.2, 1)
|
||||||
|
infinite;
|
||||||
|
}
|
||||||
|
@keyframes broadcast-slide {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(400%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
|
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
|
||||||
@ -96,19 +186,19 @@ export default function BroadcastsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
<div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-900">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||||
<TableHead className="text-slate-400">Name</TableHead>
|
<TableHead className="text-slate-400">Name</TableHead>
|
||||||
<TableHead className="text-slate-400">Template</TableHead>
|
<TableHead className="text-slate-400">Template</TableHead>
|
||||||
|
<TableHead className="text-slate-400 text-right">
|
||||||
|
Recipients
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Delivery</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Read</TableHead>
|
||||||
<TableHead className="text-slate-400">Status</TableHead>
|
<TableHead className="text-slate-400">Status</TableHead>
|
||||||
<TableHead className="text-slate-400 text-right">Recipients</TableHead>
|
<TableHead className="text-slate-400">Date</TableHead>
|
||||||
<TableHead className="text-slate-400 text-right">Sent</TableHead>
|
|
||||||
<TableHead className="text-slate-400 text-right">Delivered</TableHead>
|
|
||||||
<TableHead className="text-slate-400 text-right">Read</TableHead>
|
|
||||||
<TableHead className="text-slate-400 text-right">Failed</TableHead>
|
|
||||||
<TableHead className="text-slate-400">Created</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -126,27 +216,35 @@ export default function BroadcastsPage() {
|
|||||||
<TableCell className="text-slate-300">
|
<TableCell className="text-slate-300">
|
||||||
{broadcast.template_name}
|
{broadcast.template_name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-right text-slate-300 tabular-nums">
|
||||||
<span
|
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
|
||||||
>
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-slate-300">
|
|
||||||
{broadcast.total_recipients}
|
{broadcast.total_recipients}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-slate-300">
|
<TableCell>
|
||||||
{broadcast.sent_count}
|
<RateCell
|
||||||
|
value={broadcast.delivered_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
color="bg-emerald-500"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-slate-300">
|
<TableCell>
|
||||||
{broadcast.delivered_count}
|
<RateCell
|
||||||
|
value={broadcast.read_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
color="bg-blue-500"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-slate-300">
|
<TableCell>
|
||||||
{broadcast.read_count}
|
<span
|
||||||
</TableCell>
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||||
<TableCell className="text-right text-slate-300">
|
>
|
||||||
{broadcast.failed_count}
|
{status.pulse && (
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-slate-400">
|
<TableCell className="text-slate-400">
|
||||||
{new Date(broadcast.created_at).toLocaleDateString()}
|
{new Date(broadcast.created_at).toLocaleDateString()}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ArrowLeft, Send, Clock, Loader2, Users, Lock } from 'lucide-react';
|
import { ArrowLeft, Send, Clock, Loader2, Users, Lock, Save } from 'lucide-react';
|
||||||
|
|
||||||
interface AudienceConfig {
|
interface AudienceConfig {
|
||||||
type: string;
|
type: string;
|
||||||
@ -28,6 +28,7 @@ interface Step4Props {
|
|||||||
template: MessageTemplate;
|
template: MessageTemplate;
|
||||||
audience: AudienceConfig;
|
audience: AudienceConfig;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
|
onSaveDraft?: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
progress: number;
|
progress: number;
|
||||||
@ -39,6 +40,7 @@ export function Step4ScheduleSend({
|
|||||||
template,
|
template,
|
||||||
audience,
|
audience,
|
||||||
onSend,
|
onSend,
|
||||||
|
onSaveDraft,
|
||||||
onBack,
|
onBack,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
progress,
|
progress,
|
||||||
@ -195,7 +197,7 @@ export function Step4ScheduleSend({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-800 pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
@ -206,7 +208,20 @@ export function Step4ScheduleSend({
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
<div className="flex items-center gap-2">
|
||||||
|
{onSaveDraft && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
disabled={!name.trim() || isProcessing}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Save as Draft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
@ -250,6 +265,7 @@ export function Step4ScheduleSend({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user