wacrm/src/app/[locale]/(dashboard)/broadcasts/page.tsx
Arnas Donauskas 8a8758a487
feat(i18n): scaffold next-intl with [locale] routing + extract auth/layout
Introduces an internationalization layer so wacrm can ship in multiple
languages — initially en (canonical), es, lt (untranslated mirrors of
en, ready for translators).

Foundation
- next-intl ^4.13 (Next 16 + React 19 compatible)
- src/i18n/{routing,navigation,request}.ts — locale config, locale-aware
  Link/router/redirect, server-side message loader
- next.config.ts wired with `createNextIntlPlugin`
- src/app/* moved under src/app/[locale]/ (excluding /api and globals.css)
- [locale]/layout.tsx is now the root layout: <html lang={locale}>,
  hasLocale guard, setRequestLocale, NextIntlClientProvider,
  generateStaticParams for build-time prerendering of all locales
- middleware.ts composes next-intl's createMiddleware with the existing
  Supabase auth check: intl middleware runs first to handle locale
  prefix; auth redirects retain the active locale (e.g. signed-out user
  on /es/dashboard → /es/login); intl cookies (NEXT_LOCALE) are carried
  onto auth-issued responses so the sticky preference survives

Strings extracted (~80 keys across en/es/lt.json)
- common, nav (sidebar + header), roles (Owner/Admin/Agent/Viewer chips)
- auth.login, auth.signup, auth.forgotPassword (all forms + success states)
- join (all four UI states: loading, fail, accept, sign-up CTA + conflict modal)
- localeSwitcher

UI
- New `<LocaleSwitcher />` in the header — globe icon + locale dropdown
  using next-intl's useLocale + locale-aware useRouter

Out of scope for this PR (intentional — keeps diff reviewable)
- Dashboard feature pages (dashboard, inbox, contacts, pipelines,
  broadcasts, automations, flows, settings) and their components/
  namespaces still have hardcoded English; they continue to render
  fine because next/link redirects through the locale middleware
- Shared UI a11y strings (dialog close, etc.)
- Translating es.json / lt.json — they currently mirror en.json so the
  app runs in any locale without crashes (English fallback)
- Renaming middleware.ts → proxy.ts per Next 16 deprecation

Verification
- npm run build → 62 static pages prerendered across en/es/lt
- npm run typecheck → clean
- npm run test → 366 passing
- npm run lint → 0 errors (18 pre-existing warnings untouched)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:17:49 +03:00

289 lines
10 KiB
TypeScript

'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Broadcast } from '@/types';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Radio, Plus, Loader2 } from 'lucide-react';
import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button';
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-primary" */
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 canCreate = useCan('send-messages');
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 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();
const { data, error: fetchError } = await supabase
.from('broadcasts')
.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();
}, []);
const anySending = useMemo(
() => broadcasts.some((b) => b.status === 'sending'),
[broadcasts],
);
useEffect(() => {
function startPolling() {
if (pollTimer.current) return;
pollTimer.current = setInterval(fetchBroadcasts, POLL_INTERVAL_MS);
}
function stopPolling() {
if (!pollTimer.current) return;
clearInterval(pollTimer.current);
pollTimer.current = null;
}
// Pause polling while the tab is hidden — keeps Supabase cold when
// the user is away, and ensures a fresh fetch the moment they
// refocus so they don't see stale data on return.
function handleVisibilityChange() {
if (!anySending) return;
if (document.visibilityState === 'hidden') {
stopPolling();
} else {
fetchBroadcasts();
startPolling();
}
}
if (anySending && document.visibilityState === 'visible') {
startPolling();
} else {
stopPolling();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [anySending]);
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
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-primary" />
<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>
<p className="mt-1 text-sm text-slate-400">
Send bulk messages to your contacts using approved templates.
</p>
</div>
<GatedButton
canAct={canCreate}
gateReason="create broadcasts"
onClick={() => router.push('/broadcasts/new')}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
New Broadcast
</GatedButton>
</div>
{broadcasts.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900">
<Radio className="mb-3 h-10 w-10 text-slate-600" />
<p className="text-sm font-medium text-white">No broadcasts yet</p>
<p className="mt-1 text-xs text-slate-400">
Create your first broadcast to reach your contacts at scale.
</p>
<GatedButton
canAct={canCreate}
gateReason="create broadcasts"
onClick={() => router.push('/broadcasts/new')}
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
New Broadcast
</GatedButton>
</div>
) : (
<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="hidden text-slate-400 md:table-cell">Template</TableHead>
<TableHead className="hidden text-right text-slate-400 sm:table-cell">
Recipients
</TableHead>
<TableHead className="hidden text-slate-400 lg:table-cell">Delivery</TableHead>
<TableHead className="hidden text-slate-400 lg:table-cell">Read</TableHead>
<TableHead className="text-slate-400">Status</TableHead>
<TableHead className="hidden text-slate-400 sm:table-cell">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{broadcasts.map((broadcast) => {
const status = getBroadcastStatus(broadcast.status);
return (
<TableRow
key={broadcast.id}
className="cursor-pointer border-slate-800 hover:bg-slate-800/50"
onClick={() => router.push(`/broadcasts/${broadcast.id}`)}
>
<TableCell className="font-medium text-white">
{broadcast.name}
</TableCell>
<TableCell className="hidden text-slate-300 md:table-cell">
{broadcast.template_name}
</TableCell>
<TableCell className="hidden text-right text-slate-300 tabular-nums sm:table-cell">
{broadcast.total_recipients}
</TableCell>
<TableCell className="hidden lg:table-cell">
<RateCell
value={broadcast.delivered_count}
total={broadcast.total_recipients}
color="bg-primary"
/>
</TableCell>
<TableCell className="hidden lg:table-cell">
<RateCell
value={broadcast.read_count}
total={broadcast.total_recipients}
color="bg-blue-500"
/>
</TableCell>
<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="hidden text-slate-400 sm:table-cell">
{new Date(broadcast.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
);
}