This commit is contained in:
debu-2 2026-10-15 01:58:00 +05:30
parent 8901d99dd2
commit 29d867438c
7 changed files with 645 additions and 5 deletions

View file

@ -1,7 +1,16 @@
import { Loader2 } from 'lucide-react'
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
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."
@ -34,6 +43,13 @@ export function WhatsAppBroadcastPage() {
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)
@ -79,6 +95,29 @@ export function WhatsAppBroadcastPage() {
.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)
@ -108,6 +147,11 @@ export function WhatsAppBroadcastPage() {
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)
@ -142,6 +186,47 @@ export function WhatsAppBroadcastPage() {
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'
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">
@ -239,9 +324,32 @@ export function WhatsAppBroadcastPage() {
</label>
<label className="explorer-input">
<strong>Recipients</strong>
<span>One WhatsApp number per line, up to 20. Include the country code.</span>
<textarea value={recipients} onChange={(event) => setRecipients(event.target.value)} rows={4} required placeholder="9198XXXXXXXX" />
<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="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>
@ -290,6 +398,52 @@ export function WhatsAppBroadcastPage() {
</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>
)
}

View file

@ -740,7 +740,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.explorer-input { display: grid; gap: 6px; }
.explorer-input strong { color: #35435a; font-size: 12px; }
.explorer-input span { color: #8a95a7; font-size: 10px; line-height: 1.4; }
.explorer-input input, .explorer-input textarea { width: 100%; border: 1px solid #dfe5ed; border-radius: 6px; padding: 10px 12px; font-size: 12px; color: #26364d; }
.explorer-input input, .explorer-input textarea, .explorer-input select { width: 100%; border: 1px solid #dfe5ed; border-radius: 6px; padding: 10px 12px; font-size: 12px; color: #26364d; background: white; }
.explorer-input input[type='checkbox'] { width: 16px; height: 16px; padding: 0; }
.explorer-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.explorer-code, .explorer-response pre { margin: 0; padding: 14px; border-radius: 8px; background: #0f1728; color: #dbe7ff; font-size: 11px; line-height: 1.5; overflow: auto; }
@ -1852,6 +1852,16 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-broadcast-steps { margin: 0; padding-left: 18px; display: grid; gap: 6px; }
.wa-broadcast-results { display: grid; gap: 8px; margin-top: 12px; }
.wa-broadcast-results li { display: flex; justify-content: space-between; gap: 12px; }
.wa-broadcast-jobs { display: grid; margin-top: 12px; }
.wa-broadcast-job, .wa-broadcast-job-head { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(140px, 1fr) minmax(160px, .8fr); gap: 12px; align-items: center; width: 100%; text-align: left; padding: 12px 0; border: 0; border-bottom: 1px solid #edf0f4; background: transparent; color: #17233c; }
.wa-broadcast-job-head { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.wa-broadcast-job.is-selected { color: #1475ff; }
.wa-broadcast-chart { margin-top: 16px; }
.wa-broadcast-chart h3 { margin: 0 0 4px; }
.wa-broadcast-chart p { margin: 0 0 8px; color: #526176; }
@media (max-width: 720px) {
.wa-broadcast-job, .wa-broadcast-job-head { grid-template-columns: 1fr; gap: 4px; }
}
.wa-token-result { margin-top: 16px; }
.wa-token-result .primary-button { margin-top: 8px; text-decoration: none; display: inline-flex; }
.wa-biz-heading h1 { margin: 6px 0; }

View file

@ -1309,6 +1309,26 @@ export const whatsappApi = {
`/api/v1/whatsapp/accounts/${accountId}/templates`,
{ method: 'POST', body: JSON.stringify(payload) },
),
broadcastJobs: (accountId: string) =>
jsonRequest<WhatsAppBroadcastJob[]>(`/api/v1/whatsapp/accounts/${accountId}/broadcasts/jobs`),
uploadBroadcastCsv: (accountId: string, file: File, fields: {
country_code: string
template_name: string
template_language: string
product_policy: string
use_sample_header: boolean
body_parameters: string
}) => {
const body = new FormData()
body.append('file', file)
body.append('country_code', fields.country_code)
body.append('template_name', fields.template_name)
body.append('template_language', fields.template_language)
body.append('product_policy', fields.product_policy)
body.append('use_sample_header', fields.use_sample_header ? 'true' : 'false')
body.append('body_parameters', fields.body_parameters)
return jsonRequest<WhatsAppBroadcastJob>(`/api/v1/whatsapp/accounts/${accountId}/broadcasts/csv`, { method: 'POST', body })
},
sendBroadcast: (accountId: string, payload: {
template_name: string
template_language: string
@ -1337,6 +1357,22 @@ export const whatsappApi = {
}),
}
export type WhatsAppBroadcastJob = {
id: string
account_id: string
filename: string
country_code: string
template_name: string
template_language: string
status: 'processing' | 'completed' | 'failed'
total: number
sent_count: number
failed_count: number
last_error: string | null
created_at: string
completed_at: string | null
}
export type WaTemplateDraft = {
account_id: string
phone_number_id: string