mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
289 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|