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';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Broadcast, BroadcastRecipient } from '@/types';
|
||||
import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
@ -13,6 +13,12 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
@ -21,6 +27,10 @@ import {
|
||||
CheckCheck,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
MessageCircle,
|
||||
Filter,
|
||||
Download,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
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() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@ -60,6 +148,9 @@ export default function BroadcastDetailPage() {
|
||||
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>(
|
||||
'all',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
@ -93,6 +184,41 @@ export default function BroadcastDetailPage() {
|
||||
fetchData();
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@ -114,6 +240,13 @@ export default function BroadcastDetailPage() {
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@ -139,14 +272,16 @@ export default function BroadcastDetailPage() {
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
|
||||
<span>Template: {broadcast.template_name}</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>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatCard
|
||||
label="Total Recipients"
|
||||
value={broadcast.total_recipients}
|
||||
@ -159,21 +294,28 @@ export default function BroadcastDetailPage() {
|
||||
value={broadcast.sent_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
color="bg-blue-500/10 text-blue-400"
|
||||
color="bg-emerald-500/10 text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Delivered"
|
||||
value={broadcast.delivered_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<CheckCheck className="h-4 w-4" />}
|
||||
color="bg-emerald-500/10 text-emerald-400"
|
||||
color="bg-teal-500/10 text-teal-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Read"
|
||||
value={broadcast.read_count}
|
||||
total={broadcast.total_recipients}
|
||||
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
|
||||
label="Failed"
|
||||
@ -184,32 +326,95 @@ export default function BroadcastDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FunnelChart steps={funnelSteps} />
|
||||
|
||||
{/* Recipients Table */}
|
||||
<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">
|
||||
Recipients ({recipients.length})
|
||||
Recipients ({filteredRecipients.length}
|
||||
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
|
||||
</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>
|
||||
{recipients.length === 0 ? (
|
||||
</div>
|
||||
|
||||
{filteredRecipients.length === 0 ? (
|
||||
<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 className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Contact</TableHead>
|
||||
<TableHead className="text-slate-400">Phone</TableHead>
|
||||
<TableHead className="text-slate-400">Status</TableHead>
|
||||
<TableHead className="text-slate-400">Sent At</TableHead>
|
||||
<TableHead className="text-slate-400">Sent</TableHead>
|
||||
<TableHead className="text-slate-400">Delivered</TableHead>
|
||||
<TableHead className="text-slate-400">Read</TableHead>
|
||||
<TableHead className="text-slate-400">Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recipients.map((recipient) => {
|
||||
{filteredRecipients.map((recipient) => {
|
||||
const rStatus = getRecipientStatus(recipient.status);
|
||||
|
||||
return (
|
||||
<TableRow key={recipient.id} className="border-slate-800">
|
||||
<TableCell className="font-medium text-white">
|
||||
@ -230,6 +435,16 @@ export default function BroadcastDetailPage() {
|
||||
? new Date(recipient.sent_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{recipient.delivered_at
|
||||
? new Date(recipient.delivered_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<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>
|
||||
@ -238,6 +453,7 @@ export default function BroadcastDetailPage() {
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { MessageTemplate } from '@/types';
|
||||
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
||||
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 (
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
{/* Header */}
|
||||
@ -144,6 +197,7 @@ export default function NewBroadcastPage() {
|
||||
template={template}
|
||||
audience={audience}
|
||||
onSend={handleSend}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onBack={() => setCurrentStep(2)}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Broadcast } from '@/types';
|
||||
@ -16,13 +16,53 @@ import {
|
||||
import { Radio, Plus, Loader2 } from 'lucide-react';
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Used to kick off polling only while something is actively sending.
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
async function fetchBroadcasts() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
@ -40,9 +80,31 @@ export default function BroadcastsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@ -64,6 +126,34 @@ export default function BroadcastsPage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
|
||||
@ -96,19 +186,19 @@ export default function BroadcastsPage() {
|
||||
</Button>
|
||||
</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>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Name</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 text-right">Recipients</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>
|
||||
<TableHead className="text-slate-400">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -126,27 +216,35 @@ export default function BroadcastsPage() {
|
||||
<TableCell className="text-slate-300">
|
||||
{broadcast.template_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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">
|
||||
<TableCell className="text-right text-slate-300 tabular-nums">
|
||||
{broadcast.total_recipients}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.sent_count}
|
||||
<TableCell>
|
||||
<RateCell
|
||||
value={broadcast.delivered_count}
|
||||
total={broadcast.total_recipients}
|
||||
color="bg-emerald-500"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.delivered_count}
|
||||
<TableCell>
|
||||
<RateCell
|
||||
value={broadcast.read_count}
|
||||
total={broadcast.total_recipients}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.read_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.failed_count}
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||
>
|
||||
{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 className="text-slate-400">
|
||||
{new Date(broadcast.created_at).toLocaleDateString()}
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 {
|
||||
type: string;
|
||||
@ -28,6 +28,7 @@ interface Step4Props {
|
||||
template: MessageTemplate;
|
||||
audience: AudienceConfig;
|
||||
onSend: () => void;
|
||||
onSaveDraft?: () => void;
|
||||
onBack: () => void;
|
||||
isProcessing: boolean;
|
||||
progress: number;
|
||||
@ -39,6 +40,7 @@ export function Step4ScheduleSend({
|
||||
template,
|
||||
audience,
|
||||
onSend,
|
||||
onSaveDraft,
|
||||
onBack,
|
||||
isProcessing,
|
||||
progress,
|
||||
@ -195,7 +197,7 @@ export function Step4ScheduleSend({
|
||||
</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
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
@ -206,6 +208,19 @@ export function Step4ScheduleSend({
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<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
|
||||
render={
|
||||
@ -252,5 +267,6 @@ export function Step4ScheduleSend({
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user