This commit is contained in:
debu-2 2026-10-15 02:09:25 +05:30
parent 94cd48d1ac
commit 6b6b929fea
2 changed files with 125 additions and 75 deletions

View file

@ -2,7 +2,7 @@ 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'
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppBroadcastJob } from '../../lib/api'
const COUNTRIES = [
{ code: '91', label: 'India' },
@ -12,6 +12,25 @@ const COUNTRIES = [
{ code: '65', label: 'Singapore' },
]
const MEDIA_HEADERS = ['IMAGE', 'VIDEO', 'DOCUMENT']
function headerKind(template: WaManagedTemplate | undefined) {
if (!template) return ''
const direct = (template.header_format || '').toUpperCase()
if (MEDIA_HEADERS.includes(direct)) return direct
const drafted = (template.definition?.header?.type || '').toUpperCase()
if (MEDIA_HEADERS.includes(drafted)) return drafted
const components = Array.isArray(template.components) ? template.components : []
for (const component of components) {
if (!component || typeof component !== 'object') continue
const row = component as { type?: string; format?: string }
if (String(row.type || '').toUpperCase() !== 'HEADER') continue
const format = String(row.format || '').toUpperCase()
if (MEDIA_HEADERS.includes(format)) return format
}
return ''
}
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."
@ -31,7 +50,7 @@ export function WhatsAppBroadcastPage() {
const [params] = useSearchParams()
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
const [accountId, setAccountId] = useState('')
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
const [phoneId, setPhoneId] = useState('')
const [managed, setManaged] = useState<WaManagedTemplate[]>([])
const [templatesReady, setTemplatesReady] = useState(false)
const [useSample, setUseSample] = useState(true)
@ -62,38 +81,23 @@ export function WhatsAppBroadcastPage() {
.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)
const first = ready.flatMap((account) => account.phones.filter((phone) => phone.status === 'connected').map((phone) => ({ account, phone })))[0]
if (first) {
setAccountId(first.account.id)
setPhoneId(first.phone.phone_number_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)
waTemplateApi.list()
.then((payload) => setManaged(payload.templates))
.catch(() => setManaged([]))
}, [accountId, templateTick])
.finally(() => setTemplatesReady(true))
}, [templateTick])
useEffect(() => {
if (!accountId) return
@ -169,7 +173,7 @@ export function WhatsAppBroadcastPage() {
recipients: numbers,
body_parameters: parameters,
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
use_sample_header: Boolean(mediaHeader && useSample && savedSample),
use_sample_header: sampleOn,
})
setResult(payload)
} catch (reason) {
@ -179,13 +183,46 @@ export function WhatsAppBroadcastPage() {
}
}
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 numbers = accounts.flatMap((account) => account.phones.filter((phone) => phone.status === 'connected').map((phone) => ({ account, phone })))
const selectedNumber = numbers.find((item) => item.phone.phone_number_id === phoneId) || numbers[0]
const approved = managed.filter((item) => (
item.status === 'APPROVED'
&& item.category.toUpperCase() === 'MARKETING'
&& Boolean(item.name)
&& (!selectedNumber || item.account_id === selectedNumber.account.id)
))
const pending = managed.filter((item) => item.status !== 'APPROVED' && item.category.toUpperCase() === 'MARKETING' && (!selectedNumber || item.account_id === selectedNumber.account.id))
const selectedTemplate = approved.find((item) => item.name === templateName && item.language === templateLanguage)
|| approved.find((item) => item.name === templateName)
useEffect(() => {
if (!templatesReady || !numbers.length) return
const current = numbers.find((item) => item.phone.phone_number_id === phoneId) || numbers[0]
const hasApproved = managed.some((item) => item.status === 'APPROVED' && item.category.toUpperCase() === 'MARKETING' && item.account_id === current.account.id)
if (hasApproved) return
const next = numbers.find((item) => managed.some((template) => template.status === 'APPROVED' && template.category.toUpperCase() === 'MARKETING' && template.account_id === item.account.id))
if (!next || next.phone.phone_number_id === current.phone.phone_number_id) return
setPhoneId(next.phone.phone_number_id)
setAccountId(next.account.id)
}, [templatesReady, managed, numbers, phoneId])
useEffect(() => {
if (!approved.length) return
const requested = params.get('template')
const requestedLanguage = params.get('language')
const current = approved.find((item) => item.name === templateName && item.language === templateLanguage)
const requestedMatch = approved.find((item) => item.name === requested && (!requestedLanguage || item.language === requestedLanguage))
const next = current || requestedMatch || approved[0]
if (next.name !== templateName || next.language !== templateLanguage) {
setTemplateName(next.name)
setTemplateLanguage(next.language)
}
}, [approved, params, templateLanguage, templateName])
const headerFormat = headerKind(selectedTemplate) || (selectedTemplate?.sample_stored ? 'IMAGE' : '')
const mediaHeader = MEDIA_HEADERS.includes(headerFormat)
const savedSample = Boolean(selectedTemplate?.sample_stored)
const headerLabel = selectedTemplate?.header_format === 'VIDEO' ? 'video' : selectedTemplate?.header_format === 'DOCUMENT' ? 'document' : 'image'
const headerLabel = headerFormat === 'VIDEO' ? 'video' : headerFormat === 'DOCUMENT' ? 'document' : 'image'
const sampleOn = Boolean(mediaHeader && useSample && savedSample)
function downloadSampleCsv() {
const blob = new Blob(['phone_number\n'], { type: 'text/csv;charset=utf-8' })
@ -223,7 +260,7 @@ export function WhatsAppBroadcastPage() {
template_name: templateName.trim(),
template_language: templateLanguage.trim() || 'en',
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
use_sample_header: Boolean(mediaHeader && useSample && savedSample),
use_sample_header: sampleOn,
body_parameters: bodyValues,
})
setJobs((current) => [job, ...current.filter((item) => item.id !== job.id)])
@ -286,12 +323,20 @@ export function WhatsAppBroadcastPage() {
<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'}
<strong>Number</strong>
<span>Templates for this WhatsApp number. The same templates are shared by every number on that account.</span>
<select
value={selectedNumber?.phone.phone_number_id || ''}
onChange={(event) => {
const next = numbers.find((item) => item.phone.phone_number_id === event.target.value)
if (!next) return
setPhoneId(next.phone.phone_number_id)
setAccountId(next.account.id)
}}
>
{numbers.map(({ account, phone }) => (
<option key={phone.phone_number_id} value={phone.phone_number_id}>
{phone.display_phone || phone.verified_name || 'WhatsApp'} · {account.business_name || account.name}
</option>
))}
</select>
@ -299,27 +344,28 @@ export function WhatsAppBroadcastPage() {
<label className="explorer-input">
<strong>Marketing template</strong>
<span>
{templatesReady
? approved.length
? 'Approved marketing templates on this account.'
{!templatesReady
? 'Loading templates…'
: approved.length
? `Approved marketing templates on ${selectedNumber?.phone.display_phone || 'this number'}.`
: 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.'}
: 'No approved marketing template on this number yet.'}
</span>
{approved.length > 0 ? (
<select
value={templateName}
value={`${templateName}::${templateLanguage}`}
onChange={(event) => {
const next = approved.find((item) => item.name === event.target.value)
setTemplateName(event.target.value)
if (next?.language) setTemplateLanguage(next.language)
const next = approved.find((item) => `${item.name}::${item.language}` === event.target.value)
if (!next) return
setTemplateName(next.name)
setTemplateLanguage(next.language)
setUseSample(true)
}}
>
{approved.map((item) => (
<option key={`${item.name}-${item.language}`} value={item.name || ''}>
{item.name} ({item.language || 'en'})
<option key={`${item.name}-${item.language}`} value={`${item.name}::${item.language}`}>
{item.name} ({item.language})
</option>
))}
</select>
@ -332,6 +378,23 @@ export function WhatsAppBroadcastPage() {
<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>
{mediaHeader && (
<label className="explorer-input">
<strong>Use existing {headerLabel}</strong>
<span>
{savedSample
? `Send the ${headerLabel} already saved on this template (${selectedTemplate?.sample_name || 'sample file'}). Meta does not keep the approval file, so each send attaches this copy. This applies to the typed numbers and the CSV.`
: `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>Recipients</strong>
<span>Type numbers here, up to 20, or upload a CSV below. One number per line.</span>
@ -357,27 +420,6 @@ export function WhatsAppBroadcastPage() {
/>
</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>
@ -388,10 +430,16 @@ export function WhatsAppBroadcastPage() {
<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>
<div className="wa-broadcast-actions">
<button className="primary-button" type="submit" disabled={busy || !accountId}>
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
Send broadcast
</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>
</div>
</div>
</form>
)}

View file

@ -1848,6 +1848,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-token-embed h2 { margin-top: 0; }
.wa-token-form { display: grid; gap: 12px; margin-top: 16px; }
.wa-token-form .primary-button { justify-self: start; display: inline-flex; align-items: center; gap: 8px; }
.wa-broadcast-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-self: start; }
.wa-broadcast-actions .primary-button, .wa-broadcast-actions .secondary-button { justify-self: auto; }
.wa-token-page { max-width: 720px; }
.wa-broadcast-steps { margin: 0; padding-left: 18px; display: grid; gap: 6px; }
.wa-broadcast-results { display: grid; gap: 8px; margin-top: 12px; }