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

View file

@ -26,6 +26,7 @@ from .routes_numbers import router as numbers_router
from .routes_urmiotp import router as urmiotp_router
from .routes_voice import router as voice_router
from .routes_whatsapp import router as whatsapp_router
from .routes_whatsapp_templates import router as whatsapp_templates_router
from .routes_meta import router as meta_router
from .routes_agent_templates import router as agent_templates_router
from .routes_agent_messages import router as agent_messages_router
@ -109,6 +110,7 @@ app.include_router(numbers_router)
app.include_router(integrations_router)
app.include_router(voice_router)
app.include_router(whatsapp_router)
app.include_router(whatsapp_templates_router)
app.include_router(meta_router)
app.include_router(agent_templates_router)
app.include_router(agent_messages_router)

View file

@ -614,6 +614,36 @@ class WhatsAppPhoneAsset(Base):
account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones")
class WhatsAppMessageTemplate(Base):
"""A WhatsApp message template owned by one WABA. Meta remains the source of approval."""
__tablename__ = "whatsapp_message_templates"
__table_args__ = (UniqueConstraint("waba_id", "name", "language", name="uq_wa_template_name_lang"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("whatsapp_business_accounts.id", ondelete="CASCADE"), index=True
)
waba_id: Mapped[str] = mapped_column(String(64), index=True)
phone_number_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
meta_template_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(512), index=True)
language: Mapped[str] = mapped_column(String(16), default="en_US")
category: Mapped[str] = mapped_column(String(32), default="MARKETING")
subtype: Mapped[str] = mapped_column(String(40), default="default")
status: Mapped[str] = mapped_column(String(32), default="PENDING", index=True)
quality: Mapped[str | None] = mapped_column(String(24), nullable=True)
rejected_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
parameter_format: Mapped[str] = mapped_column(String(16), default="POSITIONAL")
definition: Mapped[dict] = mapped_column(JSON, default=dict)
components: Mapped[list] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WhatsAppConnectionAudit(Base):
__tablename__ = "whatsapp_connection_audits"

View file

@ -43,6 +43,7 @@ from .whatsapp_onboarding import (
from .whatsapp_security import verify_meta_signature
from .whatsapp_webhook import persist_webhook_payload
from .whatsapp_messages import record_inbound_messages
from .whatsapp_template_service import apply_template_status_webhook
logger = logging.getLogger(__name__)
@ -131,6 +132,7 @@ async def receive_webhook(
persist_webhook_payload(db, payload)
record_inbound_messages(db, payload)
apply_template_status_webhook(db, payload)
return {"status": "ok"}

View file

@ -0,0 +1,98 @@
from typing import Any
from fastapi import APIRouter, Depends, File, Form, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .auth import current_user
from .database import get_db
from .models import UserAccount
from .whatsapp_template_service import (
create_managed_template,
delete_managed_template,
get_managed_template,
language_catalog,
list_managed_templates,
resubmit_managed_template,
upload_header_media,
)
router = APIRouter(prefix="/api/v1/whatsapp/message-templates", tags=["whatsapp-templates"])
class TemplateWriteRequest(BaseModel):
account_id: str | None = None
phone_number_id: str | None = None
name: str = Field(min_length=1, max_length=512)
language: str = "en_US"
category: str
subtype: str = "default"
parameter_format: str = "POSITIONAL"
header: dict[str, Any] = Field(default_factory=lambda: {"type": "NONE"})
body: str = ""
body_examples: list[str] = Field(default_factory=list)
footer: str = ""
buttons: list[dict[str, Any]] = Field(default_factory=list)
auth: dict[str, Any] = Field(default_factory=dict)
@router.get("/languages")
def whatsapp_template_languages(_: UserAccount = Depends(current_user)) -> list[dict[str, str]]:
return language_catalog()
@router.get("")
async def whatsapp_template_list(
account_id: str | None = None,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await list_managed_templates(db, user, account_id)
@router.post("/media")
async def whatsapp_template_media(
account_id: str = Form(),
file: UploadFile = File(),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
data = await file.read()
return await upload_header_media(db, user, account_id, file.filename or "header", file.content_type or "", data)
@router.get("/{template_id}")
def whatsapp_template_detail(
template_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return get_managed_template(db, user, template_id)
@router.post("")
async def whatsapp_template_create(
payload: TemplateWriteRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await create_managed_template(db, user, payload.model_dump())
@router.post("/{template_id}/resubmit")
async def whatsapp_template_resubmit(
template_id: str,
payload: TemplateWriteRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await resubmit_managed_template(db, user, template_id, payload.model_dump())
@router.delete("/{template_id}")
async def whatsapp_template_delete(
template_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await delete_managed_template(db, user, template_id)

View file

@ -0,0 +1,703 @@
"""WhatsApp message template lifecycle against the customer's WABA."""
from __future__ import annotations
import re
from typing import Any
import httpx
from fastapi import HTTPException, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from .config import settings
from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate
from .whatsapp_onboarding import _graph, _graph_error_message, _owned_account, _quality_label, _token_app_id
_NAME = re.compile(r"^[a-z0-9_]{1,512}$")
_LANG = re.compile(r"^[a-z]{2}(_[A-Z]{2})?$")
_VAR = re.compile(r"\{\{([A-Za-z0-9_]+)\}\}")
_MEDIA = {
"image/jpeg": "IMAGE",
"image/png": "IMAGE",
"video/mp4": "VIDEO",
"application/pdf": "DOCUMENT",
}
LANGUAGES = [
("en", "English"),
("en_US", "English (US)"),
("en_GB", "English (UK)"),
("hi", "Hindi"),
("bn", "Bengali"),
("ta", "Tamil"),
("te", "Telugu"),
("mr", "Marathi"),
("gu", "Gujarati"),
("kn", "Kannada"),
("ml", "Malayalam"),
("pa", "Punjabi"),
("ur", "Urdu"),
("ar", "Arabic"),
("es", "Spanish"),
("es_MX", "Spanish (Mexico)"),
("pt_BR", "Portuguese (Brazil)"),
("fr", "French"),
("de", "German"),
("id", "Indonesian"),
("it", "Italian"),
("nl", "Dutch"),
("ru", "Russian"),
("tr", "Turkish"),
("ja", "Japanese"),
("ko", "Korean"),
("zh_CN", "Chinese (China)"),
("zh_HK", "Chinese (Hong Kong)"),
]
def language_catalog() -> list[dict[str, str]]:
return [{"code": code, "label": label} for code, label in LANGUAGES]
def _language_ok(code: str) -> bool:
return any(item[0] == code for item in LANGUAGES)
def _token(account: WhatsAppBusinessAccount) -> str:
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 WhatsApp access token before managing templates.")
return token
def _variables(text: str) -> list[str]:
found: list[str] = []
for match in _VAR.finditer(text or ""):
name = match.group(1)
if name not in found:
found.append(name)
return found
def _examples_for(text: str, examples: list[str], named: bool) -> list[str]:
names = _variables(text)
if len(examples) < len(names):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Every variable needs a sample value.")
if named and any(name.isdigit() for name in names):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Named variables cannot be only numbers. Use a name such as customer_name.")
if not named:
expected = [str(index) for index in range(1, len(names) + 1)]
if names != expected:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Numbered variables must be {{1}}, {{2}}, and so on, with no gaps.")
return examples[: len(names)]
def build_components(payload: dict[str, Any]) -> list[dict[str, Any]]:
category = str(payload.get("category") or "").upper()
named = str(payload.get("parameter_format") or "POSITIONAL").upper() == "NAMED"
if category == "AUTHENTICATION":
auth = payload.get("auth") if isinstance(payload.get("auth"), dict) else {}
minutes = int(auth.get("code_expiration_minutes") or 0)
components: list[dict[str, Any]] = [{
"type": "BODY",
"add_security_recommendation": bool(auth.get("add_security_recommendation", True)),
}]
if minutes:
if minutes < 1 or minutes > 90:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Code expiry should be between 1 and 90 minutes.")
components.append({"type": "FOOTER", "code_expiration_minutes": minutes})
components.append({
"type": "BUTTONS",
"buttons": [{
"type": "OTP",
"otp_type": "COPY_CODE",
"text": str(auth.get("copy_label") or "Copy code")[:25] or "Copy code",
}],
})
return components
header = payload.get("header") if isinstance(payload.get("header"), dict) else {"type": "NONE"}
body = str(payload.get("body") or "").strip()
footer = str(payload.get("footer") or "").strip()
buttons = payload.get("buttons") if isinstance(payload.get("buttons"), list) else []
if not body:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message body is required.")
if len(body) > 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message body can be at most 1,024 characters.")
if len(footer) > 60:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Footer can be at most 60 characters.")
if _variables(footer):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Footer cannot contain variables.")
components = []
header_type = str(header.get("type") or "NONE").upper()
if header_type == "TEXT":
text = str(header.get("text") or "").strip()
if not text:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header text is required.")
if len(text) > 60:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header text can be at most 60 characters.")
samples = _examples_for(text, list(header.get("examples") or []), named)
block: dict[str, Any] = {"type": "HEADER", "format": "TEXT", "text": text}
if samples:
names = _variables(text)
if named:
block["example"] = {"header_text_named_params": [
{"param_name": name, "example": sample} for name, sample in zip(names, samples, strict=False)
]}
else:
block["example"] = {"header_text": samples}
components.append(block)
elif header_type in {"IMAGE", "VIDEO", "DOCUMENT"}:
handle = str(header.get("handle") or "").strip()
if not handle:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a header file before submitting.")
components.append({
"type": "HEADER",
"format": header_type,
"example": {"header_handle": [handle]},
})
elif header_type == "LOCATION":
components.append({"type": "HEADER", "format": "LOCATION"})
elif header_type != "NONE":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose a supported header type.")
body_samples = _examples_for(body, [str(item) for item in (payload.get("body_examples") or [])], named)
body_block: dict[str, Any] = {"type": "BODY", "text": body}
if body_samples:
names = _variables(body)
if named:
body_block["example"] = {"body_text_named_params": [
{"param_name": name, "example": sample} for name, sample in zip(names, body_samples, strict=False)
]}
else:
body_block["example"] = {"body_text": [body_samples]}
components.append(body_block)
if footer:
components.append({"type": "FOOTER", "text": footer})
if buttons:
components.append({"type": "BUTTONS", "buttons": _buttons(buttons, category)})
return components
def _buttons(buttons: list[Any], category: str) -> list[dict[str, Any]]:
if len(buttons) > 10:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A template can have at most 10 buttons.")
built: list[dict[str, Any]] = []
urls = phones = copies = 0
for raw in buttons:
if not isinstance(raw, dict):
continue
kind = str(raw.get("type") or "").upper()
label = str(raw.get("text") or "").strip()
if kind != "COPY_CODE" and (not label or len(label) > 25):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Each button label should be 1 to 25 characters.")
if kind == "URL":
urls += 1
url = str(raw.get("url") or "").strip()
if not url.startswith("https://"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Website buttons need an https URL.")
button: dict[str, Any] = {"type": "URL", "text": label, "url": url}
names = _variables(url)
if len(names) > 1 or (names and not url.endswith("{{" + names[0] + "}}")):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A website button can use one variable, and it has to be at the end of the URL.")
if names:
example = str(raw.get("example") or "").strip()
if not example.startswith("https://"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Sample URL should be a full https address with the variable filled in.")
button["example"] = [example]
built.append(button)
elif kind == "PHONE_NUMBER":
phones += 1
phone = re.sub(r"\D", "", str(raw.get("phone") or ""))
if len(phone) < 8:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Enter a phone number with the country code.")
built.append({"type": "PHONE_NUMBER", "text": label, "phone_number": phone})
elif kind == "QUICK_REPLY":
built.append({"type": "QUICK_REPLY", "text": label})
elif kind == "COPY_CODE":
if category != "MARKETING":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Copy offer code is available on marketing templates.")
copies += 1
example = str(raw.get("example") or "").strip()
if not example or len(example) > 15:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Offer code sample should be 1 to 15 characters.")
built.append({"type": "COPY_CODE", "example": example})
elif kind == "CATALOG":
built.append({"type": "CATALOG", "text": label})
elif kind == "FLOW":
flow_id = str(raw.get("flow_id") or "").strip()
if not flow_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A WhatsApp Flow button needs the Flow ID from Meta.")
built.append({
"type": "FLOW",
"text": label,
"flow_id": flow_id,
"flow_action": "navigate",
"navigate_screen": str(raw.get("screen") or "MAIN"),
})
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="That button type is not supported for this template.")
if urls > 2 or phones > 1 or copies > 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Use at most two website buttons, one phone button, and one offer code.",
)
kinds = [str(item.get("type") or "") for item in built]
quick = [index for index, kind in enumerate(kinds) if kind == "QUICK_REPLY"]
if quick and (quick != list(range(quick[0], quick[0] + len(quick))) or quick[0] not in {0, len(kinds) - len(quick)}):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Keep quick reply buttons together, either all at the start or all at the end.",
)
return built
def _definition_from_components(components: list[Any], category: str) -> dict[str, Any]:
definition: dict[str, Any] = {
"category": category,
"subtype": "otp" if category == "AUTHENTICATION" else "default",
"parameter_format": "POSITIONAL",
"header": {"type": "NONE"},
"body": "",
"body_examples": [],
"footer": "",
"buttons": [],
"auth": {
"otp_type": "COPY_CODE",
"add_security_recommendation": True,
"code_expiration_minutes": 0,
"copy_label": "Copy code",
},
}
for component in components:
if not isinstance(component, dict):
continue
kind = str(component.get("type") or "").upper()
if kind == "HEADER":
fmt = str(component.get("format") or "TEXT").upper()
example = component.get("example") if isinstance(component.get("example"), dict) else {}
if fmt == "TEXT":
samples = example.get("header_text") if isinstance(example.get("header_text"), list) else []
definition["header"] = {"type": "TEXT", "text": component.get("text") or "", "examples": samples}
elif fmt == "LOCATION":
definition["header"] = {"type": "LOCATION"}
else:
handles = example.get("header_handle") if isinstance(example.get("header_handle"), list) else []
definition["header"] = {"type": fmt, "handle": handles[0] if handles else "", "preview_name": fmt.title()}
elif kind == "BODY":
definition["body"] = component.get("text") or ""
example = component.get("example") if isinstance(component.get("example"), dict) else {}
if isinstance(example.get("body_text"), list) and example["body_text"] and isinstance(example["body_text"][0], list):
definition["body_examples"] = example["body_text"][0]
named = example.get("body_text_named_params")
if isinstance(named, list):
definition["parameter_format"] = "NAMED"
definition["body_examples"] = [item.get("example") for item in named if isinstance(item, dict)]
if "add_security_recommendation" in component:
definition["auth"]["add_security_recommendation"] = bool(component.get("add_security_recommendation"))
elif kind == "FOOTER":
definition["footer"] = component.get("text") or ""
if component.get("code_expiration_minutes"):
definition["auth"]["code_expiration_minutes"] = component.get("code_expiration_minutes")
elif kind == "BUTTONS":
for button in component.get("buttons") or []:
if not isinstance(button, dict):
continue
button_type = str(button.get("type") or "").upper()
if button_type == "OTP":
definition["auth"]["copy_label"] = button.get("text") or "Copy code"
continue
definition["buttons"].append({
"type": button_type,
"text": button.get("text") or "",
"url": button.get("url") or "",
"phone": button.get("phone_number") or "",
"example": (button.get("example") or [""])[0] if isinstance(button.get("example"), list) else button.get("example") or "",
"flow_id": button.get("flow_id") or "",
"screen": button.get("navigate_screen") or "",
})
return definition
def _sent_counts(db: Session, user: UserAccount) -> dict[str, int]:
rows = db.execute(
select(WhatsAppMessage.body, func.count())
.where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.message_type == "template")
.group_by(WhatsAppMessage.body)
).all()
counts: dict[str, int] = {}
for body, count in rows:
text = str(body or "")
for prefix in ("Template: ", "Broadcast: "):
if text.startswith(prefix):
name = text[len(prefix):]
counts[name] = counts.get(name, 0) + int(count)
return counts
def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount | None, sent: int) -> dict[str, Any]:
phone = None
if account is not None:
phone = next((item for item in account.phones if item.phone_number_id == row.phone_number_id), None)
if phone is None and account.phones:
phone = account.phones[0]
return {
"id": str(row.id),
"account_id": str(row.account_id),
"account_name": account.name if account else "WhatsApp Business",
"waba_name": account.name if account else "",
"phone_number_id": row.phone_number_id,
"display_phone": phone.display_phone if phone else "",
"meta_template_id": row.meta_template_id,
"name": row.name,
"language": row.language,
"category": row.category,
"subtype": row.subtype,
"status": row.status,
"quality": row.quality,
"rejected_reason": row.rejected_reason,
"parameter_format": row.parameter_format,
"definition": row.definition or {},
"components": row.components or [],
"messages_sent": sent,
"messages_sent_source": "urmi",
"created_at": row.created_at,
"updated_at": row.updated_at,
}
def _upsert_remote(db: Session, account: WhatsAppBusinessAccount, remote: dict[str, Any]) -> WhatsAppMessageTemplate:
name = str(remote.get("name") or "")
language = str(remote.get("language") or "en")
row = db.scalar(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.waba_id == account.waba_id,
WhatsAppMessageTemplate.name == name,
WhatsAppMessageTemplate.language == language,
)
)
components = remote.get("components") if isinstance(remote.get("components"), list) else []
category = str(remote.get("category") or "MARKETING").upper()
quality = remote.get("quality_score")
score = quality.get("score") if isinstance(quality, dict) else None
reason = remote.get("rejected_reason")
if isinstance(reason, dict):
reason = reason.get("reason") or reason.get("message")
if row is None:
row = WhatsAppMessageTemplate(
user_id=account.user_id,
account_id=account.id,
waba_id=account.waba_id,
name=name,
language=language,
definition=_definition_from_components(components, category),
)
db.add(row)
row.meta_template_id = str(remote.get("id") or row.meta_template_id or "") or None
row.category = category
if components:
definition = _definition_from_components(components, category)
if row.subtype and row.subtype != "default":
definition["subtype"] = row.subtype
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]
row.status = str(remote.get("status") or "PENDING").upper()
row.quality = _quality_label(str(score) if score else None)
row.rejected_reason = str(reason)[:2000] if reason and str(reason).upper() not in {"NONE", "NULL"} else None
row.components = components
if not row.definition:
row.definition = _definition_from_components(components, category)
return row
async def _template_page(token: str, waba_id: str, params: dict[str, Any]) -> dict[str, Any]:
last: HTTPException | None = None
for fields in (
"id,name,language,status,category,components,quality_score,rejected_reason",
"id,name,language,status,category,components",
):
try:
return await _graph("GET", f"{waba_id}/message_templates", token=token, params={**params, "fields": fields})
except HTTPException as exc:
last = exc
detail = str(exc.detail).lower()
if "nonexisting field" in detail or "(#100)" in detail:
continue
raise
if last is not None:
raise last
return {}
async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None:
token = account.connection.access_token if account.connection else None
if not token:
return "This account has no access token, so templates could not be refreshed."
seen: set[tuple[str, str]] = set()
after = None
try:
for _ in range(5):
params = {"limit": "100"}
if after:
params["after"] = after
data = await _template_page(token, account.waba_id, params)
rows = data.get("data") if isinstance(data.get("data"), list) else []
for remote in rows:
if isinstance(remote, dict) and remote.get("name"):
_upsert_remote(db, account, remote)
seen.add((str(remote.get("name")), str(remote.get("language") or "")))
paging = data.get("paging") if isinstance(data.get("paging"), dict) else {}
cursors = paging.get("cursors") if isinstance(paging.get("cursors"), dict) else {}
after = cursors.get("after") if paging.get("next") else None
if not after:
break
except HTTPException as exc:
db.rollback()
return str(exc.detail)
stored = db.scalars(
select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id)
).all()
for row in stored:
if row.meta_template_id and (row.name, row.language) not in seen:
row.status = "DISABLED"
account.template_count = len(seen)
account.templates_status = "active"
db.commit()
return None
async def list_managed_templates(db: Session, user: UserAccount, account_id: str | None) -> dict[str, Any]:
query = (
select(WhatsAppBusinessAccount)
.options(selectinload(WhatsAppBusinessAccount.phones), selectinload(WhatsAppBusinessAccount.connection))
.where(WhatsAppBusinessAccount.user_id == user.id)
)
accounts = db.scalars(query).all()
if account_id:
accounts = [account for account in accounts if str(account.id) == account_id]
warnings = []
for account in accounts:
warning = await sync_account_templates(db, account)
if warning:
warnings.append(f"{account.name}: {warning}")
rows = db.scalars(
select(WhatsAppMessageTemplate)
.where(
WhatsAppMessageTemplate.user_id == user.id,
WhatsAppMessageTemplate.account_id.in_([account.id for account in accounts] or [user.id]),
)
.order_by(WhatsAppMessageTemplate.updated_at.desc())
).all() if accounts else []
by_id = {account.id: account for account in accounts}
sent = _sent_counts(db, user)
templates = [_serialize(row, by_id.get(row.account_id), sent.get(row.name, 0)) for row in rows]
summary = {
"total": len(templates),
"approved": sum(1 for item in templates if item["status"] == "APPROVED"),
"pending": sum(1 for item in templates if item["status"] in {"PENDING", "IN_APPEAL"}),
"rejected": sum(1 for item in templates if item["status"] == "REJECTED"),
"paused": sum(1 for item in templates if item["status"] == "PAUSED"),
}
return {"templates": templates, "summary": summary, "warnings": warnings}
def _owned_template(db: Session, user: UserAccount, template_id: Any) -> tuple[WhatsAppMessageTemplate, WhatsAppBusinessAccount]:
row = db.scalar(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.id == template_id,
WhatsAppMessageTemplate.user_id == user.id,
)
)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
account = _owned_account(db, user, row.account_id)
return row, account
def get_managed_template(db: Session, user: UserAccount, template_id: Any) -> dict[str, Any]:
row, account = _owned_template(db, user, template_id)
sent = _sent_counts(db, user).get(row.name, 0)
payload = _serialize(row, account, sent)
bodies = [f"Template: {row.name}", f"Broadcast: {row.name}"]
status_rows = db.execute(
select(WhatsAppMessage.status, func.count())
.where(
WhatsAppMessage.user_id == user.id,
WhatsAppMessage.message_type == "template",
or_(*[WhatsAppMessage.body == body for body in bodies]),
)
.group_by(WhatsAppMessage.status)
).all()
payload["analytics"] = {
"source": "urmi",
"note": "Counts are messages Urmi sent with this template name, plus delivery updates Urmi received. They are separate from Meta's template analytics.",
"sent": sent,
"by_status": {str(item or "unknown"): int(count) for item, count in status_rows},
}
return payload
def _validate_identity(payload: dict[str, Any]) -> tuple[str, str, str]:
name = str(payload.get("name") or "").strip().lower()
language = str(payload.get("language") or "en_US").strip()
category = str(payload.get("category") or "").upper()
if not _NAME.match(name):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Template name should use lowercase letters, numbers, and underscores.")
if not _language_ok(language):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose a language from the list.")
if category not in {"MARKETING", "UTILITY", "AUTHENTICATION"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose Marketing, Utility, or Authentication.")
return name, language, category
async def create_managed_template(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]:
account = _owned_account(db, user, payload.get("account_id"))
token = _token(account)
name, language, category = _validate_identity(payload)
existing = db.scalar(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.waba_id == account.waba_id,
WhatsAppMessageTemplate.name == name,
WhatsAppMessageTemplate.language == language,
)
)
if existing is not None and existing.status != "DISABLED":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template name and language already exist on the WhatsApp account.")
if existing is not None:
db.delete(existing)
db.flush()
components = build_components(payload)
body: dict[str, Any] = {"name": name, "language": language, "category": category, "components": components}
if category != "AUTHENTICATION":
body["parameter_format"] = "NAMED" if str(payload.get("parameter_format") or "").upper() == "NAMED" else "POSITIONAL"
data = await _graph("POST", f"{account.waba_id}/message_templates", token=token, json_body=body)
phone_id = str(payload.get("phone_number_id") or "").strip() or None
if phone_id and phone_id not in {phone.phone_number_id for phone in account.phones}:
phone_id = None
row = WhatsAppMessageTemplate(
user_id=user.id,
account_id=account.id,
waba_id=account.waba_id,
phone_number_id=phone_id,
meta_template_id=str(data.get("id") or "") or None,
name=name,
language=language,
category=str(data.get("category") or category).upper(),
subtype=str(payload.get("subtype") or "default")[:40],
status=str(data.get("status") or "PENDING").upper(),
parameter_format="NAMED" if str(payload.get("parameter_format") or "").upper() == "NAMED" else "POSITIONAL",
definition=payload,
components=components,
)
db.add(row)
db.commit()
db.refresh(row)
return _serialize(row, account, 0)
async def resubmit_managed_template(db: Session, user: UserAccount, template_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
row, account = _owned_template(db, user, template_id)
if row.status not in {"REJECTED", "PAUSED", "APPROVED"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template can be edited after Meta rejects it, pauses it, or approves it.")
if not row.meta_template_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Meta has no id for this template yet.")
_validate_identity({**payload, "name": row.name, "language": row.language, "category": payload.get("category") or row.category})
components = build_components({**payload, "name": row.name, "language": row.language})
data = await _graph("POST", row.meta_template_id, token=_token(account), json_body={"components": components, "category": row.category})
row.components = components
row.definition = payload
row.status = str(data.get("status") or "PENDING").upper()
row.rejected_reason = None
row.category = str(data.get("category") or row.category).upper()
db.commit()
db.refresh(row)
return _serialize(row, account, _sent_counts(db, user).get(row.name, 0))
async def delete_managed_template(db: Session, user: UserAccount, template_id: Any) -> dict[str, str]:
row, account = _owned_template(db, user, template_id)
params: dict[str, Any] = {"name": row.name}
if row.meta_template_id:
params["hsm_id"] = row.meta_template_id
await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(account), params=params)
db.delete(row)
db.commit()
return {"status": "deleted"}
async def upload_header_media(db: Session, user: UserAccount, account_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]:
account = _owned_account(db, user, account_id)
token = _token(account)
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.")
if len(data) > 16 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header files can be at most 16 MB.")
app_id = (account.connection.meta_app_id if account.connection else None) or settings.meta_app_id.strip()
if not app_id:
app_id = await _token_app_id(token) or ""
if not app_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This connection has no Meta app id, so the header file cannot be uploaded.")
session = await _graph(
"POST",
f"{app_id}/uploads",
token=token,
params={"file_name": filename or "header", "file_length": str(len(data)), "file_type": content_type},
)
upload_id = str(session.get("id") or "")
if not upload_id:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Meta did not start the header upload.")
url = f"https://graph.facebook.com/{settings.whatsapp_api_version}/{upload_id}"
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
url,
content=data,
headers={"Authorization": f"OAuth {token}", "file_offset": "0", "Content-Type": content_type},
)
try:
body = response.json()
except ValueError:
body = {}
if response.status_code >= 400:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=_graph_error_message(body, "Meta did not accept the header file."))
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}
def apply_template_status_webhook(db: Session, payload: dict[str, Any]) -> None:
changed = False
for entry in payload.get("entry") or []:
if not isinstance(entry, dict):
continue
for change in entry.get("changes") or []:
if not isinstance(change, dict) or change.get("field") != "message_template_status_update":
continue
value = change.get("value") if isinstance(change.get("value"), dict) else {}
name = str(value.get("message_template_name") or "")
language = str(value.get("message_template_language") or "")
event = str(value.get("event") or "").upper()
if not name or not event:
continue
rows = db.scalars(
select(WhatsAppMessageTemplate).where(
WhatsAppMessageTemplate.name == name,
or_(WhatsAppMessageTemplate.language == language, language == ""),
)
).all()
reason = value.get("reason")
for row in rows:
row.status = event
if reason and str(reason).upper() not in {"NONE", "NULL"}:
row.rejected_reason = str(reason)[:2000]
meta_id = str(value.get("message_template_id") or "")
if meta_id:
row.meta_template_id = meta_id
changed = True
if changed:
db.commit()

View file

@ -20,6 +20,9 @@ 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 { WhatsAppTemplateDetailPage } from './features/whatsapp/WhatsAppTemplateDetailPage'
import { WhatsAppTemplatesPage } from './features/whatsapp/WhatsAppTemplatesPage'
import { WhatsAppTemplateWizard } from './features/whatsapp/WhatsAppTemplateWizard'
import { WhatsAppTokenPage } from './features/whatsapp/WhatsAppTokenPage'
import { MetaOverviewPage } from './features/meta/OverviewPage'
import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
@ -76,6 +79,10 @@ export default function App() {
<Route path="messaging" element={<ComposeMessage />} />
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
<Route path="whatsapp/broadcast" element={<WhatsAppBroadcastPage />} />
<Route path="whatsapp/templates" element={<WhatsAppTemplatesPage />} />
<Route path="whatsapp/templates/new" element={<WhatsAppTemplateWizard />} />
<Route path="whatsapp/templates/:templateId/edit" element={<WhatsAppTemplateWizard />} />
<Route path="whatsapp/templates/:templateId" element={<WhatsAppTemplateDetailPage />} />
<Route path="whatsapp/token" element={<WhatsAppTokenPage />} />
<Route path="meta" element={<MetaOverviewPage />} />
<Route path="meta/connections" element={<MetaConnectionsPage />} />

View file

@ -1,7 +1,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,
ClipboardList, Code2, Cog, CreditCard, Cpu, FileCheck2, FileText, Gauge, Globe, Image, KeyRound, LayoutDashboard,
Link2, LogOut, Megaphone, Menu, MessageSquareText, Network, Phone, Search, Send, Settings, ShieldCheck,
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
MessageCircle,
@ -31,6 +31,7 @@ const groups = [
{ label: 'Communicate', links: [
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
{ label: 'WhatsApp broadcast', path: '/console/whatsapp/broadcast', icon: Send },
{ label: 'WA Templates', path: '/console/whatsapp/templates', icon: ClipboardList },
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap },
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
]},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2004,6 +2004,64 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.meta-split, .meta-posts li { grid-template-columns: 1fr; }
}
.wa-tpl { display: grid; gap: 16px; }
.wa-tpl-heading, .wa-tpl-heading-actions, .wa-tpl-nav, .wa-tpl-filters, .wa-tpl-format, .wa-tpl-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.wa-tpl-heading { justify-content: space-between; }
.wa-tpl-cards { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.wa-tpl-cards article, .wa-tpl-numbers label, .wa-tpl-categories button, .wa-tpl-button { border: 1px solid var(--border); border-radius: 12px; background: white; }
.wa-tpl-cards article { padding: 14px 16px; }
.wa-tpl-cards span, .wa-tpl-guidance, .wa-tpl-step-error { color: var(--muted); font-size: 13px; }
.wa-tpl-cards strong { display: block; margin-top: 6px; font-size: 26px; color: #14233b; }
.wa-tpl-filters input, .wa-tpl-filters select, .wa-tpl-button input, .wa-tpl-button select, .wa-tpl-samples input { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: white; }
.wa-tpl-table { margin-top: 14px; overflow-x: auto; }
.wa-tpl-row { display: grid; grid-template-columns: 1.4fr .8fr .6fr .8fr .6fr .7fr .8fr 1.3fr; gap: 8px; align-items: center; min-width: 860px; padding: 12px 4px; border-top: 1px solid #edf0f4; font-size: 13px; }
.wa-tpl-head { border-top: 0; color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.wa-tpl-name, .wa-tpl-actions button, .wa-tpl-format button, .wa-tpl-tabs button { border: 0; background: transparent; color: #1475ff; padding: 0; font-weight: 600; }
.wa-tpl-actions { display: flex; gap: 8px; }
.wa-tpl-status { font-weight: 700; text-transform: capitalize; }
.wa-tpl-status.is-approved { color: #12805c; }
.wa-tpl-status.is-pending, .wa-tpl-status.is-in_appeal { color: #a15c07; }
.wa-tpl-status.is-rejected { color: #c2362f; }
.wa-tpl-status.is-paused { color: #c56a12; }
.wa-tpl-status.is-disabled { color: #718096; }
.wa-tpl-steps { display: flex; gap: 8px; flex-wrap: wrap; margin: 0; padding: 0; list-style: none; }
.wa-tpl-steps li { padding: 6px 10px; border-radius: 999px; background: white; border: 1px solid var(--border); color: #718096; font-size: 12px; font-weight: 700; }
.wa-tpl-steps .is-current { border-color: #1475ff; color: #1475ff; background: #edf5ff; }
.wa-tpl-steps .is-done { color: #12805c; }
.wa-tpl-split { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(260px, .8fr); gap: 16px; align-items: start; }
.wa-tpl-preview { position: sticky; top: 16px; }
.wa-tpl-preview > p { margin: 0 0 8px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #8a95a7; }
.wa-tpl-phone { background: #efe7dc; border-radius: 16px; padding: 16px; }
.wa-tpl-bubble { display: grid; gap: 6px; background: white; border-radius: 12px; padding: 10px 12px; color: #14233b; }
.wa-tpl-bubble img, .wa-tpl-media { width: 100%; border-radius: 8px; background: #d9e2ec; min-height: 84px; object-fit: cover; }
.wa-tpl-media { display: grid; place-items: center; color: #526176; font-size: 13px; }
.wa-tpl-bubble small { color: #718096; }
.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.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; }
.wa-tpl-button { display: grid; gap: 8px; padding: 12px; margin-bottom: 10px; }
.wa-tpl-samples { display: grid; gap: 8px; margin-bottom: 12px; }
.wa-tpl-samples label { display: grid; grid-template-columns: 140px 1fr; gap: 8px; align-items: center; }
.wa-tpl-nav { margin-top: 16px; }
.wa-tpl-review { display: grid; gap: 8px; margin: 0; }
.wa-tpl-review div { display: grid; grid-template-columns: 160px 1fr; gap: 8px; }
.wa-tpl-review dt { color: #718096; }
.wa-tpl-review dd { margin: 0; }
.wa-tpl-checks { margin: 12px 0 0; padding-left: 18px; }
.wa-tpl-reason { margin-top: 16px; padding: 12px; border-radius: 10px; background: #fff4f2; color: #8d2b26; }
.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-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; }
.wa-tpl-preview { position: static; }
}
.stat-card > span, .balance-card > span { font-size: 13px; }
.stat-card p { font-size: 12px; }
.insight strong, .activity-item strong, .compliance-row { font-size: 13px; }

View file

@ -1335,3 +1335,90 @@ export const whatsappApi = {
body: JSON.stringify(payload),
}),
}
export type WaTemplateDraft = {
account_id: string
phone_number_id: string
name: string
language: string
category: 'MARKETING' | 'UTILITY' | 'AUTHENTICATION' | ''
subtype: string
parameter_format: 'POSITIONAL' | 'NAMED'
header: {
type: 'NONE' | 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT' | 'LOCATION'
text: string
examples: string[]
handle: string
preview_name: string
preview_url: string
}
body: string
body_examples: string[]
footer: string
buttons: {
type: 'URL' | 'PHONE_NUMBER' | 'QUICK_REPLY' | 'COPY_CODE' | 'CATALOG' | 'FLOW'
text: string
url: string
phone: string
example: string
flow_id: string
screen: string
}[]
auth: {
otp_type: 'COPY_CODE'
add_security_recommendation: boolean
code_expiration_minutes: number
copy_label: string
}
}
export type WaManagedTemplate = {
id: string
account_id: string
account_name: string
waba_name: string
phone_number_id: string | null
display_phone: string
meta_template_id: string | null
name: string
language: string
category: string
subtype: string
status: string
quality: string | null
rejected_reason: string | null
parameter_format: string
definition: Partial<WaTemplateDraft>
components: unknown[]
messages_sent: number
messages_sent_source: string
created_at: string
updated_at: string
analytics?: {
source: string
note: string
sent: number
by_status: Record<string, number>
}
}
export const waTemplateApi = {
languages: () => jsonRequest<{ code: string; label: string }[]>('/api/v1/whatsapp/message-templates/languages'),
list: (accountId?: string) =>
jsonRequest<{ templates: WaManagedTemplate[]; summary: { total: number; approved: number; pending: number; rejected: number; paused: number }; warnings: string[] }>(
`/api/v1/whatsapp/message-templates${accountId ? `?account_id=${accountId}` : ''}`,
),
get: (templateId: string) => jsonRequest<WaManagedTemplate>(`/api/v1/whatsapp/message-templates/${templateId}`),
create: (payload: WaTemplateDraft) =>
jsonRequest<WaManagedTemplate>('/api/v1/whatsapp/message-templates', { method: 'POST', body: JSON.stringify(payload) }),
resubmit: (templateId: string, payload: WaTemplateDraft) =>
jsonRequest<WaManagedTemplate>(`/api/v1/whatsapp/message-templates/${templateId}/resubmit`, { method: 'POST', body: JSON.stringify(payload) }),
remove: (templateId: string) =>
jsonRequest<{ status: string }>(`/api/v1/whatsapp/message-templates/${templateId}`, { method: 'DELETE' }),
uploadHeader: (accountId: string, file: File) => {
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 })
},
}