import { Loader2 } from 'lucide-react' import { useEffect, useState, type FormEvent } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppBroadcastJob, type WhatsAppTemplateRow } from '../../lib/api' const COUNTRIES = [ { code: '91', label: 'India' }, { code: '1', label: 'United States' }, { code: '44', label: 'United Kingdom' }, { code: '971', label: 'United Arab Emirates' }, { code: '65', label: 'Singapore' }, ] const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck' const TEAM_TEMPLATE_BODY = "Team, the broadcast desk is on air. This is the first note Urmi is sending through Meta's marketing line, and it is written for the people building it. Read it on your phone, then bring it to standup so we can close the test." type BroadcastResult = { template_name: string sent: number failed: number results: { to: string; status: string; error?: string; message_id?: string | null }[] } type CreatedTemplate = { name: string status: string } export function WhatsAppBroadcastPage() { const [params] = useSearchParams() const [accounts, setAccounts] = useState([]) const [accountId, setAccountId] = useState('') const [templates, setTemplates] = useState([]) const [managed, setManaged] = useState([]) const [templatesReady, setTemplatesReady] = useState(false) const [useSample, setUseSample] = useState(true) const [templateName, setTemplateName] = useState(TEAM_TEMPLATE_NAME) const [templateLanguage, setTemplateLanguage] = useState('en') const [draftName, setDraftName] = useState(TEAM_TEMPLATE_NAME) const [draftBody, setDraftBody] = useState(TEAM_TEMPLATE_BODY) const [creating, setCreating] = useState(false) const [created, setCreated] = useState(null) const [templateTick, setTemplateTick] = useState(0) const [recipients, setRecipients] = useState('') const [countryCode, setCountryCode] = useState('91') const [csvFile, setCsvFile] = useState(null) const [csvKey, setCsvKey] = useState(0) const [csvBusy, setCsvBusy] = useState(false) const [jobs, setJobs] = useState([]) const [selectedJobId, setSelectedJobId] = useState('') const [jobTick, setJobTick] = useState(0) const [bodyValues, setBodyValues] = useState('') const [strict, setStrict] = useState(true) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const [error, setError] = useState('') const [result, setResult] = useState(null) useEffect(() => { whatsappApi.accounts() .then((rows) => { const ready = rows.filter((row) => row.messaging_status === 'active' && row.phones.some((phone) => phone.status === 'connected')) setAccounts(ready) if (ready[0]) setAccountId(ready[0].id) }) .catch((reason) => setError(reason instanceof Error ? reason.message : 'Could not load WhatsApp accounts')) .finally(() => setLoading(false)) }, []) useEffect(() => { if (!accountId) return setTemplatesReady(false) whatsappApi.templates(accountId) .then((payload) => { const marketing = payload.templates.filter((item) => (item.category || '').toUpperCase() === 'MARKETING') setTemplates(marketing) setTemplatesReady(payload.available) const requested = params.get('template') const requestedLanguage = params.get('language') const approved = marketing.find((item) => item.name === requested) || marketing.find((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name) if (requested) setTemplateName(requested) if (requestedLanguage) setTemplateLanguage(requestedLanguage) if (approved?.name && !requested) { setTemplateName(approved.name) setTemplateLanguage(approved.language || 'en') } }) .catch(() => { setTemplates([]) setTemplatesReady(false) }) waTemplateApi.list(accountId) .then((payload) => setManaged(payload.templates)) .catch(() => setManaged([])) }, [accountId, templateTick]) useEffect(() => { if (!accountId) return let cancelled = false let timer = 0 async function load() { try { const rows = await whatsappApi.broadcastJobs(accountId) if (cancelled) return setJobs(rows) if (rows.some((job) => job.status === 'processing')) { timer = window.setTimeout(() => { void load() }, 3000) } } catch { if (!cancelled) setJobs([]) } } void load() return () => { cancelled = true window.clearTimeout(timer) } }, [accountId, jobTick]) async function createTemplate(event: FormEvent) { event.preventDefault() setCreating(true) setError('') setCreated(null) try { const payload = await whatsappApi.createMarketingTemplate(accountId, { name: draftName.trim(), language: 'en', body: draftBody.trim(), }) setCreated(payload) setTemplateName(payload.name) setTemplateLanguage(payload.language || 'en') setTemplateTick((value) => value + 1) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Meta did not accept this template') } finally { setCreating(false) } } async function submit(event: FormEvent) { event.preventDefault() setBusy(true) setError('') setResult(null) const numbers = recipients.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) const parameters = bodyValues.split('\n').map((item) => item.trim()).filter(Boolean) if (!numbers.length) { setError('Enter at least one number, or start a CSV broadcast.') setBusy(false) return } if (mediaHeader && !savedSample) { setError(`Add the sample ${headerLabel} on the template page. Meta rejects this send without it.`) setBusy(false) return } if (mediaHeader && !useSample) { setError(`Turn on the saved sample ${headerLabel}. This template’s header is a ${headerLabel}, and Meta rejects the send without it.`) setBusy(false) return } try { const payload = await whatsappApi.sendBroadcast(accountId, { template_name: templateName.trim(), template_language: templateLanguage.trim() || 'en', recipients: numbers, body_parameters: parameters, product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK', use_sample_header: Boolean(mediaHeader && useSample && savedSample), }) setResult(payload) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Meta did not accept this broadcast') } finally { setBusy(false) } } const approved = templates.filter((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name) const pending = templates.filter((item) => (item.status || '').toUpperCase() !== 'APPROVED' && item.name) const selectedTemplate = managed.find((item) => item.name === templateName && item.language === templateLanguage) || managed.find((item) => item.name === templateName) const mediaHeader = ['IMAGE', 'VIDEO', 'DOCUMENT'].includes(selectedTemplate?.header_format || '') const savedSample = Boolean(selectedTemplate?.sample_stored) const headerLabel = selectedTemplate?.header_format === 'VIDEO' ? 'video' : selectedTemplate?.header_format === 'DOCUMENT' ? 'document' : 'image' function downloadSampleCsv() { const blob = new Blob(['phone_number\n'], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = 'whatsapp-broadcast-sample.csv' link.click() URL.revokeObjectURL(url) } const selectedJob = jobs.find((job) => job.id === selectedJobId) || null async function startCsv() { if (!csvFile) { setError('Choose a CSV with a phone_number column.') return } if (!templateName.trim()) { setError('Choose a marketing template.') return } if (mediaHeader && !savedSample) { setError(`Add the sample ${headerLabel} on the template page. Meta rejects this send without it.`) return } if (mediaHeader && !useSample) { setError(`Turn on the saved sample ${headerLabel}. This template’s header is a ${headerLabel}, and Meta rejects the send without it.`) return } setCsvBusy(true) setError('') try { const job = await whatsappApi.uploadBroadcastCsv(accountId, csvFile, { country_code: countryCode, template_name: templateName.trim(), template_language: templateLanguage.trim() || 'en', product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK', use_sample_header: Boolean(mediaHeader && useSample && savedSample), body_parameters: bodyValues, }) setJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]) setSelectedJobId(job.id) setCsvFile(null) setCsvKey((value) => value + 1) setJobTick((value) => value + 1) } catch (reason) { setError(reason instanceof Error ? reason.message : 'The CSV broadcast could not be started') } finally { setCsvBusy(false) } } return (
Messages

WhatsApp broadcast

Sends a marketing template through Meta’s marketing endpoint. Chat replies stay on the WhatsApp page.

{!loading && accounts.length > 0 && (

Create the template here

Urmi submits this to Meta as a Marketing template on the connected account. Meta still has to approve it before a broadcast can send. That usually takes a few minutes.