template
This commit is contained in:
parent
8037489503
commit
ad189b8568
15 changed files with 1989 additions and 4 deletions
44
frontend/src/features/whatsapp/WaTemplatePreview.tsx
Normal file
44
frontend/src/features/whatsapp/WaTemplatePreview.tsx
Normal 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(', ')
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
111
frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx
Normal file
111
frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
497
frontend/src/features/whatsapp/WhatsAppTemplateWizard.tsx
Normal file
497
frontend/src/features/whatsapp/WhatsAppTemplateWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx
Normal file
183
frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
frontend/src/features/whatsapp/templateModel.ts
Normal file
157
frontend/src/features/whatsapp/templateModel.ts
Normal 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 ''
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue