urmi-ai/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx
2026-10-15 02:00:01 +05:30

460 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<WhatsAppAccount[]>([])
const [accountId, setAccountId] = useState('')
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
const [managed, setManaged] = useState<WaManagedTemplate[]>([])
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<CreatedTemplate | null>(null)
const [templateTick, setTemplateTick] = useState(0)
const [recipients, setRecipients] = useState('')
const [countryCode, setCountryCode] = useState('91')
const [csvFile, setCsvFile] = useState<File | null>(null)
const [csvKey, setCsvKey] = useState(0)
const [csvBusy, setCsvBusy] = useState(false)
const [jobs, setJobs] = useState<WhatsAppBroadcastJob[]>([])
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<BroadcastResult | null>(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 (
<div className="wa-biz wa-token-page">
<div className="account-heading">
<span className="eyebrow">Messages</span>
<h1>WhatsApp broadcast</h1>
<p>Sends a marketing template through Meta’s marketing endpoint. Chat replies stay on the WhatsApp page.</p>
</div>
{!loading && accounts.length > 0 && (
<form className="wa-biz-card" onSubmit={createTemplate}>
<h2>Create the template here</h2>
<p>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.</p>
<div className="wa-token-form">
<label className="explorer-input">
<strong>Template name</strong>
<span>Lowercase letters, numbers, and underscores. Meta uses this name when you send.</span>
<input value={draftName} onChange={(event) => setDraftName(event.target.value)} required />
</label>
<label className="explorer-input">
<strong>Message</strong>
<span>English, no header, buttons, or placeholders. Written for the internal team.</span>
<textarea value={draftBody} onChange={(event) => setDraftBody(event.target.value)} rows={5} required />
</label>
<button className="primary-button" type="submit" disabled={creating || !accountId}>
{creating ? <Loader2 size={16} className="wa-spin" /> : null}
Submit template to Meta
</button>
</div>
{created && (
<p className="wa-token-result">{created.name} is {created.status.toLowerCase()}. Send it after Meta marks it approved.</p>
)}
</form>
)}
{error && <p className="auth-error">{error}</p>}
{loading && <div className="auth-loading">Loading accounts…</div>}
{!loading && accounts.length === 0 && (
<section className="panel placeholder">
<p>Connect a WhatsApp number before sending a broadcast.</p>
<Link className="primary-button" to="/console/whatsapp/token">Add a WhatsApp token</Link>
</section>
)}
{!loading && accounts.length > 0 && (
<form className="wa-biz-card" onSubmit={submit}>
<div className="wa-token-form">
<label className="explorer-input">
<strong>Account</strong>
<span>The connected number this broadcast is sent from.</span>
<select value={accountId} onChange={(event) => setAccountId(event.target.value)}>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name} · {account.primary_phone?.display_phone || account.phones[0]?.display_phone || 'WhatsApp'}
</option>
))}
</select>
</label>
<label className="explorer-input">
<strong>Marketing template</strong>
<span>
{templatesReady
? approved.length
? 'Approved marketing templates on this account.'
: pending.length
? `${pending.map((item) => item.name).join(', ')} is waiting for Meta’s approval.`
: 'No approved marketing template yet. Submit the team message above, then come back when Meta approves it.'
: 'Template list is unavailable. You can still type the approved template name.'}
</span>
{approved.length > 0 ? (
<select
value={templateName}
onChange={(event) => {
const next = approved.find((item) => item.name === event.target.value)
setTemplateName(event.target.value)
if (next?.language) setTemplateLanguage(next.language)
setUseSample(true)
}}
>
{approved.map((item) => (
<option key={`${item.name}-${item.language}`} value={item.name || ''}>
{item.name} ({item.language || 'en'})
</option>
))}
</select>
) : (
<input value={templateName} onChange={(event) => setTemplateName(event.target.value)} required />
)}
</label>
<label className="explorer-input">
<strong>Language</strong>
<span>Use the language code Meta shows on the template, such as en.</span>
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} required />
</label>
<label className="explorer-input">
<strong>Recipients</strong>
<span>Type numbers here, up to 20, or upload a CSV below. One number per line.</span>
<textarea value={recipients} onChange={(event) => setRecipients(event.target.value)} rows={4} placeholder="9198XXXXXXXX" />
</label>
<label className="explorer-input">
<strong>Country code</strong>
<span>Ten-digit numbers get this code. After cleanup, every number must start with it. India must start with 91.</span>
<select value={countryCode} onChange={(event) => setCountryCode(event.target.value)}>
{COUNTRIES.map((country) => (
<option key={country.code} value={country.code}>{country.label} (+{country.code})</option>
))}
</select>
</label>
<label className="explorer-input">
<strong>CSV</strong>
<span>One column named phone_number, up to 1,000 numbers. Spaces, dashes, and plus signs are removed. A duplicate or a number that does not match the country code counts as failed. Urmi sends 10, waits 2 seconds, then continues, staying under Meta’s 20-per-second limit.</span>
<input
key={csvKey}
type="file"
accept=".csv,text/csv"
onChange={(event) => setCsvFile(event.target.files?.[0] || null)}
/>
</label>
<button className="text-button" type="button" onClick={downloadSampleCsv}>Download a blank sample CSV</button>
<button className="secondary-button" type="button" disabled={csvBusy || !accountId || !csvFile} onClick={() => void startCsv()}>
{csvBusy ? <Loader2 size={16} className="wa-spin" /> : null}
{csvFile ? `Start ${csvFile.name}` : 'Start CSV broadcast'}
</button>
{mediaHeader && (
<label className="explorer-input">
<strong>Header {headerLabel}</strong>
<span>
{savedSample
? `Use the ${headerLabel} saved on this template (${selectedTemplate?.sample_name || 'sample file'}). Meta does not keep the approval file, so each send has to attach it.`
: `This template needs a ${headerLabel} on every send, and none is saved yet.`}
</span>
{savedSample ? (
<input type="checkbox" checked={useSample} onChange={(event) => setUseSample(event.target.checked)} />
) : selectedTemplate ? (
<Link to={`/console/whatsapp/templates/${selectedTemplate.id}`}>Add the sample on the template</Link>
) : (
<span>Open the template and add the sample file first.</span>
)}
</label>
)}
<label className="explorer-input">
<strong>Body values</strong>
<span>Leave this empty for the team aircheck. If a template has {'{{1}}'}, {'{{2}}'}, put one value per line in that order.</span>
<textarea value={bodyValues} onChange={(event) => setBodyValues(event.target.value)} rows={3} />
</label>
<label className="explorer-input">
<strong>Marketing endpoint only</strong>
<span>Leave this on to test Meta’s marketing API. Turn it off only if Meta says this account is not onboarded yet and you still want the message delivered.</span>
<input type="checkbox" checked={strict} onChange={(event) => setStrict(event.target.checked)} />
</label>
<button className="primary-button" type="submit" disabled={busy || !accountId}>
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
Send broadcast
</button>
</div>
</form>
)}
{result && (
<section className="wa-biz-card wa-token-result">
<h2>{result.sent} sent, {result.failed} failed</h2>
<ul className="wa-broadcast-results">
{result.results.map((item) => (
<li key={`${item.to}-${item.message_id || item.error || item.status}`}>
<span>{item.to}</span>
<span>{item.status === 'sent' ? 'Sent' : item.error || 'Failed'}</span>
</li>
))}
</ul>
</section>
)}
{!loading && accounts.length > 0 && (
<section className="wa-biz-card">
<h2>CSV broadcasts</h2>
<p>A file shows here as soon as it is accepted. This page checks every 3 seconds until sending finishes.</p>
{jobs.length === 0 && <p className="wa-biz-muted">No CSV broadcasts yet.</p>}
{jobs.length > 0 && (
<div className="wa-broadcast-jobs">
<div className="wa-broadcast-job wa-broadcast-job-head"><span>CSV</span><span>Started</span><span>Status</span></div>
{jobs.map((job) => (
<button
type="button"
key={job.id}
className={job.id === selectedJobId ? 'wa-broadcast-job is-selected' : 'wa-broadcast-job'}
onClick={() => setSelectedJobId((current) => current === job.id ? '' : job.id)}
>
<span>{job.filename}</span>
<span>{job.created_at ? new Date(job.created_at).toLocaleString() : '—'}</span>
<span>{job.status === 'processing' ? `Processing · ${job.sent_count + job.failed_count} of ${job.total}` : job.status === 'failed' ? 'Failed' : 'Completed'}</span>
</button>
))}
</div>
)}
{selectedJob && (
<div className="wa-broadcast-chart">
<h3>{selectedJob.filename}</h3>
<p>{selectedJob.sent_count} succeeded, {selectedJob.failed_count} failed{selectedJob.last_error && selectedJob.status === 'failed' ? `. ${selectedJob.last_error}` : ''}</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={[
{ name: 'Succeeded', count: selectedJob.sent_count },
{ name: 'Failed', count: selectedJob.failed_count },
]}>
<CartesianGrid vertical={false} stroke="#edf0f4" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
<Cell fill="#12805c" />
<Cell fill="#c2362f" />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
)}
</div>
)
}