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

View file

@ -197,6 +197,7 @@ class BroadcastSendRequest(BaseModel):
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"
use_sample_header: bool = False
class CustomerSendRequest(BaseModel):
@ -354,6 +355,7 @@ async def whatsapp_account_broadcast(
payload.recipients,
payload.body_parameters,
payload.product_policy,
payload.use_sample_header,
)

View file

@ -1,6 +1,7 @@
from typing import Any
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@ -8,11 +9,13 @@ from .auth import current_user
from .database import get_db
from .models import UserAccount
from .whatsapp_template_service import (
attach_template_sample,
create_managed_template,
delete_managed_template,
get_managed_template,
language_catalog,
list_managed_templates,
read_template_sample,
resubmit_managed_template,
upload_header_media,
)
@ -79,6 +82,27 @@ async def whatsapp_template_create(
return await create_managed_template(db, user, payload.model_dump())
@router.post("/{template_id}/sample")
async def whatsapp_template_sample(
template_id: str,
file: UploadFile = File(),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
data = await file.read()
return attach_template_sample(db, user, template_id, file.filename or "header", file.content_type or "", data)
@router.get("/{template_id}/sample")
def whatsapp_template_sample_file(
template_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> Response:
data, mime, _name = read_template_sample(db, user, template_id)
return Response(content=data, media_type=mime)
@router.post("/{template_id}/resubmit")
async def whatsapp_template_resubmit(
template_id: str,

View file

@ -104,6 +104,30 @@ class WhatsAppGraphClient:
payload["context"] = {"message_id": reply_to}
return await self._send_message(payload)
async def upload_media(self, filename: str, content_type: str, data: bytes) -> str:
"""Upload a file for a template header. Returns a media id that one send can use."""
if not self.configured:
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
url = self._graph_url(f"{self.phone_number_id}/media")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
url,
data={"messaging_product": "whatsapp", "type": content_type},
files={"file": (filename or "header", data, content_type)},
headers={"Authorization": f"Bearer {self.access_token}"},
)
try:
body = response.json()
except ValueError:
body = {}
if response.status_code >= 400:
detail = body.get("error", {}).get("message") if isinstance(body, dict) else response.text
raise WhatsAppGraphError(detail or "Meta did not accept the header file", status_code=response.status_code)
media_id = str(body.get("id") or "") if isinstance(body, dict) else ""
if not media_id:
raise WhatsAppGraphError("Meta did not return a media id for the header file", status_code=502)
return media_id
async def send_template(
self,
to: str,

View file

@ -1318,6 +1318,7 @@ async def send_account_broadcast(
recipients: list[str],
body_parameters: list[str] | None = None,
product_policy: str = "STRICT",
use_sample_header: bool = False,
) -> dict[str, Any]:
"""Send a marketing template through Meta's marketing endpoint."""
account = _owned_account(db, user, account_id)
@ -1337,14 +1338,20 @@ async def send_account_broadcast(
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
components: list[dict[str, Any]] = []
if use_sample_header:
from .whatsapp_template_service import header_parameter_for_send
components.append(
await header_parameter_for_send(db, user, account, token, phone.phone_number_id, name, language)
)
if parameters:
components = [
components.append(
{
"type": "body",
"parameters": [{"type": "text", "text": item} for item in parameters],
}
]
)
client = WhatsAppGraphClient(
access_token=token,
phone_number_id=phone.phone_number_id,
@ -1366,7 +1373,7 @@ async def send_account_broadcast(
recipient,
name,
language_code=language,
components=components,
components=components or None,
product_policy=policy,
)
except WhatsAppGraphError as exc:

View file

@ -3,6 +3,8 @@
from __future__ import annotations
import re
import uuid
from pathlib import Path
from typing import Any
import httpx
@ -11,6 +13,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from .config import settings
from .kyc_service import upload_root
from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate, WhatsAppMetaConnection
from .whatsapp_onboarding import (
_apply_phones,
@ -35,6 +38,13 @@ _MEDIA = {
"video/mp4": "VIDEO",
"application/pdf": "DOCUMENT",
}
_SAMPLE_EXT = {
"image/jpeg": ".jpg",
"image/png": ".png",
"video/mp4": ".mp4",
"application/pdf": ".pdf",
}
_SAMPLE_KEYS = ("sample_id", "sample_name", "sample_type")
LANGUAGES = [
("en", "English"),
@ -314,6 +324,67 @@ def _buttons(buttons: list[Any], category: str) -> list[dict[str, Any]]:
return built
def _media_type(filename: str, content_type: str) -> str:
raw = (content_type or "").split(";")[0].strip().lower()
if raw in _MEDIA:
return raw
suffix = Path(filename or "").suffix.lower()
for mime, ext in _SAMPLE_EXT.items():
if suffix == ext or (suffix == ".jpeg" and mime == "image/jpeg"):
return mime
return ""
def _sample_folder(user_id: Any) -> Path:
folder = upload_root(settings.upload_dir) / "whatsapp-template-samples" / str(user_id)
folder.mkdir(parents=True, exist_ok=True)
return folder
def _find_sample(user_id: Any, sample_id: str) -> Path | None:
if not re.fullmatch(r"[a-f0-9]{32}", sample_id or ""):
return None
folder = upload_root(settings.upload_dir) / "whatsapp-template-samples" / str(user_id)
if not folder.is_dir():
return None
matches = [path for path in folder.glob(f"{sample_id}.*") if path.is_file()]
return matches[0] if matches else None
def _store_sample(user_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]:
mime = _media_type(filename, content_type)
kind = _MEDIA.get(mime)
if kind is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.")
if not data:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="The file is empty.")
if len(data) > 16 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header files can be at most 16 MB.")
sample_id = uuid.uuid4().hex
path = _sample_folder(user_id) / f"{sample_id}{_SAMPLE_EXT[mime]}"
path.write_bytes(data)
safe_name = Path(filename or "header").name[:180] or "header"
return {"sample_id": sample_id, "sample_name": safe_name, "sample_type": mime, "format": kind}
def _header_dict(definition: dict | None) -> dict[str, Any]:
header = definition.get("header") if isinstance(definition, dict) else None
return dict(header) if isinstance(header, dict) else {}
def _keep_sample(previous: dict | None, definition: dict[str, Any]) -> None:
"""Meta sync rebuilds the header and would otherwise drop the file Urmi saved."""
old = _header_dict(previous)
new = definition.get("header") if isinstance(definition.get("header"), dict) else None
if not isinstance(new, dict) or new.get("sample_id") or not old.get("sample_id"):
return
if str(new.get("type") or "").upper() != str(old.get("type") or "").upper():
return
for key in _SAMPLE_KEYS:
if old.get(key):
new[key] = old[key]
def _definition_from_components(components: list[Any], category: str) -> dict[str, Any]:
definition: dict[str, Any] = {
"category": category,
@ -430,6 +501,9 @@ def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount |
"parameter_format": row.parameter_format,
"definition": row.definition or {},
"components": row.components or [],
"header_format": str(_header_dict(row.definition).get("type") or "NONE").upper(),
"sample_stored": _find_sample(row.user_id, str(_header_dict(row.definition).get("sample_id") or "")) is not None,
"sample_name": str(_header_dict(row.definition).get("sample_name") or ""),
"messages_sent": sent,
"messages_sent_source": "urmi",
"created_at": row.created_at,
@ -470,6 +544,7 @@ def _upsert_remote(db: Session, account: WhatsAppBusinessAccount, remote: dict[s
definition = _definition_from_components(components, category)
if row.subtype and row.subtype != "default":
definition["subtype"] = row.subtype
_keep_sample(row.definition if isinstance(row.definition, dict) else None, definition)
row.definition = definition
row.parameter_format = str(definition.get("parameter_format") or row.parameter_format)
row.subtype = str(definition.get("subtype") or row.subtype or "default")[:40]
@ -812,7 +887,9 @@ async def resubmit_managed_template(db: Session, user: UserAccount, template_id:
components = build_components({**payload, "name": row.name, "language": row.language})
data = await _graph("POST", row.meta_template_id, token=_token(db, account), json_body={"components": components, "category": row.category})
row.components = components
row.definition = payload
definition = dict(payload)
_keep_sample(row.definition if isinstance(row.definition, dict) else None, definition)
row.definition = definition
row.status = str(data.get("status") or "PENDING").upper()
row.rejected_reason = None
row.category = str(data.get("category") or row.category).upper()
@ -837,6 +914,7 @@ async def upload_header_media(db: Session, user: UserAccount, account_id: Any, f
account = _owned_account(db, user, account_id)
_require_connected(account)
token = _token(db, account)
content_type = _media_type(filename, content_type)
kind = _MEDIA.get(content_type)
if kind is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.")
@ -872,7 +950,106 @@ async def upload_header_media(db: Session, user: UserAccount, account_id: Any, f
handle = str(body.get("h") or "")
if not handle:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Meta did not return a header file handle.")
return {"handle": handle, "format": kind}
stored = _store_sample(user.id, filename, content_type, data)
return {"handle": handle, "format": kind, **stored}
def attach_template_sample(
db: Session,
user: UserAccount,
template_id: Any,
filename: str,
content_type: str,
data: bytes,
) -> dict[str, Any]:
"""Keep a sendable copy of the header file. This does not change the template at Meta."""
row, account = _owned_template(db, user, template_id)
header = _header_dict(row.definition)
kind = str(header.get("type") or "NONE").upper()
if kind not in {"IMAGE", "VIDEO", "DOCUMENT"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template does not use an image, video, or document header.")
stored = _store_sample(user.id, filename, content_type, data)
if stored["format"] != kind:
orphan = _find_sample(user.id, stored["sample_id"])
if orphan is not None:
orphan.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"This template header is a {kind.lower()}. Upload that kind of file.",
)
header["sample_id"] = stored["sample_id"]
header["sample_name"] = stored["sample_name"]
header["sample_type"] = stored["sample_type"]
definition = dict(row.definition or {})
definition["header"] = header
row.definition = definition
db.commit()
db.refresh(row)
return _serialize(row, account, _sent_counts(db, user).get(row.name, 0))
def read_template_sample(db: Session, user: UserAccount, template_id: Any) -> tuple[bytes, str, str]:
row, _account = _owned_template(db, user, template_id)
header = _header_dict(row.definition)
path = _find_sample(user.id, str(header.get("sample_id") or ""))
if path is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No sample file is saved for this template.")
mime = str(header.get("sample_type") or "") or _media_type(path.name, "")
return path.read_bytes(), mime or "application/octet-stream", str(header.get("sample_name") or path.name)
def _template_for_send(db: Session, user: UserAccount, account: WhatsAppBusinessAccount, name: str, language: str) -> WhatsAppMessageTemplate | None:
rows = db.scalars(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.user_id == user.id,
WhatsAppMessageTemplate.account_id == account.id,
WhatsAppMessageTemplate.name == name,
)
).all()
exact = [row for row in rows if row.language == language]
if exact:
return exact[0]
prefix = language.split("_")[0]
loose = [row for row in rows if row.language.split("_")[0] == prefix]
return loose[0] if len(loose) == 1 else None
async def header_parameter_for_send(
db: Session,
user: UserAccount,
account: WhatsAppBusinessAccount,
token: str,
phone_number_id: str,
name: str,
language: str,
) -> dict[str, Any]:
"""Upload the saved sample and return the header parameter Meta requires on each send."""
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
row = _template_for_send(db, user, account, name, language)
if row is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Open the template and save a sample file before sending it.")
header = _header_dict(row.definition)
kind = str(header.get("type") or "NONE").upper()
if kind not in {"IMAGE", "VIDEO", "DOCUMENT"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template does not use a media header.")
path = _find_sample(user.id, str(header.get("sample_id") or ""))
if path is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Add the sample file on the template page. Meta does not keep the approval file for later sends.",
)
mime = str(header.get("sample_type") or "") or _media_type(path.name, "")
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_number_id, business_account_id=account.waba_id)
try:
media_id = await client.upload_media(str(header.get("sample_name") or path.name), mime, path.read_bytes())
except WhatsAppGraphError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
parameter_type = kind.lower()
media: dict[str, str] = {"id": media_id}
if kind == "DOCUMENT":
media["filename"] = str(header.get("sample_name") or path.name)
return {"type": "header", "parameters": [{"type": parameter_type, parameter_type: media}]}
def apply_template_status_webhook(db: Session, payload: dict[str, Any]) -> None:

View file

@ -1,7 +1,7 @@
import { Loader2 } from 'lucide-react'
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { whatsappApi, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck'
const TEAM_TEMPLATE_BODY = "Team, the broadcast desk is on air. This is the first note Urmi is sending through Meta's marketing line, and it is written for the people building it. Read it on your phone, then bring it to standup so we can close the test."
@ -23,7 +23,9 @@ export function WhatsAppBroadcastPage() {
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
const [accountId, setAccountId] = useState('')
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
const [managed, setManaged] = useState<WaManagedTemplate[]>([])
const [templatesReady, setTemplatesReady] = useState(false)
const [useSample, setUseSample] = useState(true)
const [templateName, setTemplateName] = useState(TEAM_TEMPLATE_NAME)
const [templateLanguage, setTemplateLanguage] = useState('en')
const [draftName, setDraftName] = useState(TEAM_TEMPLATE_NAME)
@ -72,6 +74,9 @@ export function WhatsAppBroadcastPage() {
setTemplates([])
setTemplatesReady(false)
})
waTemplateApi.list(accountId)
.then((payload) => setManaged(payload.templates))
.catch(() => setManaged([]))
}, [accountId, templateTick])
async function createTemplate(event: FormEvent) {
@ -103,6 +108,16 @@ export function WhatsAppBroadcastPage() {
setResult(null)
const numbers = recipients.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)
const parameters = bodyValues.split('\n').map((item) => item.trim()).filter(Boolean)
if (mediaHeader && !savedSample) {
setError(`Add the sample ${headerLabel} on the template page. Meta rejects this send without it.`)
setBusy(false)
return
}
if (mediaHeader && !useSample) {
setError(`Turn on the saved sample ${headerLabel}. This template’s header is a ${headerLabel}, and Meta rejects the send without it.`)
setBusy(false)
return
}
try {
const payload = await whatsappApi.sendBroadcast(accountId, {
template_name: templateName.trim(),
@ -110,6 +125,7 @@ export function WhatsAppBroadcastPage() {
recipients: numbers,
body_parameters: parameters,
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
use_sample_header: Boolean(mediaHeader && useSample && savedSample),
})
setResult(payload)
} catch (reason) {
@ -121,6 +137,11 @@ export function WhatsAppBroadcastPage() {
const approved = templates.filter((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name)
const pending = templates.filter((item) => (item.status || '').toUpperCase() !== 'APPROVED' && item.name)
const selectedTemplate = managed.find((item) => item.name === templateName && item.language === templateLanguage)
|| managed.find((item) => item.name === templateName)
const mediaHeader = ['IMAGE', 'VIDEO', 'DOCUMENT'].includes(selectedTemplate?.header_format || '')
const savedSample = Boolean(selectedTemplate?.sample_stored)
const headerLabel = selectedTemplate?.header_format === 'VIDEO' ? 'video' : selectedTemplate?.header_format === 'DOCUMENT' ? 'document' : 'image'
return (
<div className="wa-biz wa-token-page">
@ -198,6 +219,7 @@ export function WhatsAppBroadcastPage() {
const next = approved.find((item) => item.name === event.target.value)
setTemplateName(event.target.value)
if (next?.language) setTemplateLanguage(next.language)
setUseSample(true)
}}
>
{approved.map((item) => (
@ -220,6 +242,23 @@ export function WhatsAppBroadcastPage() {
<span>One WhatsApp number per line, up to 20. Include the country code.</span>
<textarea value={recipients} onChange={(event) => setRecipients(event.target.value)} rows={4} required placeholder="9198XXXXXXXX" />
</label>
{mediaHeader && (
<label className="explorer-input">
<strong>Header {headerLabel}</strong>
<span>
{savedSample
? `Use the ${headerLabel} saved on this template (${selectedTemplate?.sample_name || 'sample file'}). Meta does not keep the approval file, so each send has to attach it.`
: `This template needs a ${headerLabel} on every send, and none is saved yet.`}
</span>
{savedSample ? (
<input type="checkbox" checked={useSample} onChange={(event) => setUseSample(event.target.checked)} />
) : selectedTemplate ? (
<Link to={`/console/whatsapp/templates/${selectedTemplate.id}`}>Add the sample on the template</Link>
) : (
<span>Open the template and add the sample file first.</span>
)}
</label>
)}
<label className="explorer-input">
<strong>Body values</strong>
<span>Leave this empty for the team aircheck. If a template has {'{{1}}'}, {'{{2}}'}, put one value per line in that order.</span>

View file

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

View file

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

View file

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

View file

@ -741,6 +741,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.explorer-input strong { color: #35435a; font-size: 12px; }
.explorer-input span { color: #8a95a7; font-size: 10px; line-height: 1.4; }
.explorer-input input, .explorer-input textarea { width: 100%; border: 1px solid #dfe5ed; border-radius: 6px; padding: 10px 12px; font-size: 12px; color: #26364d; }
.explorer-input input[type='checkbox'] { width: 16px; height: 16px; padding: 0; }
.explorer-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.explorer-code, .explorer-response pre { margin: 0; padding: 14px; border-radius: 8px; background: #0f1728; color: #dbe7ff; font-size: 11px; line-height: 1.5; overflow: auto; }
.explorer-response { display: grid; gap: 10px; padding: 14px; border: 1px solid #bfe8d5; border-radius: 8px; background: #f5fffa; }
@ -2042,7 +2043,10 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-tpl-preview-buttons { display: grid; gap: 6px; margin-top: 8px; }
.wa-tpl-preview-buttons span { background: white; border-radius: 8px; text-align: center; color: #1475ff; padding: 8px; font-size: 13px; font-weight: 700; }
.wa-tpl-numbers, .wa-tpl-categories { display: grid; gap: 10px; }
.wa-tpl-numbers label, .wa-tpl-categories button { display: grid; grid-template-columns: auto 1fr; gap: 8px 12px; padding: 14px; text-align: left; }
.wa-tpl-numbers label, .wa-tpl-categories button { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; padding: 14px; text-align: left; align-items: start; }
.wa-tpl-categories button span { gap: 4px; }
.wa-tpl-categories button strong { color: #14233b; }
.wa-tpl-categories button small { color: #718096; font-size: 13px; line-height: 1.45; }
.wa-tpl-numbers label.is-selected, .wa-tpl-categories button.is-selected { border-color: #1475ff; box-shadow: 0 0 0 3px rgba(20,117,255,.12); }
.wa-tpl-numbers span, .wa-tpl-categories button span { display: grid; }
.wa-tpl-numbers small, .wa-tpl-categories span { color: #718096; }
@ -2059,6 +2063,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-tpl-tabs { display: flex; gap: 8px; }
.wa-tpl-tabs .is-current { color: #14233b; }
.wa-tpl-drop { display: grid; gap: 8px; padding: 16px; border: 1px dashed #b7c3d3; border-radius: 12px; background: #f8fafc; }
.wa-tpl-sample { display: grid; gap: 8px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.wa-tpl-sample p { margin: 0; color: #526176; }
.wa-tpl-result dl { margin-top: 16px; }
@media (max-width: 980px) {
.wa-tpl-cards, .wa-tpl-split, .wa-tpl-samples label, .wa-tpl-review div { grid-template-columns: 1fr; }

View file

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