template
This commit is contained in:
parent
94cd48d1ac
commit
6b6b929fea
2 changed files with 125 additions and 75 deletions
|
|
@ -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>
|
||||
<button className="primary-button" type="submit" disabled={busy || !accountId}>
|
||||
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||
Send broadcast
|
||||
</button>
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue