whatsapp broadcast

This commit is contained in:
debu-2 2026-10-13 03:14:52 +05:30
parent 63011f7d96
commit 70ccb720aa
9 changed files with 570 additions and 4 deletions

View file

@ -24,14 +24,17 @@ from .whatsapp_onboarding import (
account_logs,
complete_signup,
connect_with_token,
create_marketing_template,
customer_token_for_phone,
developer_config,
disconnect_account,
download_account_media,
enable_customer_delivery,
list_account_messages,
list_accounts,
mask_identifier,
register_phone_number,
send_account_broadcast,
send_account_message,
list_templates,
refresh_account,
@ -180,6 +183,20 @@ class SendWhatsAppTextRequest(BaseModel):
phone_number_id: str = Field(min_length=4, max_length=64)
class MarketingTemplateRequest(BaseModel):
name: str = Field(min_length=1, max_length=512)
language: str = Field(default="en", max_length=16)
body: str = Field(min_length=1, max_length=1024)
class BroadcastSendRequest(BaseModel):
template_name: str = Field(min_length=1, max_length=512)
template_language: str = Field(default="en", max_length=16)
recipients: list[str] = Field(min_length=1, max_length=20)
body_parameters: list[str] = Field(default_factory=list, max_length=10)
product_policy: str = "STRICT"
class CustomerSendRequest(BaseModel):
to: str = Field(min_length=8, max_length=20)
message_type: str = "text"
@ -200,6 +217,10 @@ class RegisterPhoneRequest(BaseModel):
pin: str | None = Field(default=None, max_length=6)
class DeliverySecretRequest(BaseModel):
app_secret: str = Field(min_length=8, max_length=256)
class ManualTokenRequest(BaseModel):
access_token: str = Field(min_length=1, max_length=4096)
waba_id: str = Field(min_length=1, max_length=64)
@ -285,6 +306,16 @@ def whatsapp_account_messages(
return list_account_messages(db, user, account_id, limit)
@router.post("/accounts/{account_id}/delivery")
async def whatsapp_account_delivery(
account_id: str,
payload: DeliverySecretRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await enable_customer_delivery(db, user, account_id, payload.app_secret)
@router.post("/accounts/{account_id}/messages/send")
async def whatsapp_account_send(
account_id: str,
@ -295,6 +326,35 @@ async def whatsapp_account_send(
return await send_account_message(db, user, account_id, payload.model_dump())
@router.post("/accounts/{account_id}/templates")
async def whatsapp_account_create_template(
account_id: str,
payload: MarketingTemplateRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await create_marketing_template(db, user, account_id, payload.name, payload.language, payload.body)
@router.post("/accounts/{account_id}/broadcasts")
async def whatsapp_account_broadcast(
account_id: str,
payload: BroadcastSendRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await send_account_broadcast(
db,
user,
account_id,
payload.template_name,
payload.template_language,
payload.recipients,
payload.body_parameters,
payload.product_policy,
)
@router.get("/accounts/{account_id}/media/{media_id}")
async def whatsapp_account_media(
account_id: str,

View file

@ -123,6 +123,33 @@ class WhatsAppGraphClient:
}
)
async def send_marketing_template(
self,
to: str,
name: str,
language_code: str = "en",
components: list[dict[str, Any]] | None = None,
product_policy: str = "STRICT",
) -> dict[str, Any]:
"""Send a marketing template through Meta's marketing endpoint, not /messages."""
if not self.configured:
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
template: dict[str, Any] = {"name": name, "language": {"code": language_code}}
if components:
template["components"] = components
return await self._request(
"POST",
self._graph_url(f"{self.phone_number_id}/marketing_messages"),
json_payload={
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "template",
"template": template,
"product_policy": product_policy,
},
)
async def send_interactive_buttons(
self,
to: str,

View file

@ -1027,6 +1027,60 @@ async def list_templates(db: Session, user: UserAccount, account_id: Any) -> dic
return {"available": True, "templates": templates}
_TEMPLATE_NAME = re.compile(r"^[a-z0-9_]{1,512}$")
async def create_marketing_template(
db: Session,
user: UserAccount,
account_id: Any,
name: str,
language: str,
body: str,
) -> dict[str, Any]:
"""Submit a marketing template to Meta for the connected WhatsApp account."""
account = _owned_account(db, user, account_id)
connection = account.connection
token = connection.access_token if connection else None
if connection is None or not token or connection.status == "disconnected":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the access token again before creating a template.")
template_name = name.strip().lower()
template_language = language.strip() or "en"
text = body.strip()
if not _TEMPLATE_NAME.match(template_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template name should use lowercase letters, numbers, and underscores.",
)
if not re.fullmatch(r"[a-z]{2}(_[A-Z]{2})?", template_language):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Language should look like en or en_US.")
if not text or len(text) > 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Template text should be 1 to 1024 characters.")
if "{{" in text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This test template cannot include placeholders such as {{1}}.",
)
data = await _graph(
"POST",
f"{account.waba_id}/message_templates",
token=token,
json_body={
"name": template_name,
"language": template_language,
"category": "MARKETING",
"components": [{"type": "BODY", "text": text}],
},
)
return {
"id": data.get("id"),
"name": template_name,
"language": template_language,
"category": str(data.get("category") or "MARKETING"),
"status": str(data.get("status") or "PENDING"),
}
def account_logs(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
audits = db.scalars(
@ -1127,6 +1181,31 @@ def _messaging_phone(account: WhatsAppBusinessAccount) -> tuple[WhatsAppPhoneAss
return phone, token
async def enable_customer_delivery(db: Session, user: UserAccount, account_id: Any, app_secret: str) -> dict[str, Any]:
"""Point this account's replies and delivery updates at the workspace."""
account = _owned_account(db, user, account_id)
connection = account.connection
token = connection.access_token if connection else None
if connection is None or not token or connection.status == "disconnected":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the access token again before replies can arrive here.")
secret = "".join(str(app_secret or "").split())
if len(secret) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Paste the App secret from the Meta app that created this token.",
)
connection.meta_app_secret = secret
phone_ids = [phone.phone_number_id for phone in account.phones]
receiving = await _attach_customer_webhook(token, account.waba_id, phone_ids)
account.webhooks_status = "active" if receiving else "unavailable"
account.last_error = None if receiving else "Meta did not accept the delivery address for replies."
db.commit()
db.refresh(account)
if not receiving:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=account.last_error)
return serialize_account(account)
async def send_account_message(db: Session, user: UserAccount, account_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
phone, token = _messaging_phone(account)
@ -1201,6 +1280,93 @@ async def send_account_message(db: Session, user: UserAccount, account_id: Any,
return serialize_message(message)
async def send_account_broadcast(
db: Session,
user: UserAccount,
account_id: Any,
template_name: str,
template_language: str,
recipients: list[str],
body_parameters: list[str] | None = None,
product_policy: str = "STRICT",
) -> dict[str, Any]:
"""Send a marketing template through Meta's marketing endpoint."""
account = _owned_account(db, user, account_id)
phone, token = _messaging_phone(account)
name = template_name.strip()
language = template_language.strip() or "en"
policy = product_policy if product_policy in {"STRICT", "CLOUD_API_FALLBACK"} else "STRICT"
numbers = []
for raw in recipients:
piece = str(raw or "").strip()
if piece:
numbers.append(piece)
if not name:
raise HTTPException(status_code=422, detail="Choose a marketing template.")
if not numbers:
raise HTTPException(status_code=422, detail="Add at least one recipient.")
if len(numbers) > 20:
raise HTTPException(status_code=422, detail="A test broadcast can include up to 20 numbers.")
parameters = [str(item).strip() for item in (body_parameters or []) if str(item).strip()]
components = None
if parameters:
components = [
{
"type": "body",
"parameters": [{"type": "text", "text": item} for item in parameters],
}
]
client = WhatsAppGraphClient(
access_token=token,
phone_number_id=phone.phone_number_id,
business_account_id=account.waba_id,
)
try:
from_number = normalize_wa_number(phone.display_phone or "")
except ValueError:
from_number = phone.display_phone or phone.phone_number_id
results: list[dict[str, Any]] = []
for raw in numbers:
try:
recipient = normalize_wa_number(raw)
except ValueError:
results.append({"to": raw, "status": "failed", "error": "Enter a valid WhatsApp number."})
continue
try:
result = await client.send_marketing_template(
recipient,
name,
language_code=language,
components=components,
product_policy=policy,
)
except WhatsAppGraphError as exc:
results.append({"to": recipient, "status": "failed", "error": exc.detail})
continue
message = record_outbound_message(
db,
to_number=recipient,
body=f"Broadcast: {name}",
message_type="template",
wa_message_id=extract_wa_message_id(result),
status="sent",
payload=result if isinstance(result, dict) else None,
from_number=from_number,
user_id=user.id,
phone_number_id=phone.phone_number_id,
)
results.append({"to": recipient, "status": "sent", "message_id": message.wa_message_id})
sent = sum(1 for item in results if item["status"] == "sent")
return {
"template_name": name,
"template_language": language,
"product_policy": policy,
"sent": sent,
"failed": len(results) - sent,
"results": results,
}
async def download_account_media(db: Session, user: UserAccount, account_id: Any, media_id: str) -> tuple[bytes, str]:
account = _owned_account(db, user, account_id)
token = account.connection.access_token if account.connection else None

View file

@ -19,6 +19,7 @@ import { TestingCenterPage } from './features/testing/TestingCenterPage'
import { LogsPage } from './features/logs/LogsPage'
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
import { WhatsAppBroadcastPage } from './features/whatsapp/WhatsAppBroadcastPage'
import { WhatsAppTokenPage } from './features/whatsapp/WhatsAppTokenPage'
import { MetaOverviewPage } from './features/meta/OverviewPage'
import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
@ -74,6 +75,7 @@ export default function App() {
<Route path="templates" element={<TemplatesPage />} />
<Route path="messaging" element={<ComposeMessage />} />
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
<Route path="whatsapp/broadcast" element={<WhatsAppBroadcastPage />} />
<Route path="whatsapp/token" element={<WhatsAppTokenPage />} />
<Route path="meta" element={<MetaOverviewPage />} />
<Route path="meta/connections" element={<MetaConnectionsPage />} />

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import {
Activity, BarChart3, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
Code2, Cog, CreditCard, Cpu, FileCheck2, FileText, Gauge, Globe, Image, KeyRound, LayoutDashboard,
Link2, LogOut, Megaphone, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck,
Link2, LogOut, Megaphone, Menu, MessageSquareText, Network, Phone, Search, Send, Settings, ShieldCheck,
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
MessageCircle,
} from 'lucide-react'
@ -31,6 +31,7 @@ const groups = [
{ label: 'Messages', links: [
{ label: 'Send a message', path: '/console/messaging', icon: MessageSquareText },
{ label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle, end: true },
{ label: 'WhatsApp broadcast', path: '/console/whatsapp/broadcast', icon: Send },
{ label: 'WhatsApp token', path: '/console/whatsapp/token', icon: KeyRound },
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap },
{ label: 'Templates', path: '/console/templates', icon: BookOpen },

View file

@ -109,8 +109,26 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
const [reactFor, setReactFor] = useState<string | null>(null)
const [emojiOpen, setEmojiOpen] = useState(false)
const chatEndRef = useRef<HTMLDivElement | null>(null)
const line = account.primary_phone?.display_phone || account.name
const canSend = account.messaging_status === 'active' && account.usable
const [deliveryAccount, setDeliveryAccount] = useState(account)
const [appSecret, setAppSecret] = useState('')
const [deliveryBusy, setDeliveryBusy] = useState(false)
const line = deliveryAccount.primary_phone?.display_phone || deliveryAccount.name
const canSend = deliveryAccount.messaging_status === 'active' && deliveryAccount.usable
const repliesLive = deliveryAccount.webhooks_status === 'active'
async function saveDelivery(event: FormEvent) {
event.preventDefault()
setDeliveryBusy(true)
setError('')
try {
setDeliveryAccount(await whatsappApi.enableDelivery(account.id, appSecret.trim()))
setAppSecret('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Replies could not be turned on')
} finally {
setDeliveryBusy(false)
}
}
async function loadMessages() {
setMessages(await whatsappApi.messages(account.id))
@ -272,7 +290,17 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
<button type="button" className="wa-ghost" onClick={() => void loadMessages()}><RefreshCw size={14} /> Refresh</button>
</div>
</div>
{!canSend && <p className="wa-biz-notice">{account.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}</p>}
{!canSend && <p className="wa-biz-notice">{deliveryAccount.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}</p>}
{canSend && !repliesLive && (
<form className="wa-biz-notice" onSubmit={(event) => void saveDelivery(event)}>
<p>WhatsApp accepted messages from this number, but delivery updates and replies are still going to the Meta app that created the token. Paste that app’s App secret so they arrive here. In Meta: the app → App settings → Basic → App secret.</p>
<label className="explorer-input">
<strong>App secret</strong>
<input type="password" value={appSecret} onChange={(event) => setAppSecret(event.target.value)} autoComplete="off" required />
</label>
<button className="primary-button" type="submit" disabled={deliveryBusy}>Turn on replies</button>
</form>
)}
<div className="wa-inbox">
<aside className="wa-list">
<div className="wa-list-head">

View file

@ -0,0 +1,251 @@
import { Loader2 } from 'lucide-react'
import { useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { whatsappApi, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck'
const TEAM_TEMPLATE_BODY = "Team, the broadcast desk is on air. This is the first note Urmi is sending through Meta's marketing line, and it is written for the people building it. Read it on your phone, then bring it to standup so we can close the test."
type BroadcastResult = {
template_name: string
sent: number
failed: number
results: { to: string; status: string; error?: string; message_id?: string | null }[]
}
type CreatedTemplate = {
name: string
status: string
}
export function WhatsAppBroadcastPage() {
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
const [accountId, setAccountId] = useState('')
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
const [templatesReady, setTemplatesReady] = useState(false)
const [templateName, setTemplateName] = useState(TEAM_TEMPLATE_NAME)
const [templateLanguage, setTemplateLanguage] = useState('en')
const [draftName, setDraftName] = useState(TEAM_TEMPLATE_NAME)
const [draftBody, setDraftBody] = useState(TEAM_TEMPLATE_BODY)
const [creating, setCreating] = useState(false)
const [created, setCreated] = useState<CreatedTemplate | null>(null)
const [templateTick, setTemplateTick] = useState(0)
const [recipients, setRecipients] = useState('')
const [bodyValues, setBodyValues] = useState('')
const [strict, setStrict] = useState(true)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState<BroadcastResult | null>(null)
useEffect(() => {
whatsappApi.accounts()
.then((rows) => {
const ready = rows.filter((row) => row.messaging_status === 'active' && row.phones.some((phone) => phone.status === 'connected'))
setAccounts(ready)
if (ready[0]) setAccountId(ready[0].id)
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Could not load WhatsApp accounts'))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!accountId) return
setTemplatesReady(false)
whatsappApi.templates(accountId)
.then((payload) => {
const marketing = payload.templates.filter((item) => (item.category || '').toUpperCase() === 'MARKETING')
setTemplates(marketing)
setTemplatesReady(payload.available)
const approved = marketing.find((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name)
if (approved?.name) {
setTemplateName(approved.name)
setTemplateLanguage(approved.language || 'en')
}
})
.catch(() => {
setTemplates([])
setTemplatesReady(false)
})
}, [accountId, templateTick])
async function createTemplate(event: FormEvent) {
event.preventDefault()
setCreating(true)
setError('')
setCreated(null)
try {
const payload = await whatsappApi.createMarketingTemplate(accountId, {
name: draftName.trim(),
language: 'en',
body: draftBody.trim(),
})
setCreated(payload)
setTemplateName(payload.name)
setTemplateLanguage(payload.language || 'en')
setTemplateTick((value) => value + 1)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Meta did not accept this template')
} finally {
setCreating(false)
}
}
async function submit(event: FormEvent) {
event.preventDefault()
setBusy(true)
setError('')
setResult(null)
const numbers = recipients.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)
const parameters = bodyValues.split('\n').map((item) => item.trim()).filter(Boolean)
try {
const payload = await whatsappApi.sendBroadcast(accountId, {
template_name: templateName.trim(),
template_language: templateLanguage.trim() || 'en',
recipients: numbers,
body_parameters: parameters,
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
})
setResult(payload)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Meta did not accept this broadcast')
} finally {
setBusy(false)
}
}
const approved = templates.filter((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name)
const pending = templates.filter((item) => (item.status || '').toUpperCase() !== 'APPROVED' && item.name)
return (
<div className="wa-biz wa-token-page">
<div className="account-heading">
<span className="eyebrow">Messages</span>
<h1>WhatsApp broadcast</h1>
<p>Sends a marketing template through Meta’s marketing endpoint. Chat replies stay on the WhatsApp page.</p>
</div>
{!loading && accounts.length > 0 && (
<form className="wa-biz-card" onSubmit={createTemplate}>
<h2>Create the template here</h2>
<p>Urmi submits this to Meta as a Marketing template on the connected account. Meta still has to approve it before a broadcast can send. That usually takes a few minutes.</p>
<div className="wa-token-form">
<label className="explorer-input">
<strong>Template name</strong>
<span>Lowercase letters, numbers, and underscores. Meta uses this name when you send.</span>
<input value={draftName} onChange={(event) => setDraftName(event.target.value)} required />
</label>
<label className="explorer-input">
<strong>Message</strong>
<span>English, no header, buttons, or placeholders. Written for the internal team.</span>
<textarea value={draftBody} onChange={(event) => setDraftBody(event.target.value)} rows={5} required />
</label>
<button className="primary-button" type="submit" disabled={creating || !accountId}>
{creating ? <Loader2 size={16} className="wa-spin" /> : null}
Submit template to Meta
</button>
</div>
{created && (
<p className="wa-token-result">{created.name} is {created.status.toLowerCase()}. Send it after Meta marks it approved.</p>
)}
</form>
)}
{error && <p className="auth-error">{error}</p>}
{loading && <div className="auth-loading">Loading accounts…</div>}
{!loading && accounts.length === 0 && (
<section className="panel placeholder">
<p>Connect a WhatsApp number before sending a broadcast.</p>
<Link className="primary-button" to="/console/whatsapp/token">Add a WhatsApp token</Link>
</section>
)}
{!loading && accounts.length > 0 && (
<form className="wa-biz-card" onSubmit={submit}>
<div className="wa-token-form">
<label className="explorer-input">
<strong>Account</strong>
<span>The connected number this broadcast is sent from.</span>
<select value={accountId} onChange={(event) => setAccountId(event.target.value)}>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name} · {account.primary_phone?.display_phone || account.phones[0]?.display_phone || 'WhatsApp'}
</option>
))}
</select>
</label>
<label className="explorer-input">
<strong>Marketing template</strong>
<span>
{templatesReady
? approved.length
? 'Approved marketing templates on this account.'
: pending.length
? `${pending.map((item) => item.name).join(', ')} is waiting for Meta’s approval.`
: 'No approved marketing template yet. Submit the team message above, then come back when Meta approves it.'
: 'Template list is unavailable. You can still type the approved template name.'}
</span>
{approved.length > 0 ? (
<select
value={templateName}
onChange={(event) => {
const next = approved.find((item) => item.name === event.target.value)
setTemplateName(event.target.value)
if (next?.language) setTemplateLanguage(next.language)
}}
>
{approved.map((item) => (
<option key={`${item.name}-${item.language}`} value={item.name || ''}>
{item.name} ({item.language || 'en'})
</option>
))}
</select>
) : (
<input value={templateName} onChange={(event) => setTemplateName(event.target.value)} required />
)}
</label>
<label className="explorer-input">
<strong>Language</strong>
<span>Use the language code Meta shows on the template, such as en.</span>
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} required />
</label>
<label className="explorer-input">
<strong>Recipients</strong>
<span>One WhatsApp number per line, up to 20. Include the country code.</span>
<textarea value={recipients} onChange={(event) => setRecipients(event.target.value)} rows={4} required placeholder="9198XXXXXXXX" />
</label>
<label className="explorer-input">
<strong>Body values</strong>
<span>Leave this empty for the team aircheck. If a template has {'{{1}}'}, {'{{2}}'}, put one value per line in that order.</span>
<textarea value={bodyValues} onChange={(event) => setBodyValues(event.target.value)} rows={3} />
</label>
<label className="explorer-input">
<strong>Marketing endpoint only</strong>
<span>Leave this on to test Meta’s marketing API. Turn it off only if Meta says this account is not onboarded yet and you still want the message delivered.</span>
<input type="checkbox" checked={strict} onChange={(event) => setStrict(event.target.checked)} />
</label>
<button className="primary-button" type="submit" disabled={busy || !accountId}>
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
Send broadcast
</button>
</div>
</form>
)}
{result && (
<section className="wa-biz-card wa-token-result">
<h2>{result.sent} sent, {result.failed} failed</h2>
<ul className="wa-broadcast-results">
{result.results.map((item) => (
<li key={`${item.to}-${item.message_id || item.error || item.status}`}>
<span>{item.to}</span>
<span>{item.status === 'sent' ? 'Sent' : item.error || 'Failed'}</span>
</li>
))}
</ul>
</section>
)}
</div>
)
}

View file

@ -1848,6 +1848,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-token-form { display: grid; gap: 12px; margin-top: 16px; }
.wa-token-form .primary-button { justify-self: start; display: inline-flex; align-items: center; gap: 8px; }
.wa-token-page { max-width: 720px; }
.wa-broadcast-steps { margin: 0; padding-left: 18px; display: grid; gap: 6px; }
.wa-broadcast-results { display: grid; gap: 8px; margin-top: 12px; }
.wa-broadcast-results li { display: flex; justify-content: space-between; gap: 12px; }
.wa-token-result { margin-top: 16px; }
.wa-token-result .primary-button { margin-top: 8px; text-decoration: none; display: inline-flex; }
.wa-biz-heading h1 { margin: 6px 0; }

View file

@ -1290,6 +1290,11 @@ export const whatsappApi = {
method: 'POST',
body: JSON.stringify({ pin: pin || null }),
}),
enableDelivery: (accountId: string, appSecret: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/delivery`, {
method: 'POST',
body: JSON.stringify({ app_secret: appSecret }),
}),
messages: (accountId: string) =>
jsonRequest<CustomerWhatsAppMessage[]>(`/api/v1/whatsapp/accounts/${accountId}/messages`),
sendAccountMessage: (accountId: string, payload: CustomerWhatsAppSend) =>
@ -1299,6 +1304,29 @@ export const whatsappApi = {
}),
templates: (accountId: string) =>
jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`),
createMarketingTemplate: (accountId: string, payload: { name: string; language: string; body: string }) =>
jsonRequest<{ id?: string | null; name: string; language: string; category: string; status: string }>(
`/api/v1/whatsapp/accounts/${accountId}/templates`,
{ method: 'POST', body: JSON.stringify(payload) },
),
sendBroadcast: (accountId: string, payload: {
template_name: string
template_language: string
recipients: string[]
body_parameters?: string[]
product_policy?: 'STRICT' | 'CLOUD_API_FALLBACK'
}) =>
jsonRequest<{
template_name: string
template_language: string
product_policy: string
sent: number
failed: number
results: { to: string; status: string; error?: string; message_id?: string | null }[]
}>(`/api/v1/whatsapp/accounts/${accountId}/broadcasts`, {
method: 'POST',
body: JSON.stringify(payload),
}),
logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`),
developer: (accountId: string) => jsonRequest<WhatsAppDeveloperConfig>(`/api/v1/whatsapp/accounts/${accountId}/developer`),
sendText: (payload: { to: string; body: string; phone_number_id: string }) =>