This commit is contained in:
debu-2 2026-10-14 05:30:45 +05:30
parent 8037489503
commit ad189b8568
15 changed files with 1989 additions and 4 deletions

View file

@ -0,0 +1,44 @@
import type { WaTemplateDraft } from '../../lib/api'
import { variableNames, withSamples } from './templateModel'
export function WaTemplatePreview({ draft }: { draft: WaTemplateDraft }) {
const auth = draft.category === 'AUTHENTICATION'
const header = auth ? '' : draft.header.type === 'TEXT' ? withSamples(draft.header.text, draft.header.examples) : ''
const body = auth
? `${draft.auth.add_security_recommendation ? 'Your verification code is 123456. For your security, do not share this code.' : 'Your verification code is 123456.'}`
: withSamples(draft.body, draft.body_examples)
const footer = auth
? (draft.auth.code_expiration_minutes ? `This code expires in ${draft.auth.code_expiration_minutes} minutes.` : '')
: draft.footer
const buttons = auth
? [draft.auth.copy_label || 'Copy code']
: draft.buttons.map((button) => button.type === 'COPY_CODE' ? `Copy code ${button.example}` : button.text || button.type)
return (
<aside className="wa-tpl-preview">
<p>Preview</p>
<div className="wa-tpl-phone">
<div className="wa-tpl-bubble">
{!auth && draft.header.type === 'IMAGE' && (draft.header.preview_url ? <img src={draft.header.preview_url} alt="" /> : <div className="wa-tpl-media">{draft.header.preview_name || 'Image'}</div>)}
{!auth && draft.header.type === 'VIDEO' && <div className="wa-tpl-media">Video</div>}
{!auth && draft.header.type === 'DOCUMENT' && <div className="wa-tpl-media">{draft.header.preview_name || 'Document'}</div>}
{!auth && draft.header.type === 'LOCATION' && <div className="wa-tpl-media">Location</div>}
{header && <strong>{header}</strong>}
<span>{body || 'Your message will appear here.'}</span>
{footer && <small>{footer}</small>}
</div>
{buttons.filter(Boolean).length > 0 && (
<div className="wa-tpl-preview-buttons">
{buttons.filter(Boolean).map((label, index) => <span key={`${label}-${index}`}>{label}</span>)}
</div>
)}
</div>
<small>Sample values are shown in place of variables. Meta sends the real values when you use the template.</small>
</aside>
)
}
export function previewBody(draft: WaTemplateDraft): string {
if (draft.category === 'AUTHENTICATION') return 'Your verification code is 123456.'
return withSamples(draft.body, draft.body_examples) || variableNames(draft.body).join(', ')
}

View file

@ -1,6 +1,6 @@
import { Loader2 } from 'lucide-react'
import { useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import { whatsappApi, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck'
@ -19,6 +19,7 @@ type CreatedTemplate = {
}
export function WhatsAppBroadcastPage() {
const [params] = useSearchParams()
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
const [accountId, setAccountId] = useState('')
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
@ -57,8 +58,12 @@ export function WhatsAppBroadcastPage() {
const marketing = payload.templates.filter((item) => (item.category || '').toUpperCase() === 'MARKETING')
setTemplates(marketing)
setTemplatesReady(payload.available)
const approved = marketing.find((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name)
if (approved?.name) {
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')
}

View file

@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { waTemplateApi, type WaManagedTemplate, type WaTemplateDraft } from '../../lib/api'
import { WaTemplatePreview } from './WaTemplatePreview'
import { emptyDraft } from './templateModel'
const STATUS_LABEL: Record<string, string> = {
APPROVED: 'Approved',
PENDING: 'Pending',
REJECTED: 'Rejected',
PAUSED: 'Paused',
DISABLED: 'Disabled',
IN_APPEAL: 'In appeal',
}
export function WhatsAppTemplateDetailPage() {
const { templateId = '' } = useParams()
const navigate = useNavigate()
const [row, setRow] = useState<WaManagedTemplate | null>(null)
const [error, setError] = useState('')
const [tab, setTab] = useState<'details' | 'analytics'>('details')
useEffect(() => {
waTemplateApi.get(templateId)
.then(setRow)
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Template not found'))
}, [templateId])
async function remove() {
if (!row || !window.confirm(`Delete ${row.name}?`)) return
try {
await waTemplateApi.remove(row.id)
navigate('/console/whatsapp/templates')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'This template could not be deleted')
}
}
if (error && !row) return <p className="auth-error">{error}</p>
if (!row) return <div className="auth-loading">Loading template…</div>
const draft: WaTemplateDraft = {
...emptyDraft(),
...row.definition,
category: ((row.definition.category || row.category) as WaTemplateDraft['category']) || '',
name: row.name,
language: row.language,
header: { ...emptyDraft().header, ...row.definition.header },
auth: { ...emptyDraft().auth, ...row.definition.auth },
buttons: row.definition.buttons || [],
body_examples: row.definition.body_examples || [],
}
const analytics = row.analytics
return (
<div className="wa-tpl">
<div className="account-heading wa-tpl-heading">
<div>
<span className="eyebrow">Message templates</span>
<h1>{row.name}</h1>
<p className={`wa-tpl-status is-${row.status.toLowerCase()}`}>{STATUS_LABEL[row.status] || row.status}</p>
</div>
<div className="wa-tpl-heading-actions">
<Link className="secondary-button" to={`/console/whatsapp/templates/new?duplicate=${row.id}`}>Duplicate</Link>
{row.status === 'APPROVED' && <Link className="primary-button" to={`/console/whatsapp/broadcast?template=${encodeURIComponent(row.name)}&language=${encodeURIComponent(row.language)}`}>Send</Link>}
{['REJECTED', 'PAUSED', 'APPROVED'].includes(row.status) && <Link className="primary-button" to={`/console/whatsapp/templates/${row.id}/edit`}>{row.status === 'APPROVED' ? 'Edit' : 'Edit and resubmit'}</Link>}
<button type="button" className="secondary-button" onClick={() => void remove()}>Delete</button>
</div>
</div>
{error && <p className="auth-error">{error}</p>}
<div className="wa-tpl-tabs">
<button type="button" className={tab === 'details' ? 'is-current' : ''} onClick={() => setTab('details')}>Details</button>
<button type="button" className={tab === 'analytics' ? 'is-current' : ''} onClick={() => setTab('analytics')}>Template analytics</button>
</div>
{tab === 'details' ? (
<div className="wa-tpl-split">
<section className="wa-biz-card">
<dl className="wa-tpl-review">
<div><dt>Meta template ID</dt><dd>{row.meta_template_id || 'Not assigned yet'}</dd></div>
<div><dt>Account</dt><dd>{row.account_name}</dd></div>
<div><dt>Phone</dt><dd>{row.display_phone || 'Shared on the account'}</dd></div>
<div><dt>Category</dt><dd>{row.category.toLowerCase()}</dd></div>
<div><dt>Language</dt><dd>{row.language}</dd></div>
<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>
{row.rejected_reason && (
<div className="wa-tpl-reason">
<strong>Why Meta paused or rejected it</strong>
<p>{row.rejected_reason}</p>
<p>Update the message so it matches Meta’s template rules, then submit it again. Meta reviews the new version.</p>
</div>
)}
</section>
<WaTemplatePreview draft={draft} />
</div>
) : (
<section className="wa-biz-card">
<h2>Messages recorded in Urmi</h2>
<p>{analytics?.note}</p>
<div className="wa-tpl-cards">
<article><span>Sent</span><strong>{analytics?.sent ?? row.messages_sent}</strong></article>
{Object.entries(analytics?.by_status || {}).map(([label, value]) => (
<article key={label}><span>{label}</span><strong>{value}</strong></article>
))}
</div>
</section>
)}
</div>
)
}

View file

@ -0,0 +1,497 @@
import { Bell, KeyRound, Loader2, Megaphone } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WaTemplateDraft, type WhatsAppAccount } from '../../lib/api'
import { WaTemplatePreview } from './WaTemplatePreview'
import { CATEGORIES, DRAFT_KEY, buttonError, buttonTypes, contentError, emptyDraft, nameError, variableNames } from './templateModel'
const STEPS = ['Number', 'Name', 'Category', 'Content', 'Buttons', 'Review']
function mergeDraft(base: WaTemplateDraft, incoming: Partial<WaTemplateDraft> | undefined): WaTemplateDraft {
if (!incoming) return base
return {
...base,
...incoming,
header: { ...base.header, ...incoming.header },
auth: { ...base.auth, ...incoming.auth },
buttons: incoming.buttons || base.buttons,
body_examples: incoming.body_examples || base.body_examples,
}
}
export function WhatsAppTemplateWizard() {
const navigate = useNavigate()
const { templateId } = useParams()
const [params] = useSearchParams()
const duplicateId = params.get('duplicate')
const editing = Boolean(templateId)
const [step, setStep] = useState(0)
const [draft, setDraft] = useState<WaTemplateDraft>(emptyDraft)
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
const [languages, setLanguages] = useState<{ code: string; label: string }[]>([{ code: 'en_US', label: 'English (US)' }, { code: 'en', label: 'English' }, { code: 'hi', label: 'Hindi' }])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [submitted, setSubmitted] = useState<WaManagedTemplate | null>(null)
const bodyRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
Promise.all([
whatsappApi.accounts(),
waTemplateApi.languages().catch(() => languages),
templateId || duplicateId ? waTemplateApi.get(templateId || duplicateId || '') : Promise.resolve(null),
]).then(([accountRows, languageRows, existing]) => {
setAccounts(accountRows)
if (languageRows.length) setLanguages(languageRows)
if (existing) {
const next = mergeDraft(emptyDraft(), existing.definition)
next.account_id = existing.account_id
next.phone_number_id = existing.phone_number_id || ''
next.name = duplicateId ? `${existing.name}_copy`.slice(0, 512) : existing.name
next.language = existing.language
next.category = existing.category as WaTemplateDraft['category']
next.subtype = existing.subtype
next.parameter_format = existing.parameter_format === 'NAMED' ? 'NAMED' : 'POSITIONAL'
setDraft(next)
} else if (!editing) {
const saved = window.localStorage.getItem(DRAFT_KEY)
if (saved) {
try { setDraft(mergeDraft(emptyDraft(), JSON.parse(saved) as Partial<WaTemplateDraft>)) } catch { /* keep empty */ }
}
}
}).catch((reason) => setError(reason instanceof Error ? reason.message : 'This template could not be opened'))
.finally(() => setLoading(false))
// languages is only a fallback for the first request.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateId, duplicateId])
useEffect(() => {
if (!editing && !duplicateId) window.localStorage.setItem(DRAFT_KEY, JSON.stringify(draft))
}, [draft, editing, duplicateId])
const numbers = useMemo(() => accounts.flatMap((account) => (
account.phones.length
? account.phones.map((phone) => ({ account, phone: phone as WhatsAppAccount['phones'][number] | null }))
: [{ account, phone: null as WhatsAppAccount['phones'][number] | null }]
)), [accounts])
const selected = numbers.find((item) => item.account.id === draft.account_id && (item.phone?.phone_number_id || '') === draft.phone_number_id)
const sharedCount = selected ? selected.account.phones.length : 0
const category = CATEGORIES.find((item) => item.id === draft.category)
const stepError = [
draft.account_id ? '' : 'Select a WhatsApp number.',
nameError(draft.name) || (draft.language ? '' : 'Select a language.'),
draft.category ? '' : 'Select a category.',
contentError(draft),
buttonError(draft),
contentError(draft) || buttonError(draft) || nameError(draft.name),
][step]
function patch(partial: Partial<WaTemplateDraft>) {
setDraft((current) => ({ ...current, ...partial }))
setError('')
}
function insertVariable() {
const names = variableNames(draft.body)
const token = draft.parameter_format === 'NAMED'
? `{{${window.prompt('Variable name', 'customer_name')?.trim().replace(/\W/g, '_').toLowerCase() || 'name'}}}`
: `{{${names.length + 1}}}`
const area = bodyRef.current
if (!area) {
patch({ body: `${draft.body}${token}` })
return
}
const start = area.selectionStart
const next = `${draft.body.slice(0, start)}${token}${draft.body.slice(area.selectionEnd)}`
patch({ body: next })
}
function wrap(marker: string) {
const area = bodyRef.current
if (!area) return
const start = area.selectionStart
const end = area.selectionEnd
const selectedText = draft.body.slice(start, end) || 'text'
const next = `${draft.body.slice(0, start)}${marker}${selectedText}${marker}${draft.body.slice(end)}`
patch({ body: next })
}
async function uploadHeader(file: File) {
if (!draft.account_id) return
setBusy(true)
setError('')
try {
const uploaded = await waTemplateApi.uploadHeader(draft.account_id, file)
patch({
header: {
...draft.header,
type: uploaded.format as WaTemplateDraft['header']['type'],
handle: uploaded.handle,
preview_name: file.name,
preview_url: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
},
})
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'The header file was not accepted')
} finally {
setBusy(false)
}
}
async function submit() {
setBusy(true)
setError('')
try {
const saved = editing && templateId
? await waTemplateApi.resubmit(templateId, draft)
: await waTemplateApi.create(draft)
if (!editing) window.localStorage.removeItem(DRAFT_KEY)
setSubmitted(saved)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Meta did not accept this template')
} finally {
setBusy(false)
}
}
if (submitted) {
return (
<div className="wa-tpl">
<section className="wa-biz-card wa-tpl-result">
<h1>Template submitted for review</h1>
<p>Meta has the template. It is not approved until Meta says so.</p>
<dl>
<div><dt>Template</dt><dd>{submitted.name}</dd></div>
<div><dt>Account</dt><dd>{submitted.account_name}</dd></div>
<div><dt>Category</dt><dd>{submitted.category.toLowerCase()}</dd></div>
<div><dt>Language</dt><dd>{submitted.language}</dd></div>
<div><dt>Status</dt><dd>{submitted.status === 'APPROVED' ? 'Approved' : 'Pending review'}</dd></div>
<div><dt>Meta template ID</dt><dd>{submitted.meta_template_id || 'Waiting for Meta'}</dd></div>
</dl>
<div className="wa-tpl-nav">
<Link className="primary-button" to={`/console/whatsapp/templates/${submitted.id}`}>View template</Link>
<button type="button" className="secondary-button" onClick={() => { setDraft(emptyDraft()); setStep(0); setSubmitted(null); navigate('/console/whatsapp/templates/new') }}>Create another template</button>
</div>
</section>
</div>
)
}
return (
<div className="wa-tpl">
<div className="account-heading">
<span className="eyebrow">Message templates</span>
<h1>{editing ? 'Edit template' : 'Create WhatsApp template'}</h1>
</div>
<ol className="wa-tpl-steps">
{STEPS.map((label, index) => <li key={label} className={index === step ? 'is-current' : index < step ? 'is-done' : ''}>{index + 1}. {label}</li>)}
</ol>
{error && <p className="auth-error">{error}</p>}
{loading ? <div className="auth-loading">Loading…</div> : (
<div className={step >= 2 ? 'wa-tpl-split' : ''}>
<section className="wa-biz-card">
{step === 0 && (
<>
<h2>Select WhatsApp number</h2>
<p>The template is created on the WhatsApp Business account that owns this number. Other numbers on the same account can use it after Meta approves it.</p>
<div className="wa-tpl-numbers">
{numbers.map(({ account, phone }) => {
const phoneId = phone?.phone_number_id || ''
const checked = draft.account_id === account.id && draft.phone_number_id === phoneId
return (
<label key={`${account.id}-${phoneId}`} className={checked ? 'is-selected' : ''}>
<input
type="radio"
name="wa-number"
checked={checked}
onChange={() => patch({ account_id: account.id, phone_number_id: phoneId })}
/>
<span>
<strong>{phone?.verified_name || account.name}</strong>
<em>{phone?.display_phone || 'No display number'}</em>
<small>WABA: {account.name}</small>
<small>{account.status === 'connected' ? 'Connected' : account.status} · {account.messaging_status === 'active' ? 'Messaging available' : 'Messaging unavailable'}</small>
</span>
</label>
)
})}
</div>
{numbers.length === 0 && <p>Connect a WhatsApp number first. <Link to="/console/whatsapp">Open WhatsApp</Link></p>}
{sharedCount > 1 && <p>This account has {sharedCount} numbers. The template is shared across them.</p>}
</>
)}
{step === 1 && (
<>
<h2>Template details</h2>
<label className="explorer-input">
<strong>Template name</strong>
<span>{draft.name.length}/512. Lowercase letters, numbers, and underscores. Example: order_confirmation_v2</span>
<input value={draft.name} onChange={(event) => patch({ name: event.target.value.toLowerCase() })} disabled={editing} />
</label>
<label className="explorer-input">
<strong>Language</strong>
<select value={draft.language} onChange={(event) => patch({ language: event.target.value })} disabled={editing}>
{languages.map((item) => <option key={item.code} value={item.code}>{item.label}</option>)}
</select>
</label>
<p>Selected account: {selected ? `${selected.phone?.verified_name || selected.account.name} · ${selected.phone?.display_phone || selected.account.name}` : 'None selected'}</p>
</>
)}
{step === 2 && (
<>
<h2>Choose template category</h2>
<div className="wa-tpl-categories">
{CATEGORIES.map((item) => {
const Icon = item.id === 'MARKETING' ? Megaphone : item.id === 'UTILITY' ? Bell : KeyRound
return (
<button
type="button"
key={item.id}
className={draft.category === item.id ? 'is-selected' : ''}
disabled={editing}
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>
</button>
)
})}
</div>
{category && (
<label className="explorer-input">
<strong>Type</strong>
<select value={draft.subtype} onChange={(event) => patch({ subtype: event.target.value })}>
{category.subtypes.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
</select>
</label>
)}
</>
)}
{step === 3 && draft.category === 'AUTHENTICATION' && (
<>
<h2>One-time passcode</h2>
<p>Meta writes the code message. You choose the security line, expiry, and copy button.</p>
<label className="explorer-input"><strong>Copy button label</strong><input value={draft.auth.copy_label} onChange={(event) => patch({ auth: { ...draft.auth, copy_label: event.target.value } })} /></label>
<label className="explorer-input"><strong>Code expiry minutes</strong><span>Optional. Use 1 to 90, or 0 to leave it out.</span><input type="number" min={0} max={90} value={draft.auth.code_expiration_minutes} onChange={(event) => patch({ auth: { ...draft.auth, code_expiration_minutes: Number(event.target.value) } })} /></label>
<label className="wa-tpl-check"><input type="checkbox" checked={draft.auth.add_security_recommendation} onChange={(event) => patch({ auth: { ...draft.auth, add_security_recommendation: event.target.checked } })} /> Add the security recommendation</label>
</>
)}
{step === 3 && draft.category !== 'AUTHENTICATION' && (
<>
<h2>Design your message</h2>
<label className="explorer-input">
<strong>Variable type</strong>
<select value={draft.parameter_format} onChange={(event) => patch({ parameter_format: event.target.value as WaTemplateDraft['parameter_format'] })}>
<option value="POSITIONAL">Numbered variables, {'{{1}}'} and {'{{2}}'}</option>
<option value="NAMED">Named variables, {'{{customer_name}}'}</option>
</select>
</label>
<label className="explorer-input">
<strong>Header</strong>
<select value={draft.header.type} onChange={(event) => patch({ header: { ...draft.header, type: event.target.value as WaTemplateDraft['header']['type'] } })}>
<option value="NONE">None</option>
<option value="TEXT">Text</option>
<option value="IMAGE">Image</option>
<option value="VIDEO">Video</option>
<option value="DOCUMENT">Document</option>
<option value="LOCATION">Location</option>
</select>
</label>
{draft.header.type === 'TEXT' && (
<label className="explorer-input">
<strong>Header text</strong>
<span>{draft.header.text.length}/60</span>
<input value={draft.header.text} maxLength={60} onChange={(event) => patch({ header: { ...draft.header, text: event.target.value } })} />
</label>
)}
{['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>
<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>
)}
<label className="explorer-input">
<strong>Message body</strong>
<span>{draft.body.length}/1024</span>
<div className="wa-tpl-format">
<button type="button" onClick={() => wrap('*')}>Bold</button>
<button type="button" onClick={() => wrap('_')}>Italic</button>
<button type="button" onClick={() => wrap('~')}>Strike</button>
<button type="button" onClick={() => wrap('```')}>Mono</button>
{['🙂', '✅', '📦', '🎉'].map((emoji) => <button type="button" key={emoji} onClick={() => patch({ body: `${draft.body}${emoji}` })}>{emoji}</button>)}
<button type="button" onClick={insertVariable}>Add variable</button>
</div>
<textarea ref={bodyRef} rows={7} value={draft.body} onChange={(event) => patch({ body: event.target.value })} />
</label>
{variableNames(draft.body).length > 0 && (
<div className="wa-tpl-samples">
{variableNames(draft.body).map((name, index) => (
<label key={name}>
<span>{`{{${name}}}`}</span>
<input value={draft.body_examples[index] || ''} placeholder="Sample value" onChange={(event) => {
const body_examples = [...draft.body_examples]
body_examples[index] = event.target.value
patch({ body_examples })
}} />
</label>
))}
</div>
)}
{draft.header.type === 'TEXT' && variableNames(draft.header.text).length > 0 && (
<div className="wa-tpl-samples">
{variableNames(draft.header.text).map((name, index) => (
<label key={name}>
<span>Header {`{{${name}}}`}</span>
<input value={draft.header.examples[index] || ''} onChange={(event) => {
const examples = [...draft.header.examples]
examples[index] = event.target.value
patch({ header: { ...draft.header, examples } })
}} />
</label>
))}
</div>
)}
<label className="explorer-input">
<strong>Footer</strong>
<span>{draft.footer.length}/60. No variables.</span>
<input value={draft.footer} maxLength={60} onChange={(event) => patch({ footer: event.target.value })} />
</label>
</>
)}
{step === 4 && (
<>
<h2>Add message actions</h2>
{draft.category === 'AUTHENTICATION' ? <p>The copy-code button from the previous step is the only action for this authentication template.</p> : (
<>
{draft.buttons.map((button, index) => (
<div className="wa-tpl-button" key={`${button.type}-${index}`}>
<strong>Button {index + 1}</strong>
<select value={button.type} onChange={(event) => {
const buttons = [...draft.buttons]
buttons[index] = { ...button, type: event.target.value as WaTemplateDraft['buttons'][number]['type'] }
patch({ buttons })
}}>
{buttonTypes(draft.category, draft.subtype).map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select>
{button.type !== 'COPY_CODE' && <input value={button.text} placeholder="Label" maxLength={25} onChange={(event) => {
const buttons = [...draft.buttons]
buttons[index] = { ...button, text: event.target.value }
patch({ buttons })
}} />}
{button.type === 'URL' && <input value={button.url} placeholder="https://example.com/orders/{{1}}" onChange={(event) => {
const buttons = [...draft.buttons]
buttons[index] = { ...button, url: event.target.value }
patch({ buttons })
}} />}
{button.type === 'URL' && variableNames(button.url).length > 0 && <input value={button.example} placeholder="https://example.com/orders/ORD12345" onChange={(event) => {
const buttons = [...draft.buttons]
buttons[index] = { ...button, example: event.target.value }
patch({ buttons })
}} />}
{button.type === 'PHONE_NUMBER' && <input value={button.phone} placeholder="+91 98765 43210" onChange={(event) => {
const buttons = [...draft.buttons]
buttons[index] = { ...button, phone: event.target.value }
patch({ buttons })
}} />}
{button.type === 'COPY_CODE' && <input value={button.example} placeholder="Offer code sample" maxLength={15} onChange={(event) => {
const buttons = [...draft.buttons]
buttons[index] = { ...button, example: event.target.value }
patch({ buttons })
}} />}
{button.type === 'FLOW' && <input value={button.flow_id} placeholder="Flow ID" onChange={(event) => {
const buttons = [...draft.buttons]
buttons[index] = { ...button, flow_id: event.target.value }
patch({ buttons })
}} />}
<span>
<button type="button" disabled={index === 0} onClick={() => {
const buttons = [...draft.buttons]
;[buttons[index - 1], buttons[index]] = [buttons[index], buttons[index - 1]]
patch({ buttons })
}}>Up</button>
<button type="button" disabled={index === draft.buttons.length - 1} onClick={() => {
const buttons = [...draft.buttons]
;[buttons[index + 1], buttons[index]] = [buttons[index], buttons[index + 1]]
patch({ buttons })
}}>Down</button>
<button type="button" onClick={() => patch({ buttons: draft.buttons.filter((_, item) => item !== index) })}>Remove</button>
</span>
</div>
))}
<label className="explorer-input">
<strong>Add button</strong>
<select value="" onChange={(event) => {
const type = event.target.value as WaTemplateDraft['buttons'][number]['type']
if (!type) return
patch({ buttons: [...draft.buttons, { type, text: '', url: '', phone: '', example: '', flow_id: '', screen: 'MAIN' }] })
}}>
<option value="">Choose a button</option>
{buttonTypes(draft.category, draft.subtype).map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select>
</label>
</>
)}
</>
)}
{step === 5 && (
<>
<h2>Review your template</h2>
<dl className="wa-tpl-review">
<div><dt>Template name</dt><dd>{draft.name}</dd></div>
<div><dt>WhatsApp account</dt><dd>{selected?.account.name || '—'}</dd></div>
<div><dt>Phone number</dt><dd>{selected?.phone?.display_phone || '—'}</dd></div>
<div><dt>Category</dt><dd>{draft.category.toLowerCase()}</dd></div>
<div><dt>Type</dt><dd>{draft.subtype.replaceAll('_', ' ')}</dd></div>
<div><dt>Language</dt><dd>{draft.language}</dd></div>
<div><dt>Header</dt><dd>{draft.category === 'AUTHENTICATION' ? 'None' : draft.header.type.toLowerCase()}</dd></div>
<div><dt>Body</dt><dd>{draft.category === 'AUTHENTICATION' ? 'Meta one-time code message' : draft.body}</dd></div>
<div><dt>Footer</dt><dd>{draft.footer || 'None'}</dd></div>
<div><dt>Buttons</dt><dd>{draft.category === 'AUTHENTICATION' ? draft.auth.copy_label : draft.buttons.map((button) => button.text || button.type).join(', ') || 'None'}</dd></div>
</dl>
<ul className="wa-tpl-checks">
{[
[!nameError(draft.name), nameError(draft.name) || 'Template name valid', 1],
[Boolean(draft.category), draft.category ? 'Category selected' : 'Category missing', 2],
[Boolean(draft.language), draft.language ? 'Language selected' : 'Language missing', 1],
[!contentError(draft), contentError(draft) || 'Message content is ready', 3],
[!buttonError(draft), buttonError(draft) || 'Buttons are ready', 4],
].map(([ok, label, target]) => (
<li key={String(label)}>
{ok ? '✓ ' : ''}{label}
{!ok && <button type="button" onClick={() => setStep(Number(target))}>Open this step</button>}
</li>
))}
</ul>
<p>Meta decides the final category and whether the template is approved.</p>
</>
)}
{step >= 2 && category && step < 5 && (
<div className="wa-tpl-guidance">
<strong>What this template is good for</strong>
<p>{category.goodFor}</p>
<strong>Supported options</strong>
<p>{category.options}</p>
</div>
)}
<div className="wa-tpl-nav">
<button type="button" className="secondary-button" onClick={() => step === 0 ? navigate('/console/whatsapp/templates') : setStep((current) => current - 1)}>
{step === 0 ? 'Cancel' : 'Previous'}
</button>
{step < 5 ? (
<button type="button" className="primary-button" disabled={Boolean(stepError)} onClick={() => setStep((current) => current + 1)}>Next</button>
) : (
<button type="button" className="primary-button" disabled={busy || Boolean(stepError)} onClick={() => void submit()}>
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
Submit for review
</button>
)}
</div>
{stepError && step < 5 && <p className="wa-tpl-step-error">{stepError}</p>}
</section>
{step >= 2 && <WaTemplatePreview draft={draft} />}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,183 @@
import { Loader2, Plus } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount } from '../../lib/api'
const STATUS_LABEL: Record<string, string> = {
APPROVED: 'Approved',
PENDING: 'Pending',
REJECTED: 'Rejected',
PAUSED: 'Paused',
DISABLED: 'Disabled',
IN_APPEAL: 'In appeal',
}
function statusLabel(status: string) {
return STATUS_LABEL[status] || status.toLowerCase()
}
export function WhatsAppTemplatesPage() {
const navigate = useNavigate()
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
const [accountId, setAccountId] = useState('')
const [rows, setRows] = useState<WaManagedTemplate[]>([])
const [summary, setSummary] = useState({ total: 0, approved: 0, pending: 0, rejected: 0, paused: 0 })
const [warnings, setWarnings] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [guidelines, setGuidelines] = useState(false)
const [search, setSearch] = useState('')
const [category, setCategory] = useState('')
const [language, setLanguage] = useState('')
const [status, setStatus] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
function load(nextAccount = accountId) {
setLoading(true)
setError('')
waTemplateApi.list(nextAccount || undefined)
.then((payload) => {
setRows(payload.templates)
setSummary(payload.summary)
setWarnings(payload.warnings)
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Templates could not be loaded'))
.finally(() => setLoading(false))
}
useEffect(() => {
whatsappApi.accounts()
.then(setAccounts)
.catch(() => setAccounts([]))
load('')
// The first load is intentionally unfiltered.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const filtered = useMemo(() => rows.filter((row) => {
const haystack = `${row.name} ${row.meta_template_id || ''}`.toLowerCase()
if (search && !haystack.includes(search.toLowerCase())) return false
if (category && row.category !== category) return false
if (language && row.language !== language) return false
if (status && row.status !== status) return false
const updated = row.updated_at?.slice(0, 10)
if (from && updated < from) return false
if (to && updated > to) return false
return true
}), [rows, search, category, language, status, from, to])
const languages = [...new Set(rows.map((row) => row.language))].sort()
const cards = [
['Total templates', summary.total],
['Approved', summary.approved],
['Pending review', summary.pending],
['Rejected', summary.rejected],
['Paused', summary.paused],
] as const
async function remove(row: WaManagedTemplate) {
if (!window.confirm(`Delete ${row.name}? Meta removes it from this WhatsApp account.`)) return
try {
await waTemplateApi.remove(row.id)
load()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'This template could not be deleted')
}
}
return (
<div className="wa-tpl">
<div className="account-heading wa-tpl-heading">
<div>
<span className="eyebrow">Communicate</span>
<h1>Message templates</h1>
<p>Create, manage, and monitor your WhatsApp message templates.</p>
</div>
<div className="wa-tpl-heading-actions">
<button type="button" className="secondary-button" onClick={() => setGuidelines((open) => !open)}>Template guidelines</button>
<Link className="primary-button" to="/console/whatsapp/templates/new"><Plus size={16} /> Create template</Link>
</div>
</div>
{guidelines && (
<section className="wa-biz-card wa-tpl-guidelines">
<h2>Template guidelines</h2>
<ul>
<li>Marketing is for offers and announcements. Utility is for an existing order or account. Authentication is only for one-time codes.</li>
<li>Names use lowercase letters, numbers, and underscores, and must be unique on the WhatsApp account for that language.</li>
<li>Meta reviews every template. A template stays pending until Meta approves it, and Meta can change the category.</li>
<li>Templates belong to the WhatsApp Business account, so every number on that account can use an approved template.</li>
</ul>
</section>
)}
{error && <p className="auth-error">{error}</p>}
{warnings.map((warning) => <p className="auth-error" key={warning}>{warning}</p>)}
<div className="wa-tpl-cards">
{cards.map(([label, value]) => (
<article key={label}>
<span>{label}</span>
<strong>{value}</strong>
</article>
))}
</div>
<section className="wa-biz-card">
<div className="wa-tpl-filters">
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search by name or ID" aria-label="Search templates" />
<select value={accountId} onChange={(event) => { setAccountId(event.target.value); load(event.target.value) }} aria-label="WhatsApp account">
<option value="">All accounts</option>
{accounts.map((account) => <option key={account.id} value={account.id}>{account.name}</option>)}
</select>
<select value={category} onChange={(event) => setCategory(event.target.value)} aria-label="Category">
<option value="">Category</option>
<option value="MARKETING">Marketing</option>
<option value="UTILITY">Utility</option>
<option value="AUTHENTICATION">Authentication</option>
</select>
<select value={language} onChange={(event) => setLanguage(event.target.value)} aria-label="Language">
<option value="">Language</option>
{languages.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<select value={status} onChange={(event) => setStatus(event.target.value)} aria-label="Status">
<option value="">Status</option>
{Object.entries(STATUS_LABEL).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<input type="date" value={from} onChange={(event) => setFrom(event.target.value)} aria-label="Updated from" />
<input type="date" value={to} onChange={(event) => setTo(event.target.value)} aria-label="Updated to" />
</div>
{loading && <div className="auth-loading"><Loader2 size={16} className="wa-spin" /> Loading templates…</div>}
{!loading && accounts.length === 0 && <p>Connect a WhatsApp number before creating templates. <Link to="/console/whatsapp">Open WhatsApp</Link></p>}
{!loading && accounts.length > 0 && filtered.length === 0 && <p>{rows.length === 0 ? 'No templates yet. Create one and Meta will review it.' : 'No templates match these filters.'}</p>}
{!loading && filtered.length > 0 && (
<div className="wa-tpl-table">
<div className="wa-tpl-row wa-tpl-head">
<span>Template name</span><span>Category</span><span>Language</span><span>Status</span><span>Quality</span><span>Messages sent</span><span>Last updated</span><span>Actions</span>
</div>
{filtered.map((row) => (
<div className="wa-tpl-row" key={row.id}>
<button type="button" className="wa-tpl-name" onClick={() => navigate(`/console/whatsapp/templates/${row.id}`)}>{row.name}</button>
<span>{row.category.toLowerCase()}</span>
<span>{row.language}</span>
<span className={`wa-tpl-status is-${row.status.toLowerCase()}`}>{statusLabel(row.status)}</span>
<span>{row.quality || '—'}</span>
<span>{row.messages_sent}</span>
<span>{row.updated_at ? new Date(row.updated_at).toLocaleDateString() : '—'}</span>
<span className="wa-tpl-actions">
<button type="button" onClick={() => navigate(`/console/whatsapp/templates/${row.id}`)}>View</button>
{['REJECTED', 'PAUSED', 'APPROVED'].includes(row.status) && <button type="button" onClick={() => navigate(`/console/whatsapp/templates/${row.id}/edit`)}>Edit</button>}
<button type="button" onClick={() => navigate(`/console/whatsapp/templates/new?duplicate=${row.id}`)}>Duplicate</button>
<button type="button" onClick={() => void remove(row)}>Delete</button>
</span>
</div>
))}
</div>
)}
</section>
</div>
)
}

View file

@ -0,0 +1,157 @@
import type { WaTemplateDraft } from '../../lib/api'
export const DRAFT_KEY = 'urmi.waTemplateDraft'
export const CATEGORIES = [
{
id: 'MARKETING' as const,
title: 'Marketing',
description: 'Promotions, product announcements, offers, and customer engagement.',
subtypes: [
['default', 'Default'],
['catalog', 'Catalog'],
['flows', 'Flows'],
['order_details', 'Order details'],
['calling_permissions', 'Calling permissions request'],
],
goodFor: 'Offers, product news, and invitations people have agreed to receive.',
options: 'Header, body variables, footer, website, phone, quick reply, and offer code.',
},
{
id: 'UTILITY' as const,
title: 'Utility',
description: 'Transactional notifications related to an existing customer interaction.',
subtypes: [
['default', 'Default'],
['flows', 'Flows'],
['order_status', 'Order status'],
['order_details', 'Order details'],
['calling_permissions', 'Calling permissions request'],
],
goodFor: 'Order updates, account notices, and appointment reminders.',
options: 'Header, body variables, footer, website, phone, and quick reply.',
},
{
id: 'AUTHENTICATION' as const,
title: 'Authentication',
description: 'One-time passwords and account verification messages.',
subtypes: [['otp', 'One-time passcode']],
goodFor: 'Sending a one-time code the customer can copy.',
options: 'Security recommendation, code expiry, and a copy-code button. The message wording is set by Meta.',
},
]
export function emptyDraft(): WaTemplateDraft {
return {
account_id: '',
phone_number_id: '',
name: '',
language: 'en_US',
category: '',
subtype: 'default',
parameter_format: 'POSITIONAL',
header: { type: 'NONE', text: '', examples: [], handle: '', preview_name: '', preview_url: '' },
body: '',
body_examples: [],
footer: '',
buttons: [],
auth: { otp_type: 'COPY_CODE', add_security_recommendation: true, code_expiration_minutes: 10, copy_label: 'Copy code' },
}
}
export function variableNames(text: string): string[] {
const found: string[] = []
const pattern = /\{\{([A-Za-z0-9_]+)\}\}/g
for (const match of text.matchAll(pattern)) {
if (!found.includes(match[1])) found.push(match[1])
}
return found
}
export function withSamples(text: string, samples: string[]): string {
const names = variableNames(text)
return names.reduce((current, name, index) => current.replaceAll(`{{${name}}}`, samples[index] || `{{${name}}}`), text)
}
export function buttonTypes(category: string, subtype: string): { id: WaTemplateDraft['buttons'][number]['type']; label: string }[] {
if (category === 'AUTHENTICATION') return []
const items: { id: WaTemplateDraft['buttons'][number]['type']; label: string }[] = [
{ id: 'URL', label: 'Visit website' },
{ id: 'PHONE_NUMBER', label: 'Call phone number' },
{ id: 'QUICK_REPLY', label: 'Quick reply' },
]
if (category === 'MARKETING') items.push({ id: 'COPY_CODE', label: 'Copy offer code' })
if (subtype === 'catalog') items.push({ id: 'CATALOG', label: 'View catalog' })
if (subtype === 'flows') items.push({ id: 'FLOW', label: 'WhatsApp Flow' })
return items
}
export function nameError(name: string): string {
if (!name.trim()) return 'Template name is required.'
if (!/^[a-z0-9_]+$/.test(name.trim())) return 'Use lowercase letters, numbers, and underscores only.'
if (name.trim().length > 512) return 'Name can be at most 512 characters.'
return ''
}
export function contentError(draft: WaTemplateDraft): string {
if (draft.category === 'AUTHENTICATION') {
const minutes = Number(draft.auth.code_expiration_minutes)
if (minutes && (minutes < 1 || minutes > 90)) return 'Code expiry should be between 1 and 90 minutes.'
if (!draft.auth.copy_label.trim() || draft.auth.copy_label.trim().length > 25) return 'Copy button label should be 1 to 25 characters.'
return ''
}
if (!draft.body.trim()) return 'Message body is required.'
if (draft.body.length > 1024) return 'Message body can be at most 1,024 characters.'
if (draft.footer.length > 60) return 'Footer can be at most 60 characters.'
if (variableNames(draft.footer).length) return 'Footer cannot contain variables.'
if (draft.header.type === 'TEXT') {
if (!draft.header.text.trim()) return 'Header text is required.'
if (draft.header.text.length > 60) return 'Header text can be at most 60 characters.'
if (variableNames(draft.header.text).some((_, index) => !draft.header.examples[index]?.trim())) return 'Header variables need sample values.'
}
if (['IMAGE', 'VIDEO', 'DOCUMENT'].includes(draft.header.type) && !draft.header.handle) return 'Upload a header file.'
const names = variableNames(draft.body)
if (draft.parameter_format === 'POSITIONAL' && names.some((name, index) => name !== String(index + 1))) {
return 'Numbered variables must be {{1}}, {{2}}, and so on, with no gaps.'
}
if (draft.parameter_format === 'NAMED' && names.some((name) => /^\d+$/.test(name))) return 'Named variables need a name, such as customer_name.'
if (names.some((_, index) => !draft.body_examples[index]?.trim())) return 'Every body variable needs a sample value.'
return ''
}
export function buttonError(draft: WaTemplateDraft): string {
if (draft.category === 'AUTHENTICATION') return ''
if (draft.buttons.length > 10) return 'A template can have at most 10 buttons.'
let urls = 0
let phones = 0
let copies = 0
for (const button of draft.buttons) {
if (button.type !== 'COPY_CODE' && (!button.text.trim() || button.text.trim().length > 25)) return 'Each button label should be 1 to 25 characters.'
if (button.type === 'URL') {
urls += 1
if (!button.url.startsWith('https://')) return 'Website buttons need an https URL.'
const urlNames = variableNames(button.url)
if (urlNames.length > 1) return 'A website button can use one variable.'
if (urlNames.length === 1 && !button.url.endsWith(`{{${urlNames[0]}}}`)) return 'Put the website variable at the end of the URL.'
if (urlNames.length && !button.example.trim().startsWith('https://')) return 'Sample URL should be a full https address with the variable filled in.'
}
if (button.type === 'PHONE_NUMBER') {
phones += 1
if (button.phone.replace(/\D/g, '').length < 8) return 'Enter a phone number with the country code.'
}
if (button.type === 'COPY_CODE') {
copies += 1
if (!button.example.trim() || button.example.trim().length > 15) return 'Offer code sample should be 1 to 15 characters.'
}
if (button.type === 'FLOW' && !button.flow_id.trim()) return 'A WhatsApp Flow button needs the Flow ID from Meta.'
}
if (urls > 2 || phones > 1 || copies > 1) return 'Use at most two website buttons, one phone button, and one offer code.'
const kinds = draft.buttons.map((button) => button.type)
const quick = kinds.flatMap((kind, index) => kind === 'QUICK_REPLY' ? [index] : [])
if (quick.length) {
const together = quick.every((value, index) => value === quick[0] + index)
const atEdge = quick[0] === 0 || quick[0] === kinds.length - quick.length
if (!together || !atEdge) return 'Keep quick reply buttons together, either all at the start or all at the end.'
}
return ''
}