This commit is contained in:
debu-2 2026-10-14 06:32:25 +05:30
parent a109df2deb
commit 8901d99dd2
11 changed files with 380 additions and 13 deletions

View file

@ -1,7 +1,7 @@
import { Loader2 } from 'lucide-react'
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { whatsappApi, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
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."
@ -23,7 +23,9 @@ export function WhatsAppBroadcastPage() {
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)
@ -72,6 +74,9 @@ export function WhatsAppBroadcastPage() {
setTemplates([])
setTemplatesReady(false)
})
waTemplateApi.list(accountId)
.then((payload) => setManaged(payload.templates))
.catch(() => setManaged([]))
}, [accountId, templateTick])
async function createTemplate(event: FormEvent) {
@ -103,6 +108,16 @@ 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 (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(),
@ -110,6 +125,7 @@ export function WhatsAppBroadcastPage() {
recipients: numbers,
body_parameters: parameters,
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
use_sample_header: Boolean(mediaHeader && useSample && savedSample),
})
setResult(payload)
} catch (reason) {
@ -121,6 +137,11 @@ 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 savedSample = Boolean(selectedTemplate?.sample_stored)
const headerLabel = selectedTemplate?.header_format === 'VIDEO' ? 'video' : selectedTemplate?.header_format === 'DOCUMENT' ? 'document' : 'image'
return (
<div className="wa-biz wa-token-page">
@ -198,6 +219,7 @@ export function WhatsAppBroadcastPage() {
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) => (
@ -220,6 +242,23 @@ export function WhatsAppBroadcastPage() {
<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" />
</label>
{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>

View file

@ -19,6 +19,8 @@ export function WhatsAppTemplateDetailPage() {
const [row, setRow] = useState<WaManagedTemplate | null>(null)
const [error, setError] = useState('')
const [tab, setTab] = useState<'details' | 'analytics'>('details')
const [sampleUrl, setSampleUrl] = useState('')
const [savingSample, setSavingSample] = useState(false)
useEffect(() => {
waTemplateApi.get(templateId)
@ -26,6 +28,41 @@ export function WhatsAppTemplateDetailPage() {
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Template not found'))
}, [templateId])
useEffect(() => {
if (!row?.sample_stored || row.header_format !== 'IMAGE') {
setSampleUrl('')
return
}
let cancelled = false
let url = ''
waTemplateApi.sampleBlob(row.id)
.then((blob) => {
if (cancelled) return
url = URL.createObjectURL(blob)
setSampleUrl(url)
})
.catch(() => {
if (!cancelled) setSampleUrl('')
})
return () => {
cancelled = true
if (url) URL.revokeObjectURL(url)
}
}, [row?.id, row?.sample_stored, row?.header_format])
async function saveSample(file: File) {
if (!row) return
setSavingSample(true)
setError('')
try {
setRow(await waTemplateApi.saveSample(row.id, file))
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'The sample file was not saved')
} finally {
setSavingSample(false)
}
}
async function remove() {
if (!row || !window.confirm(`Delete ${row.name}?`)) return
try {
@ -45,7 +82,12 @@ export function WhatsAppTemplateDetailPage() {
category: ((row.definition.category || row.category) as WaTemplateDraft['category']) || '',
name: row.name,
language: row.language,
header: { ...emptyDraft().header, ...row.definition.header },
header: {
...emptyDraft().header,
...row.definition.header,
preview_name: row.sample_name || row.definition.header?.preview_name || '',
preview_url: sampleUrl || row.definition.header?.preview_url || '',
},
auth: { ...emptyDraft().auth, ...row.definition.auth },
buttons: row.definition.buttons || [],
body_examples: row.definition.body_examples || [],
@ -85,6 +127,29 @@ export function WhatsAppTemplateDetailPage() {
<div><dt>Quality</dt><dd>{row.quality || 'Not rated yet'}</dd></div>
<div><dt>Updated</dt><dd>{row.updated_at ? new Date(row.updated_at).toLocaleString() : '—'}</dd></div>
</dl>
{['IMAGE', 'VIDEO', 'DOCUMENT'].includes(row.header_format) && (
<div className="wa-tpl-sample">
<strong>Sample file for sending</strong>
<p>
{row.sample_stored
? `${row.sample_name || 'A sample file'} is saved. A broadcast can attach this file. Meta still needs it on every send, because the approval upload is not reused.`
: 'No sample file is saved. Meta approved the layout, but each send still needs this file. Add it here, then turn it on when you broadcast.'}
</p>
<label className="wa-tpl-drop">
<strong>{savingSample ? 'Saving…' : row.sample_stored ? 'Replace the sample file' : 'Add the sample file'}</strong>
<input
type="file"
disabled={savingSample}
accept={row.header_format === 'IMAGE' ? 'image/jpeg,image/png' : row.header_format === 'VIDEO' ? 'video/mp4' : 'application/pdf'}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void saveSample(file)
event.target.value = ''
}}
/>
</label>
</div>
)}
{row.rejected_reason && (
<div className="wa-tpl-reason">
<strong>Why Meta paused or rejected it</strong>

View file

@ -127,6 +127,9 @@ export function WhatsAppTemplateWizard() {
...draft.header,
type: uploaded.format as WaTemplateDraft['header']['type'],
handle: uploaded.handle,
sample_id: uploaded.sample_id,
sample_name: uploaded.sample_name,
sample_type: uploaded.sample_type,
preview_name: file.name,
preview_url: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
},
@ -252,8 +255,10 @@ export function WhatsAppTemplateWizard() {
onClick={() => patch({ category: item.id, subtype: item.subtypes[0][0], buttons: item.id === 'AUTHENTICATION' ? [] : draft.buttons })}
>
<Icon size={18} />
<strong>{item.title}</strong>
<span>{item.description}</span>
<span>
<strong>{item.title}</strong>
<small>{item.description}</small>
</span>
</button>
)
})}
@ -308,6 +313,7 @@ export function WhatsAppTemplateWizard() {
{['IMAGE', 'VIDEO', 'DOCUMENT'].includes(draft.header.type) && (
<label className="wa-tpl-drop">
<strong>{draft.header.preview_name || 'Drop a JPEG, PNG, MP4, or PDF'}</strong>
<small>Meta uses this file only to review the template. Urmi keeps a copy so a broadcast can attach it when the message is sent.</small>
<input type="file" accept={draft.header.type === 'IMAGE' ? 'image/jpeg,image/png' : draft.header.type === 'VIDEO' ? 'video/mp4' : 'application/pdf'} onChange={(event) => { const file = event.target.files?.[0]; if (file) void uploadHeader(file) }} />
</label>
)}

View file

@ -50,7 +50,7 @@ export function emptyDraft(): WaTemplateDraft {
category: '',
subtype: 'default',
parameter_format: 'POSITIONAL',
header: { type: 'NONE', text: '', examples: [], handle: '', preview_name: '', preview_url: '' },
header: { type: 'NONE', text: '', examples: [], handle: '', sample_id: '', sample_name: '', sample_type: '', preview_name: '', preview_url: '' },
body: '',
body_examples: [],
footer: '',

View file

@ -741,6 +741,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.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[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; }
.explorer-response { display: grid; gap: 10px; padding: 14px; border: 1px solid #bfe8d5; border-radius: 8px; background: #f5fffa; }
@ -2042,7 +2043,10 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-tpl-preview-buttons { display: grid; gap: 6px; margin-top: 8px; }
.wa-tpl-preview-buttons span { background: white; border-radius: 8px; text-align: center; color: #1475ff; padding: 8px; font-size: 13px; font-weight: 700; }
.wa-tpl-numbers, .wa-tpl-categories { display: grid; gap: 10px; }
.wa-tpl-numbers label, .wa-tpl-categories button { display: grid; grid-template-columns: auto 1fr; gap: 8px 12px; padding: 14px; text-align: left; }
.wa-tpl-numbers label, .wa-tpl-categories button { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; padding: 14px; text-align: left; align-items: start; }
.wa-tpl-categories button span { gap: 4px; }
.wa-tpl-categories button strong { color: #14233b; }
.wa-tpl-categories button small { color: #718096; font-size: 13px; line-height: 1.45; }
.wa-tpl-numbers label.is-selected, .wa-tpl-categories button.is-selected { border-color: #1475ff; box-shadow: 0 0 0 3px rgba(20,117,255,.12); }
.wa-tpl-numbers span, .wa-tpl-categories button span { display: grid; }
.wa-tpl-numbers small, .wa-tpl-categories span { color: #718096; }
@ -2059,6 +2063,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-tpl-tabs { display: flex; gap: 8px; }
.wa-tpl-tabs .is-current { color: #14233b; }
.wa-tpl-drop { display: grid; gap: 8px; padding: 16px; border: 1px dashed #b7c3d3; border-radius: 12px; background: #f8fafc; }
.wa-tpl-sample { display: grid; gap: 8px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.wa-tpl-sample p { margin: 0; color: #526176; }
.wa-tpl-result dl { margin-top: 16px; }
@media (max-width: 980px) {
.wa-tpl-cards, .wa-tpl-split, .wa-tpl-samples label, .wa-tpl-review div { grid-template-columns: 1fr; }

View file

@ -1315,6 +1315,7 @@ export const whatsappApi = {
recipients: string[]
body_parameters?: string[]
product_policy?: 'STRICT' | 'CLOUD_API_FALLBACK'
use_sample_header?: boolean
}) =>
jsonRequest<{
template_name: string
@ -1349,6 +1350,9 @@ export type WaTemplateDraft = {
text: string
examples: string[]
handle: string
sample_id: string
sample_name: string
sample_type: string
preview_name: string
preview_url: string
}
@ -1391,6 +1395,9 @@ export type WaManagedTemplate = {
quality: string | null
rejected_reason: string | null
parameter_format: string
header_format: string
sample_stored: boolean
sample_name: string
definition: Partial<WaTemplateDraft>
components: unknown[]
messages_sent: number
@ -1422,6 +1429,16 @@ export const waTemplateApi = {
const body = new FormData()
body.append('account_id', accountId)
body.append('file', file)
return jsonRequest<{ handle: string; format: string }>('/api/v1/whatsapp/message-templates/media', { method: 'POST', body })
return jsonRequest<{ handle: string; format: string; sample_id: string; sample_name: string; sample_type: string }>('/api/v1/whatsapp/message-templates/media', { method: 'POST', body })
},
saveSample: (templateId: string, file: File) => {
const body = new FormData()
body.append('file', file)
return jsonRequest<WaManagedTemplate>(`/api/v1/whatsapp/message-templates/${templateId}/sample`, { method: 'POST', body })
},
sampleBlob: async (templateId: string) => {
const response = await fetch(`${API_BASE_URL}/api/v1/whatsapp/message-templates/${templateId}/sample`, { credentials: 'include' })
if (!response.ok) throw new Error('The sample file could not be opened')
return response.blob()
},
}