template
This commit is contained in:
parent
8037489503
commit
ad189b8568
15 changed files with 1989 additions and 4 deletions
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue