template
This commit is contained in:
parent
8037489503
commit
ad189b8568
15 changed files with 1989 additions and 4 deletions
|
|
@ -26,6 +26,7 @@ from .routes_numbers import router as numbers_router
|
||||||
from .routes_urmiotp import router as urmiotp_router
|
from .routes_urmiotp import router as urmiotp_router
|
||||||
from .routes_voice import router as voice_router
|
from .routes_voice import router as voice_router
|
||||||
from .routes_whatsapp import router as whatsapp_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_meta import router as meta_router
|
||||||
from .routes_agent_templates import router as agent_templates_router
|
from .routes_agent_templates import router as agent_templates_router
|
||||||
from .routes_agent_messages import router as agent_messages_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(integrations_router)
|
||||||
app.include_router(voice_router)
|
app.include_router(voice_router)
|
||||||
app.include_router(whatsapp_router)
|
app.include_router(whatsapp_router)
|
||||||
|
app.include_router(whatsapp_templates_router)
|
||||||
app.include_router(meta_router)
|
app.include_router(meta_router)
|
||||||
app.include_router(agent_templates_router)
|
app.include_router(agent_templates_router)
|
||||||
app.include_router(agent_messages_router)
|
app.include_router(agent_messages_router)
|
||||||
|
|
|
||||||
|
|
@ -614,6 +614,36 @@ class WhatsAppPhoneAsset(Base):
|
||||||
account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones")
|
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):
|
class WhatsAppConnectionAudit(Base):
|
||||||
__tablename__ = "whatsapp_connection_audits"
|
__tablename__ = "whatsapp_connection_audits"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ from .whatsapp_onboarding import (
|
||||||
from .whatsapp_security import verify_meta_signature
|
from .whatsapp_security import verify_meta_signature
|
||||||
from .whatsapp_webhook import persist_webhook_payload
|
from .whatsapp_webhook import persist_webhook_payload
|
||||||
from .whatsapp_messages import record_inbound_messages
|
from .whatsapp_messages import record_inbound_messages
|
||||||
|
from .whatsapp_template_service import apply_template_status_webhook
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -131,6 +132,7 @@ async def receive_webhook(
|
||||||
|
|
||||||
persist_webhook_payload(db, payload)
|
persist_webhook_payload(db, payload)
|
||||||
record_inbound_messages(db, payload)
|
record_inbound_messages(db, payload)
|
||||||
|
apply_template_status_webhook(db, payload)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
98
backend/app/routes_whatsapp_templates.py
Normal file
98
backend/app/routes_whatsapp_templates.py
Normal 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)
|
||||||
703
backend/app/whatsapp_template_service.py
Normal file
703
backend/app/whatsapp_template_service.py
Normal 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()
|
||||||
|
|
@ -20,6 +20,9 @@ import { LogsPage } from './features/logs/LogsPage'
|
||||||
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
|
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
|
||||||
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
|
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
|
||||||
import { WhatsAppBroadcastPage } from './features/whatsapp/WhatsAppBroadcastPage'
|
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 { WhatsAppTokenPage } from './features/whatsapp/WhatsAppTokenPage'
|
||||||
import { MetaOverviewPage } from './features/meta/OverviewPage'
|
import { MetaOverviewPage } from './features/meta/OverviewPage'
|
||||||
import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
|
import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
|
||||||
|
|
@ -76,6 +79,10 @@ export default function App() {
|
||||||
<Route path="messaging" element={<ComposeMessage />} />
|
<Route path="messaging" element={<ComposeMessage />} />
|
||||||
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
|
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
|
||||||
<Route path="whatsapp/broadcast" element={<WhatsAppBroadcastPage />} />
|
<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="whatsapp/token" element={<WhatsAppTokenPage />} />
|
||||||
<Route path="meta" element={<MetaOverviewPage />} />
|
<Route path="meta" element={<MetaOverviewPage />} />
|
||||||
<Route path="meta/connections" element={<MetaConnectionsPage />} />
|
<Route path="meta/connections" element={<MetaConnectionsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
|
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,
|
Link2, LogOut, Megaphone, Menu, MessageSquareText, Network, Phone, Search, Send, Settings, ShieldCheck,
|
||||||
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
|
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
|
@ -31,6 +31,7 @@ const groups = [
|
||||||
{ label: 'Communicate', links: [
|
{ label: 'Communicate', links: [
|
||||||
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
|
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
|
||||||
{ label: 'WhatsApp broadcast', path: '/console/whatsapp/broadcast', icon: Send },
|
{ 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: 'Campaigns', path: '/console/campaigns', icon: Zap },
|
||||||
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
|
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
|
||||||
]},
|
]},
|
||||||
|
|
|
||||||
44
frontend/src/features/whatsapp/WaTemplatePreview.tsx
Normal file
44
frontend/src/features/whatsapp/WaTemplatePreview.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { WaTemplateDraft } from '../../lib/api'
|
||||||
|
import { variableNames, withSamples } from './templateModel'
|
||||||
|
|
||||||
|
export function WaTemplatePreview({ draft }: { draft: WaTemplateDraft }) {
|
||||||
|
const auth = draft.category === 'AUTHENTICATION'
|
||||||
|
const header = auth ? '' : draft.header.type === 'TEXT' ? withSamples(draft.header.text, draft.header.examples) : ''
|
||||||
|
const body = auth
|
||||||
|
? `${draft.auth.add_security_recommendation ? 'Your verification code is 123456. For your security, do not share this code.' : 'Your verification code is 123456.'}`
|
||||||
|
: withSamples(draft.body, draft.body_examples)
|
||||||
|
const footer = auth
|
||||||
|
? (draft.auth.code_expiration_minutes ? `This code expires in ${draft.auth.code_expiration_minutes} minutes.` : '')
|
||||||
|
: draft.footer
|
||||||
|
const buttons = auth
|
||||||
|
? [draft.auth.copy_label || 'Copy code']
|
||||||
|
: draft.buttons.map((button) => button.type === 'COPY_CODE' ? `Copy code ${button.example}` : button.text || button.type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="wa-tpl-preview">
|
||||||
|
<p>Preview</p>
|
||||||
|
<div className="wa-tpl-phone">
|
||||||
|
<div className="wa-tpl-bubble">
|
||||||
|
{!auth && draft.header.type === 'IMAGE' && (draft.header.preview_url ? <img src={draft.header.preview_url} alt="" /> : <div className="wa-tpl-media">{draft.header.preview_name || 'Image'}</div>)}
|
||||||
|
{!auth && draft.header.type === 'VIDEO' && <div className="wa-tpl-media">Video</div>}
|
||||||
|
{!auth && draft.header.type === 'DOCUMENT' && <div className="wa-tpl-media">{draft.header.preview_name || 'Document'}</div>}
|
||||||
|
{!auth && draft.header.type === 'LOCATION' && <div className="wa-tpl-media">Location</div>}
|
||||||
|
{header && <strong>{header}</strong>}
|
||||||
|
<span>{body || 'Your message will appear here.'}</span>
|
||||||
|
{footer && <small>{footer}</small>}
|
||||||
|
</div>
|
||||||
|
{buttons.filter(Boolean).length > 0 && (
|
||||||
|
<div className="wa-tpl-preview-buttons">
|
||||||
|
{buttons.filter(Boolean).map((label, index) => <span key={`${label}-${index}`}>{label}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<small>Sample values are shown in place of variables. Meta sends the real values when you use the template.</small>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewBody(draft: WaTemplateDraft): string {
|
||||||
|
if (draft.category === 'AUTHENTICATION') return 'Your verification code is 123456.'
|
||||||
|
return withSamples(draft.body, draft.body_examples) || variableNames(draft.body).join(', ')
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { useEffect, useState, type FormEvent } from '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'
|
import { whatsappApi, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
|
||||||
|
|
||||||
const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck'
|
const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck'
|
||||||
|
|
@ -19,6 +19,7 @@ type CreatedTemplate = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WhatsAppBroadcastPage() {
|
export function WhatsAppBroadcastPage() {
|
||||||
|
const [params] = useSearchParams()
|
||||||
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
|
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
|
||||||
const [accountId, setAccountId] = useState('')
|
const [accountId, setAccountId] = useState('')
|
||||||
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
|
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
|
||||||
|
|
@ -57,8 +58,12 @@ export function WhatsAppBroadcastPage() {
|
||||||
const marketing = payload.templates.filter((item) => (item.category || '').toUpperCase() === 'MARKETING')
|
const marketing = payload.templates.filter((item) => (item.category || '').toUpperCase() === 'MARKETING')
|
||||||
setTemplates(marketing)
|
setTemplates(marketing)
|
||||||
setTemplatesReady(payload.available)
|
setTemplatesReady(payload.available)
|
||||||
const approved = marketing.find((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name)
|
const requested = params.get('template')
|
||||||
if (approved?.name) {
|
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)
|
setTemplateName(approved.name)
|
||||||
setTemplateLanguage(approved.language || 'en')
|
setTemplateLanguage(approved.language || 'en')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx
Normal file
111
frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { waTemplateApi, type WaManagedTemplate, type WaTemplateDraft } from '../../lib/api'
|
||||||
|
import { WaTemplatePreview } from './WaTemplatePreview'
|
||||||
|
import { emptyDraft } from './templateModel'
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
PENDING: 'Pending',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
PAUSED: 'Paused',
|
||||||
|
DISABLED: 'Disabled',
|
||||||
|
IN_APPEAL: 'In appeal',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhatsAppTemplateDetailPage() {
|
||||||
|
const { templateId = '' } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [row, setRow] = useState<WaManagedTemplate | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [tab, setTab] = useState<'details' | 'analytics'>('details')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
waTemplateApi.get(templateId)
|
||||||
|
.then(setRow)
|
||||||
|
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Template not found'))
|
||||||
|
}, [templateId])
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!row || !window.confirm(`Delete ${row.name}?`)) return
|
||||||
|
try {
|
||||||
|
await waTemplateApi.remove(row.id)
|
||||||
|
navigate('/console/whatsapp/templates')
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'This template could not be deleted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !row) return <p className="auth-error">{error}</p>
|
||||||
|
if (!row) return <div className="auth-loading">Loading template…</div>
|
||||||
|
|
||||||
|
const draft: WaTemplateDraft = {
|
||||||
|
...emptyDraft(),
|
||||||
|
...row.definition,
|
||||||
|
category: ((row.definition.category || row.category) as WaTemplateDraft['category']) || '',
|
||||||
|
name: row.name,
|
||||||
|
language: row.language,
|
||||||
|
header: { ...emptyDraft().header, ...row.definition.header },
|
||||||
|
auth: { ...emptyDraft().auth, ...row.definition.auth },
|
||||||
|
buttons: row.definition.buttons || [],
|
||||||
|
body_examples: row.definition.body_examples || [],
|
||||||
|
}
|
||||||
|
const analytics = row.analytics
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wa-tpl">
|
||||||
|
<div className="account-heading wa-tpl-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Message templates</span>
|
||||||
|
<h1>{row.name}</h1>
|
||||||
|
<p className={`wa-tpl-status is-${row.status.toLowerCase()}`}>{STATUS_LABEL[row.status] || row.status}</p>
|
||||||
|
</div>
|
||||||
|
<div className="wa-tpl-heading-actions">
|
||||||
|
<Link className="secondary-button" to={`/console/whatsapp/templates/new?duplicate=${row.id}`}>Duplicate</Link>
|
||||||
|
{row.status === 'APPROVED' && <Link className="primary-button" to={`/console/whatsapp/broadcast?template=${encodeURIComponent(row.name)}&language=${encodeURIComponent(row.language)}`}>Send</Link>}
|
||||||
|
{['REJECTED', 'PAUSED', 'APPROVED'].includes(row.status) && <Link className="primary-button" to={`/console/whatsapp/templates/${row.id}/edit`}>{row.status === 'APPROVED' ? 'Edit' : 'Edit and resubmit'}</Link>}
|
||||||
|
<button type="button" className="secondary-button" onClick={() => void remove()}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
<div className="wa-tpl-tabs">
|
||||||
|
<button type="button" className={tab === 'details' ? 'is-current' : ''} onClick={() => setTab('details')}>Details</button>
|
||||||
|
<button type="button" className={tab === 'analytics' ? 'is-current' : ''} onClick={() => setTab('analytics')}>Template analytics</button>
|
||||||
|
</div>
|
||||||
|
{tab === 'details' ? (
|
||||||
|
<div className="wa-tpl-split">
|
||||||
|
<section className="wa-biz-card">
|
||||||
|
<dl className="wa-tpl-review">
|
||||||
|
<div><dt>Meta template ID</dt><dd>{row.meta_template_id || 'Not assigned yet'}</dd></div>
|
||||||
|
<div><dt>Account</dt><dd>{row.account_name}</dd></div>
|
||||||
|
<div><dt>Phone</dt><dd>{row.display_phone || 'Shared on the account'}</dd></div>
|
||||||
|
<div><dt>Category</dt><dd>{row.category.toLowerCase()}</dd></div>
|
||||||
|
<div><dt>Language</dt><dd>{row.language}</dd></div>
|
||||||
|
<div><dt>Quality</dt><dd>{row.quality || 'Not rated yet'}</dd></div>
|
||||||
|
<div><dt>Updated</dt><dd>{row.updated_at ? new Date(row.updated_at).toLocaleString() : '—'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{row.rejected_reason && (
|
||||||
|
<div className="wa-tpl-reason">
|
||||||
|
<strong>Why Meta paused or rejected it</strong>
|
||||||
|
<p>{row.rejected_reason}</p>
|
||||||
|
<p>Update the message so it matches Meta’s template rules, then submit it again. Meta reviews the new version.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<WaTemplatePreview draft={draft} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="wa-biz-card">
|
||||||
|
<h2>Messages recorded in Urmi</h2>
|
||||||
|
<p>{analytics?.note}</p>
|
||||||
|
<div className="wa-tpl-cards">
|
||||||
|
<article><span>Sent</span><strong>{analytics?.sent ?? row.messages_sent}</strong></article>
|
||||||
|
{Object.entries(analytics?.by_status || {}).map(([label, value]) => (
|
||||||
|
<article key={label}><span>{label}</span><strong>{value}</strong></article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
497
frontend/src/features/whatsapp/WhatsAppTemplateWizard.tsx
Normal file
497
frontend/src/features/whatsapp/WhatsAppTemplateWizard.tsx
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
import { Bell, KeyRound, Loader2, Megaphone } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WaTemplateDraft, type WhatsAppAccount } from '../../lib/api'
|
||||||
|
import { WaTemplatePreview } from './WaTemplatePreview'
|
||||||
|
import { CATEGORIES, DRAFT_KEY, buttonError, buttonTypes, contentError, emptyDraft, nameError, variableNames } from './templateModel'
|
||||||
|
|
||||||
|
const STEPS = ['Number', 'Name', 'Category', 'Content', 'Buttons', 'Review']
|
||||||
|
|
||||||
|
function mergeDraft(base: WaTemplateDraft, incoming: Partial<WaTemplateDraft> | undefined): WaTemplateDraft {
|
||||||
|
if (!incoming) return base
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...incoming,
|
||||||
|
header: { ...base.header, ...incoming.header },
|
||||||
|
auth: { ...base.auth, ...incoming.auth },
|
||||||
|
buttons: incoming.buttons || base.buttons,
|
||||||
|
body_examples: incoming.body_examples || base.body_examples,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhatsAppTemplateWizard() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { templateId } = useParams()
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const duplicateId = params.get('duplicate')
|
||||||
|
const editing = Boolean(templateId)
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [draft, setDraft] = useState<WaTemplateDraft>(emptyDraft)
|
||||||
|
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
|
||||||
|
const [languages, setLanguages] = useState<{ code: string; label: string }[]>([{ code: 'en_US', label: 'English (US)' }, { code: 'en', label: 'English' }, { code: 'hi', label: 'Hindi' }])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState<WaManagedTemplate | null>(null)
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
whatsappApi.accounts(),
|
||||||
|
waTemplateApi.languages().catch(() => languages),
|
||||||
|
templateId || duplicateId ? waTemplateApi.get(templateId || duplicateId || '') : Promise.resolve(null),
|
||||||
|
]).then(([accountRows, languageRows, existing]) => {
|
||||||
|
setAccounts(accountRows)
|
||||||
|
if (languageRows.length) setLanguages(languageRows)
|
||||||
|
if (existing) {
|
||||||
|
const next = mergeDraft(emptyDraft(), existing.definition)
|
||||||
|
next.account_id = existing.account_id
|
||||||
|
next.phone_number_id = existing.phone_number_id || ''
|
||||||
|
next.name = duplicateId ? `${existing.name}_copy`.slice(0, 512) : existing.name
|
||||||
|
next.language = existing.language
|
||||||
|
next.category = existing.category as WaTemplateDraft['category']
|
||||||
|
next.subtype = existing.subtype
|
||||||
|
next.parameter_format = existing.parameter_format === 'NAMED' ? 'NAMED' : 'POSITIONAL'
|
||||||
|
setDraft(next)
|
||||||
|
} else if (!editing) {
|
||||||
|
const saved = window.localStorage.getItem(DRAFT_KEY)
|
||||||
|
if (saved) {
|
||||||
|
try { setDraft(mergeDraft(emptyDraft(), JSON.parse(saved) as Partial<WaTemplateDraft>)) } catch { /* keep empty */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch((reason) => setError(reason instanceof Error ? reason.message : 'This template could not be opened'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
// languages is only a fallback for the first request.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [templateId, duplicateId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing && !duplicateId) window.localStorage.setItem(DRAFT_KEY, JSON.stringify(draft))
|
||||||
|
}, [draft, editing, duplicateId])
|
||||||
|
|
||||||
|
const numbers = useMemo(() => accounts.flatMap((account) => (
|
||||||
|
account.phones.length
|
||||||
|
? account.phones.map((phone) => ({ account, phone: phone as WhatsAppAccount['phones'][number] | null }))
|
||||||
|
: [{ account, phone: null as WhatsAppAccount['phones'][number] | null }]
|
||||||
|
)), [accounts])
|
||||||
|
const selected = numbers.find((item) => item.account.id === draft.account_id && (item.phone?.phone_number_id || '') === draft.phone_number_id)
|
||||||
|
const sharedCount = selected ? selected.account.phones.length : 0
|
||||||
|
const category = CATEGORIES.find((item) => item.id === draft.category)
|
||||||
|
const stepError = [
|
||||||
|
draft.account_id ? '' : 'Select a WhatsApp number.',
|
||||||
|
nameError(draft.name) || (draft.language ? '' : 'Select a language.'),
|
||||||
|
draft.category ? '' : 'Select a category.',
|
||||||
|
contentError(draft),
|
||||||
|
buttonError(draft),
|
||||||
|
contentError(draft) || buttonError(draft) || nameError(draft.name),
|
||||||
|
][step]
|
||||||
|
|
||||||
|
function patch(partial: Partial<WaTemplateDraft>) {
|
||||||
|
setDraft((current) => ({ ...current, ...partial }))
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertVariable() {
|
||||||
|
const names = variableNames(draft.body)
|
||||||
|
const token = draft.parameter_format === 'NAMED'
|
||||||
|
? `{{${window.prompt('Variable name', 'customer_name')?.trim().replace(/\W/g, '_').toLowerCase() || 'name'}}}`
|
||||||
|
: `{{${names.length + 1}}}`
|
||||||
|
const area = bodyRef.current
|
||||||
|
if (!area) {
|
||||||
|
patch({ body: `${draft.body}${token}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = area.selectionStart
|
||||||
|
const next = `${draft.body.slice(0, start)}${token}${draft.body.slice(area.selectionEnd)}`
|
||||||
|
patch({ body: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(marker: string) {
|
||||||
|
const area = bodyRef.current
|
||||||
|
if (!area) return
|
||||||
|
const start = area.selectionStart
|
||||||
|
const end = area.selectionEnd
|
||||||
|
const selectedText = draft.body.slice(start, end) || 'text'
|
||||||
|
const next = `${draft.body.slice(0, start)}${marker}${selectedText}${marker}${draft.body.slice(end)}`
|
||||||
|
patch({ body: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadHeader(file: File) {
|
||||||
|
if (!draft.account_id) return
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const uploaded = await waTemplateApi.uploadHeader(draft.account_id, file)
|
||||||
|
patch({
|
||||||
|
header: {
|
||||||
|
...draft.header,
|
||||||
|
type: uploaded.format as WaTemplateDraft['header']['type'],
|
||||||
|
handle: uploaded.handle,
|
||||||
|
preview_name: file.name,
|
||||||
|
preview_url: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'The header file was not accepted')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const saved = editing && templateId
|
||||||
|
? await waTemplateApi.resubmit(templateId, draft)
|
||||||
|
: await waTemplateApi.create(draft)
|
||||||
|
if (!editing) window.localStorage.removeItem(DRAFT_KEY)
|
||||||
|
setSubmitted(saved)
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Meta did not accept this template')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="wa-tpl">
|
||||||
|
<section className="wa-biz-card wa-tpl-result">
|
||||||
|
<h1>Template submitted for review</h1>
|
||||||
|
<p>Meta has the template. It is not approved until Meta says so.</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Template</dt><dd>{submitted.name}</dd></div>
|
||||||
|
<div><dt>Account</dt><dd>{submitted.account_name}</dd></div>
|
||||||
|
<div><dt>Category</dt><dd>{submitted.category.toLowerCase()}</dd></div>
|
||||||
|
<div><dt>Language</dt><dd>{submitted.language}</dd></div>
|
||||||
|
<div><dt>Status</dt><dd>{submitted.status === 'APPROVED' ? 'Approved' : 'Pending review'}</dd></div>
|
||||||
|
<div><dt>Meta template ID</dt><dd>{submitted.meta_template_id || 'Waiting for Meta'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div className="wa-tpl-nav">
|
||||||
|
<Link className="primary-button" to={`/console/whatsapp/templates/${submitted.id}`}>View template</Link>
|
||||||
|
<button type="button" className="secondary-button" onClick={() => { setDraft(emptyDraft()); setStep(0); setSubmitted(null); navigate('/console/whatsapp/templates/new') }}>Create another template</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wa-tpl">
|
||||||
|
<div className="account-heading">
|
||||||
|
<span className="eyebrow">Message templates</span>
|
||||||
|
<h1>{editing ? 'Edit template' : 'Create WhatsApp template'}</h1>
|
||||||
|
</div>
|
||||||
|
<ol className="wa-tpl-steps">
|
||||||
|
{STEPS.map((label, index) => <li key={label} className={index === step ? 'is-current' : index < step ? 'is-done' : ''}>{index + 1}. {label}</li>)}
|
||||||
|
</ol>
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
{loading ? <div className="auth-loading">Loading…</div> : (
|
||||||
|
<div className={step >= 2 ? 'wa-tpl-split' : ''}>
|
||||||
|
<section className="wa-biz-card">
|
||||||
|
{step === 0 && (
|
||||||
|
<>
|
||||||
|
<h2>Select WhatsApp number</h2>
|
||||||
|
<p>The template is created on the WhatsApp Business account that owns this number. Other numbers on the same account can use it after Meta approves it.</p>
|
||||||
|
<div className="wa-tpl-numbers">
|
||||||
|
{numbers.map(({ account, phone }) => {
|
||||||
|
const phoneId = phone?.phone_number_id || ''
|
||||||
|
const checked = draft.account_id === account.id && draft.phone_number_id === phoneId
|
||||||
|
return (
|
||||||
|
<label key={`${account.id}-${phoneId}`} className={checked ? 'is-selected' : ''}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="wa-number"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => patch({ account_id: account.id, phone_number_id: phoneId })}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{phone?.verified_name || account.name}</strong>
|
||||||
|
<em>{phone?.display_phone || 'No display number'}</em>
|
||||||
|
<small>WABA: {account.name}</small>
|
||||||
|
<small>{account.status === 'connected' ? 'Connected' : account.status} · {account.messaging_status === 'active' ? 'Messaging available' : 'Messaging unavailable'}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{numbers.length === 0 && <p>Connect a WhatsApp number first. <Link to="/console/whatsapp">Open WhatsApp</Link></p>}
|
||||||
|
{sharedCount > 1 && <p>This account has {sharedCount} numbers. The template is shared across them.</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
|
<h2>Template details</h2>
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Template name</strong>
|
||||||
|
<span>{draft.name.length}/512. Lowercase letters, numbers, and underscores. Example: order_confirmation_v2</span>
|
||||||
|
<input value={draft.name} onChange={(event) => patch({ name: event.target.value.toLowerCase() })} disabled={editing} />
|
||||||
|
</label>
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Language</strong>
|
||||||
|
<select value={draft.language} onChange={(event) => patch({ language: event.target.value })} disabled={editing}>
|
||||||
|
{languages.map((item) => <option key={item.code} value={item.code}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p>Selected account: {selected ? `${selected.phone?.verified_name || selected.account.name} · ${selected.phone?.display_phone || selected.account.name}` : 'None selected'}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<h2>Choose template category</h2>
|
||||||
|
<div className="wa-tpl-categories">
|
||||||
|
{CATEGORIES.map((item) => {
|
||||||
|
const Icon = item.id === 'MARKETING' ? Megaphone : item.id === 'UTILITY' ? Bell : KeyRound
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.id}
|
||||||
|
className={draft.category === item.id ? 'is-selected' : ''}
|
||||||
|
disabled={editing}
|
||||||
|
onClick={() => patch({ category: item.id, subtype: item.subtypes[0][0], buttons: item.id === 'AUTHENTICATION' ? [] : draft.buttons })}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span>{item.description}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{category && (
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Type</strong>
|
||||||
|
<select value={draft.subtype} onChange={(event) => patch({ subtype: event.target.value })}>
|
||||||
|
{category.subtypes.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 3 && draft.category === 'AUTHENTICATION' && (
|
||||||
|
<>
|
||||||
|
<h2>One-time passcode</h2>
|
||||||
|
<p>Meta writes the code message. You choose the security line, expiry, and copy button.</p>
|
||||||
|
<label className="explorer-input"><strong>Copy button label</strong><input value={draft.auth.copy_label} onChange={(event) => patch({ auth: { ...draft.auth, copy_label: event.target.value } })} /></label>
|
||||||
|
<label className="explorer-input"><strong>Code expiry minutes</strong><span>Optional. Use 1 to 90, or 0 to leave it out.</span><input type="number" min={0} max={90} value={draft.auth.code_expiration_minutes} onChange={(event) => patch({ auth: { ...draft.auth, code_expiration_minutes: Number(event.target.value) } })} /></label>
|
||||||
|
<label className="wa-tpl-check"><input type="checkbox" checked={draft.auth.add_security_recommendation} onChange={(event) => patch({ auth: { ...draft.auth, add_security_recommendation: event.target.checked } })} /> Add the security recommendation</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 3 && draft.category !== 'AUTHENTICATION' && (
|
||||||
|
<>
|
||||||
|
<h2>Design your message</h2>
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Variable type</strong>
|
||||||
|
<select value={draft.parameter_format} onChange={(event) => patch({ parameter_format: event.target.value as WaTemplateDraft['parameter_format'] })}>
|
||||||
|
<option value="POSITIONAL">Numbered variables, {'{{1}}'} and {'{{2}}'}</option>
|
||||||
|
<option value="NAMED">Named variables, {'{{customer_name}}'}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Header</strong>
|
||||||
|
<select value={draft.header.type} onChange={(event) => patch({ header: { ...draft.header, type: event.target.value as WaTemplateDraft['header']['type'] } })}>
|
||||||
|
<option value="NONE">None</option>
|
||||||
|
<option value="TEXT">Text</option>
|
||||||
|
<option value="IMAGE">Image</option>
|
||||||
|
<option value="VIDEO">Video</option>
|
||||||
|
<option value="DOCUMENT">Document</option>
|
||||||
|
<option value="LOCATION">Location</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{draft.header.type === 'TEXT' && (
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Header text</strong>
|
||||||
|
<span>{draft.header.text.length}/60</span>
|
||||||
|
<input value={draft.header.text} maxLength={60} onChange={(event) => patch({ header: { ...draft.header, text: event.target.value } })} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{['IMAGE', 'VIDEO', 'DOCUMENT'].includes(draft.header.type) && (
|
||||||
|
<label className="wa-tpl-drop">
|
||||||
|
<strong>{draft.header.preview_name || 'Drop a JPEG, PNG, MP4, or PDF'}</strong>
|
||||||
|
<input type="file" accept={draft.header.type === 'IMAGE' ? 'image/jpeg,image/png' : draft.header.type === 'VIDEO' ? 'video/mp4' : 'application/pdf'} onChange={(event) => { const file = event.target.files?.[0]; if (file) void uploadHeader(file) }} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Message body</strong>
|
||||||
|
<span>{draft.body.length}/1024</span>
|
||||||
|
<div className="wa-tpl-format">
|
||||||
|
<button type="button" onClick={() => wrap('*')}>Bold</button>
|
||||||
|
<button type="button" onClick={() => wrap('_')}>Italic</button>
|
||||||
|
<button type="button" onClick={() => wrap('~')}>Strike</button>
|
||||||
|
<button type="button" onClick={() => wrap('```')}>Mono</button>
|
||||||
|
{['🙂', '✅', '📦', '🎉'].map((emoji) => <button type="button" key={emoji} onClick={() => patch({ body: `${draft.body}${emoji}` })}>{emoji}</button>)}
|
||||||
|
<button type="button" onClick={insertVariable}>Add variable</button>
|
||||||
|
</div>
|
||||||
|
<textarea ref={bodyRef} rows={7} value={draft.body} onChange={(event) => patch({ body: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
{variableNames(draft.body).length > 0 && (
|
||||||
|
<div className="wa-tpl-samples">
|
||||||
|
{variableNames(draft.body).map((name, index) => (
|
||||||
|
<label key={name}>
|
||||||
|
<span>{`{{${name}}}`}</span>
|
||||||
|
<input value={draft.body_examples[index] || ''} placeholder="Sample value" onChange={(event) => {
|
||||||
|
const body_examples = [...draft.body_examples]
|
||||||
|
body_examples[index] = event.target.value
|
||||||
|
patch({ body_examples })
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{draft.header.type === 'TEXT' && variableNames(draft.header.text).length > 0 && (
|
||||||
|
<div className="wa-tpl-samples">
|
||||||
|
{variableNames(draft.header.text).map((name, index) => (
|
||||||
|
<label key={name}>
|
||||||
|
<span>Header {`{{${name}}}`}</span>
|
||||||
|
<input value={draft.header.examples[index] || ''} onChange={(event) => {
|
||||||
|
const examples = [...draft.header.examples]
|
||||||
|
examples[index] = event.target.value
|
||||||
|
patch({ header: { ...draft.header, examples } })
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Footer</strong>
|
||||||
|
<span>{draft.footer.length}/60. No variables.</span>
|
||||||
|
<input value={draft.footer} maxLength={60} onChange={(event) => patch({ footer: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 4 && (
|
||||||
|
<>
|
||||||
|
<h2>Add message actions</h2>
|
||||||
|
{draft.category === 'AUTHENTICATION' ? <p>The copy-code button from the previous step is the only action for this authentication template.</p> : (
|
||||||
|
<>
|
||||||
|
{draft.buttons.map((button, index) => (
|
||||||
|
<div className="wa-tpl-button" key={`${button.type}-${index}`}>
|
||||||
|
<strong>Button {index + 1}</strong>
|
||||||
|
<select value={button.type} onChange={(event) => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
buttons[index] = { ...button, type: event.target.value as WaTemplateDraft['buttons'][number]['type'] }
|
||||||
|
patch({ buttons })
|
||||||
|
}}>
|
||||||
|
{buttonTypes(draft.category, draft.subtype).map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
{button.type !== 'COPY_CODE' && <input value={button.text} placeholder="Label" maxLength={25} onChange={(event) => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
buttons[index] = { ...button, text: event.target.value }
|
||||||
|
patch({ buttons })
|
||||||
|
}} />}
|
||||||
|
{button.type === 'URL' && <input value={button.url} placeholder="https://example.com/orders/{{1}}" onChange={(event) => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
buttons[index] = { ...button, url: event.target.value }
|
||||||
|
patch({ buttons })
|
||||||
|
}} />}
|
||||||
|
{button.type === 'URL' && variableNames(button.url).length > 0 && <input value={button.example} placeholder="https://example.com/orders/ORD12345" onChange={(event) => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
buttons[index] = { ...button, example: event.target.value }
|
||||||
|
patch({ buttons })
|
||||||
|
}} />}
|
||||||
|
{button.type === 'PHONE_NUMBER' && <input value={button.phone} placeholder="+91 98765 43210" onChange={(event) => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
buttons[index] = { ...button, phone: event.target.value }
|
||||||
|
patch({ buttons })
|
||||||
|
}} />}
|
||||||
|
{button.type === 'COPY_CODE' && <input value={button.example} placeholder="Offer code sample" maxLength={15} onChange={(event) => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
buttons[index] = { ...button, example: event.target.value }
|
||||||
|
patch({ buttons })
|
||||||
|
}} />}
|
||||||
|
{button.type === 'FLOW' && <input value={button.flow_id} placeholder="Flow ID" onChange={(event) => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
buttons[index] = { ...button, flow_id: event.target.value }
|
||||||
|
patch({ buttons })
|
||||||
|
}} />}
|
||||||
|
<span>
|
||||||
|
<button type="button" disabled={index === 0} onClick={() => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
;[buttons[index - 1], buttons[index]] = [buttons[index], buttons[index - 1]]
|
||||||
|
patch({ buttons })
|
||||||
|
}}>Up</button>
|
||||||
|
<button type="button" disabled={index === draft.buttons.length - 1} onClick={() => {
|
||||||
|
const buttons = [...draft.buttons]
|
||||||
|
;[buttons[index + 1], buttons[index]] = [buttons[index], buttons[index + 1]]
|
||||||
|
patch({ buttons })
|
||||||
|
}}>Down</button>
|
||||||
|
<button type="button" onClick={() => patch({ buttons: draft.buttons.filter((_, item) => item !== index) })}>Remove</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Add button</strong>
|
||||||
|
<select value="" onChange={(event) => {
|
||||||
|
const type = event.target.value as WaTemplateDraft['buttons'][number]['type']
|
||||||
|
if (!type) return
|
||||||
|
patch({ buttons: [...draft.buttons, { type, text: '', url: '', phone: '', example: '', flow_id: '', screen: 'MAIN' }] })
|
||||||
|
}}>
|
||||||
|
<option value="">Choose a button</option>
|
||||||
|
{buttonTypes(draft.category, draft.subtype).map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 5 && (
|
||||||
|
<>
|
||||||
|
<h2>Review your template</h2>
|
||||||
|
<dl className="wa-tpl-review">
|
||||||
|
<div><dt>Template name</dt><dd>{draft.name}</dd></div>
|
||||||
|
<div><dt>WhatsApp account</dt><dd>{selected?.account.name || '—'}</dd></div>
|
||||||
|
<div><dt>Phone number</dt><dd>{selected?.phone?.display_phone || '—'}</dd></div>
|
||||||
|
<div><dt>Category</dt><dd>{draft.category.toLowerCase()}</dd></div>
|
||||||
|
<div><dt>Type</dt><dd>{draft.subtype.replaceAll('_', ' ')}</dd></div>
|
||||||
|
<div><dt>Language</dt><dd>{draft.language}</dd></div>
|
||||||
|
<div><dt>Header</dt><dd>{draft.category === 'AUTHENTICATION' ? 'None' : draft.header.type.toLowerCase()}</dd></div>
|
||||||
|
<div><dt>Body</dt><dd>{draft.category === 'AUTHENTICATION' ? 'Meta one-time code message' : draft.body}</dd></div>
|
||||||
|
<div><dt>Footer</dt><dd>{draft.footer || 'None'}</dd></div>
|
||||||
|
<div><dt>Buttons</dt><dd>{draft.category === 'AUTHENTICATION' ? draft.auth.copy_label : draft.buttons.map((button) => button.text || button.type).join(', ') || 'None'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<ul className="wa-tpl-checks">
|
||||||
|
{[
|
||||||
|
[!nameError(draft.name), nameError(draft.name) || 'Template name valid', 1],
|
||||||
|
[Boolean(draft.category), draft.category ? 'Category selected' : 'Category missing', 2],
|
||||||
|
[Boolean(draft.language), draft.language ? 'Language selected' : 'Language missing', 1],
|
||||||
|
[!contentError(draft), contentError(draft) || 'Message content is ready', 3],
|
||||||
|
[!buttonError(draft), buttonError(draft) || 'Buttons are ready', 4],
|
||||||
|
].map(([ok, label, target]) => (
|
||||||
|
<li key={String(label)}>
|
||||||
|
{ok ? '✓ ' : ''}{label}
|
||||||
|
{!ok && <button type="button" onClick={() => setStep(Number(target))}>Open this step</button>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p>Meta decides the final category and whether the template is approved.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step >= 2 && category && step < 5 && (
|
||||||
|
<div className="wa-tpl-guidance">
|
||||||
|
<strong>What this template is good for</strong>
|
||||||
|
<p>{category.goodFor}</p>
|
||||||
|
<strong>Supported options</strong>
|
||||||
|
<p>{category.options}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="wa-tpl-nav">
|
||||||
|
<button type="button" className="secondary-button" onClick={() => step === 0 ? navigate('/console/whatsapp/templates') : setStep((current) => current - 1)}>
|
||||||
|
{step === 0 ? 'Cancel' : 'Previous'}
|
||||||
|
</button>
|
||||||
|
{step < 5 ? (
|
||||||
|
<button type="button" className="primary-button" disabled={Boolean(stepError)} onClick={() => setStep((current) => current + 1)}>Next</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="primary-button" disabled={busy || Boolean(stepError)} onClick={() => void submit()}>
|
||||||
|
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||||
|
Submit for review
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{stepError && step < 5 && <p className="wa-tpl-step-error">{stepError}</p>}
|
||||||
|
</section>
|
||||||
|
{step >= 2 && <WaTemplatePreview draft={draft} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx
Normal file
183
frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { Loader2, Plus } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount } from '../../lib/api'
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
PENDING: 'Pending',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
PAUSED: 'Paused',
|
||||||
|
DISABLED: 'Disabled',
|
||||||
|
IN_APPEAL: 'In appeal',
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
return STATUS_LABEL[status] || status.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhatsAppTemplatesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
|
||||||
|
const [accountId, setAccountId] = useState('')
|
||||||
|
const [rows, setRows] = useState<WaManagedTemplate[]>([])
|
||||||
|
const [summary, setSummary] = useState({ total: 0, approved: 0, pending: 0, rejected: 0, paused: 0 })
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [guidelines, setGuidelines] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [category, setCategory] = useState('')
|
||||||
|
const [language, setLanguage] = useState('')
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [from, setFrom] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
|
||||||
|
function load(nextAccount = accountId) {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
waTemplateApi.list(nextAccount || undefined)
|
||||||
|
.then((payload) => {
|
||||||
|
setRows(payload.templates)
|
||||||
|
setSummary(payload.summary)
|
||||||
|
setWarnings(payload.warnings)
|
||||||
|
})
|
||||||
|
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Templates could not be loaded'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
whatsappApi.accounts()
|
||||||
|
.then(setAccounts)
|
||||||
|
.catch(() => setAccounts([]))
|
||||||
|
load('')
|
||||||
|
// The first load is intentionally unfiltered.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => rows.filter((row) => {
|
||||||
|
const haystack = `${row.name} ${row.meta_template_id || ''}`.toLowerCase()
|
||||||
|
if (search && !haystack.includes(search.toLowerCase())) return false
|
||||||
|
if (category && row.category !== category) return false
|
||||||
|
if (language && row.language !== language) return false
|
||||||
|
if (status && row.status !== status) return false
|
||||||
|
const updated = row.updated_at?.slice(0, 10)
|
||||||
|
if (from && updated < from) return false
|
||||||
|
if (to && updated > to) return false
|
||||||
|
return true
|
||||||
|
}), [rows, search, category, language, status, from, to])
|
||||||
|
|
||||||
|
const languages = [...new Set(rows.map((row) => row.language))].sort()
|
||||||
|
const cards = [
|
||||||
|
['Total templates', summary.total],
|
||||||
|
['Approved', summary.approved],
|
||||||
|
['Pending review', summary.pending],
|
||||||
|
['Rejected', summary.rejected],
|
||||||
|
['Paused', summary.paused],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
async function remove(row: WaManagedTemplate) {
|
||||||
|
if (!window.confirm(`Delete ${row.name}? Meta removes it from this WhatsApp account.`)) return
|
||||||
|
try {
|
||||||
|
await waTemplateApi.remove(row.id)
|
||||||
|
load()
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'This template could not be deleted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wa-tpl">
|
||||||
|
<div className="account-heading wa-tpl-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Communicate</span>
|
||||||
|
<h1>Message templates</h1>
|
||||||
|
<p>Create, manage, and monitor your WhatsApp message templates.</p>
|
||||||
|
</div>
|
||||||
|
<div className="wa-tpl-heading-actions">
|
||||||
|
<button type="button" className="secondary-button" onClick={() => setGuidelines((open) => !open)}>Template guidelines</button>
|
||||||
|
<Link className="primary-button" to="/console/whatsapp/templates/new"><Plus size={16} /> Create template</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guidelines && (
|
||||||
|
<section className="wa-biz-card wa-tpl-guidelines">
|
||||||
|
<h2>Template guidelines</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Marketing is for offers and announcements. Utility is for an existing order or account. Authentication is only for one-time codes.</li>
|
||||||
|
<li>Names use lowercase letters, numbers, and underscores, and must be unique on the WhatsApp account for that language.</li>
|
||||||
|
<li>Meta reviews every template. A template stays pending until Meta approves it, and Meta can change the category.</li>
|
||||||
|
<li>Templates belong to the WhatsApp Business account, so every number on that account can use an approved template.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
{warnings.map((warning) => <p className="auth-error" key={warning}>{warning}</p>)}
|
||||||
|
|
||||||
|
<div className="wa-tpl-cards">
|
||||||
|
{cards.map(([label, value]) => (
|
||||||
|
<article key={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="wa-biz-card">
|
||||||
|
<div className="wa-tpl-filters">
|
||||||
|
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search by name or ID" aria-label="Search templates" />
|
||||||
|
<select value={accountId} onChange={(event) => { setAccountId(event.target.value); load(event.target.value) }} aria-label="WhatsApp account">
|
||||||
|
<option value="">All accounts</option>
|
||||||
|
{accounts.map((account) => <option key={account.id} value={account.id}>{account.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={category} onChange={(event) => setCategory(event.target.value)} aria-label="Category">
|
||||||
|
<option value="">Category</option>
|
||||||
|
<option value="MARKETING">Marketing</option>
|
||||||
|
<option value="UTILITY">Utility</option>
|
||||||
|
<option value="AUTHENTICATION">Authentication</option>
|
||||||
|
</select>
|
||||||
|
<select value={language} onChange={(event) => setLanguage(event.target.value)} aria-label="Language">
|
||||||
|
<option value="">Language</option>
|
||||||
|
{languages.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={status} onChange={(event) => setStatus(event.target.value)} aria-label="Status">
|
||||||
|
<option value="">Status</option>
|
||||||
|
{Object.entries(STATUS_LABEL).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||||
|
</select>
|
||||||
|
<input type="date" value={from} onChange={(event) => setFrom(event.target.value)} aria-label="Updated from" />
|
||||||
|
<input type="date" value={to} onChange={(event) => setTo(event.target.value)} aria-label="Updated to" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="auth-loading"><Loader2 size={16} className="wa-spin" /> Loading templates…</div>}
|
||||||
|
{!loading && accounts.length === 0 && <p>Connect a WhatsApp number before creating templates. <Link to="/console/whatsapp">Open WhatsApp</Link></p>}
|
||||||
|
{!loading && accounts.length > 0 && filtered.length === 0 && <p>{rows.length === 0 ? 'No templates yet. Create one and Meta will review it.' : 'No templates match these filters.'}</p>}
|
||||||
|
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="wa-tpl-table">
|
||||||
|
<div className="wa-tpl-row wa-tpl-head">
|
||||||
|
<span>Template name</span><span>Category</span><span>Language</span><span>Status</span><span>Quality</span><span>Messages sent</span><span>Last updated</span><span>Actions</span>
|
||||||
|
</div>
|
||||||
|
{filtered.map((row) => (
|
||||||
|
<div className="wa-tpl-row" key={row.id}>
|
||||||
|
<button type="button" className="wa-tpl-name" onClick={() => navigate(`/console/whatsapp/templates/${row.id}`)}>{row.name}</button>
|
||||||
|
<span>{row.category.toLowerCase()}</span>
|
||||||
|
<span>{row.language}</span>
|
||||||
|
<span className={`wa-tpl-status is-${row.status.toLowerCase()}`}>{statusLabel(row.status)}</span>
|
||||||
|
<span>{row.quality || '—'}</span>
|
||||||
|
<span>{row.messages_sent}</span>
|
||||||
|
<span>{row.updated_at ? new Date(row.updated_at).toLocaleDateString() : '—'}</span>
|
||||||
|
<span className="wa-tpl-actions">
|
||||||
|
<button type="button" onClick={() => navigate(`/console/whatsapp/templates/${row.id}`)}>View</button>
|
||||||
|
{['REJECTED', 'PAUSED', 'APPROVED'].includes(row.status) && <button type="button" onClick={() => navigate(`/console/whatsapp/templates/${row.id}/edit`)}>Edit</button>}
|
||||||
|
<button type="button" onClick={() => navigate(`/console/whatsapp/templates/new?duplicate=${row.id}`)}>Duplicate</button>
|
||||||
|
<button type="button" onClick={() => void remove(row)}>Delete</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
frontend/src/features/whatsapp/templateModel.ts
Normal file
157
frontend/src/features/whatsapp/templateModel.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import type { WaTemplateDraft } from '../../lib/api'
|
||||||
|
|
||||||
|
export const DRAFT_KEY = 'urmi.waTemplateDraft'
|
||||||
|
|
||||||
|
export const CATEGORIES = [
|
||||||
|
{
|
||||||
|
id: 'MARKETING' as const,
|
||||||
|
title: 'Marketing',
|
||||||
|
description: 'Promotions, product announcements, offers, and customer engagement.',
|
||||||
|
subtypes: [
|
||||||
|
['default', 'Default'],
|
||||||
|
['catalog', 'Catalog'],
|
||||||
|
['flows', 'Flows'],
|
||||||
|
['order_details', 'Order details'],
|
||||||
|
['calling_permissions', 'Calling permissions request'],
|
||||||
|
],
|
||||||
|
goodFor: 'Offers, product news, and invitations people have agreed to receive.',
|
||||||
|
options: 'Header, body variables, footer, website, phone, quick reply, and offer code.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'UTILITY' as const,
|
||||||
|
title: 'Utility',
|
||||||
|
description: 'Transactional notifications related to an existing customer interaction.',
|
||||||
|
subtypes: [
|
||||||
|
['default', 'Default'],
|
||||||
|
['flows', 'Flows'],
|
||||||
|
['order_status', 'Order status'],
|
||||||
|
['order_details', 'Order details'],
|
||||||
|
['calling_permissions', 'Calling permissions request'],
|
||||||
|
],
|
||||||
|
goodFor: 'Order updates, account notices, and appointment reminders.',
|
||||||
|
options: 'Header, body variables, footer, website, phone, and quick reply.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUTHENTICATION' as const,
|
||||||
|
title: 'Authentication',
|
||||||
|
description: 'One-time passwords and account verification messages.',
|
||||||
|
subtypes: [['otp', 'One-time passcode']],
|
||||||
|
goodFor: 'Sending a one-time code the customer can copy.',
|
||||||
|
options: 'Security recommendation, code expiry, and a copy-code button. The message wording is set by Meta.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function emptyDraft(): WaTemplateDraft {
|
||||||
|
return {
|
||||||
|
account_id: '',
|
||||||
|
phone_number_id: '',
|
||||||
|
name: '',
|
||||||
|
language: 'en_US',
|
||||||
|
category: '',
|
||||||
|
subtype: 'default',
|
||||||
|
parameter_format: 'POSITIONAL',
|
||||||
|
header: { type: 'NONE', text: '', examples: [], handle: '', preview_name: '', preview_url: '' },
|
||||||
|
body: '',
|
||||||
|
body_examples: [],
|
||||||
|
footer: '',
|
||||||
|
buttons: [],
|
||||||
|
auth: { otp_type: 'COPY_CODE', add_security_recommendation: true, code_expiration_minutes: 10, copy_label: 'Copy code' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function variableNames(text: string): string[] {
|
||||||
|
const found: string[] = []
|
||||||
|
const pattern = /\{\{([A-Za-z0-9_]+)\}\}/g
|
||||||
|
for (const match of text.matchAll(pattern)) {
|
||||||
|
if (!found.includes(match[1])) found.push(match[1])
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withSamples(text: string, samples: string[]): string {
|
||||||
|
const names = variableNames(text)
|
||||||
|
return names.reduce((current, name, index) => current.replaceAll(`{{${name}}}`, samples[index] || `{{${name}}}`), text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buttonTypes(category: string, subtype: string): { id: WaTemplateDraft['buttons'][number]['type']; label: string }[] {
|
||||||
|
if (category === 'AUTHENTICATION') return []
|
||||||
|
const items: { id: WaTemplateDraft['buttons'][number]['type']; label: string }[] = [
|
||||||
|
{ id: 'URL', label: 'Visit website' },
|
||||||
|
{ id: 'PHONE_NUMBER', label: 'Call phone number' },
|
||||||
|
{ id: 'QUICK_REPLY', label: 'Quick reply' },
|
||||||
|
]
|
||||||
|
if (category === 'MARKETING') items.push({ id: 'COPY_CODE', label: 'Copy offer code' })
|
||||||
|
if (subtype === 'catalog') items.push({ id: 'CATALOG', label: 'View catalog' })
|
||||||
|
if (subtype === 'flows') items.push({ id: 'FLOW', label: 'WhatsApp Flow' })
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nameError(name: string): string {
|
||||||
|
if (!name.trim()) return 'Template name is required.'
|
||||||
|
if (!/^[a-z0-9_]+$/.test(name.trim())) return 'Use lowercase letters, numbers, and underscores only.'
|
||||||
|
if (name.trim().length > 512) return 'Name can be at most 512 characters.'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentError(draft: WaTemplateDraft): string {
|
||||||
|
if (draft.category === 'AUTHENTICATION') {
|
||||||
|
const minutes = Number(draft.auth.code_expiration_minutes)
|
||||||
|
if (minutes && (minutes < 1 || minutes > 90)) return 'Code expiry should be between 1 and 90 minutes.'
|
||||||
|
if (!draft.auth.copy_label.trim() || draft.auth.copy_label.trim().length > 25) return 'Copy button label should be 1 to 25 characters.'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (!draft.body.trim()) return 'Message body is required.'
|
||||||
|
if (draft.body.length > 1024) return 'Message body can be at most 1,024 characters.'
|
||||||
|
if (draft.footer.length > 60) return 'Footer can be at most 60 characters.'
|
||||||
|
if (variableNames(draft.footer).length) return 'Footer cannot contain variables.'
|
||||||
|
if (draft.header.type === 'TEXT') {
|
||||||
|
if (!draft.header.text.trim()) return 'Header text is required.'
|
||||||
|
if (draft.header.text.length > 60) return 'Header text can be at most 60 characters.'
|
||||||
|
if (variableNames(draft.header.text).some((_, index) => !draft.header.examples[index]?.trim())) return 'Header variables need sample values.'
|
||||||
|
}
|
||||||
|
if (['IMAGE', 'VIDEO', 'DOCUMENT'].includes(draft.header.type) && !draft.header.handle) return 'Upload a header file.'
|
||||||
|
const names = variableNames(draft.body)
|
||||||
|
if (draft.parameter_format === 'POSITIONAL' && names.some((name, index) => name !== String(index + 1))) {
|
||||||
|
return 'Numbered variables must be {{1}}, {{2}}, and so on, with no gaps.'
|
||||||
|
}
|
||||||
|
if (draft.parameter_format === 'NAMED' && names.some((name) => /^\d+$/.test(name))) return 'Named variables need a name, such as customer_name.'
|
||||||
|
if (names.some((_, index) => !draft.body_examples[index]?.trim())) return 'Every body variable needs a sample value.'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buttonError(draft: WaTemplateDraft): string {
|
||||||
|
if (draft.category === 'AUTHENTICATION') return ''
|
||||||
|
if (draft.buttons.length > 10) return 'A template can have at most 10 buttons.'
|
||||||
|
let urls = 0
|
||||||
|
let phones = 0
|
||||||
|
let copies = 0
|
||||||
|
for (const button of draft.buttons) {
|
||||||
|
if (button.type !== 'COPY_CODE' && (!button.text.trim() || button.text.trim().length > 25)) return 'Each button label should be 1 to 25 characters.'
|
||||||
|
if (button.type === 'URL') {
|
||||||
|
urls += 1
|
||||||
|
if (!button.url.startsWith('https://')) return 'Website buttons need an https URL.'
|
||||||
|
const urlNames = variableNames(button.url)
|
||||||
|
if (urlNames.length > 1) return 'A website button can use one variable.'
|
||||||
|
if (urlNames.length === 1 && !button.url.endsWith(`{{${urlNames[0]}}}`)) return 'Put the website variable at the end of the URL.'
|
||||||
|
if (urlNames.length && !button.example.trim().startsWith('https://')) return 'Sample URL should be a full https address with the variable filled in.'
|
||||||
|
}
|
||||||
|
if (button.type === 'PHONE_NUMBER') {
|
||||||
|
phones += 1
|
||||||
|
if (button.phone.replace(/\D/g, '').length < 8) return 'Enter a phone number with the country code.'
|
||||||
|
}
|
||||||
|
if (button.type === 'COPY_CODE') {
|
||||||
|
copies += 1
|
||||||
|
if (!button.example.trim() || button.example.trim().length > 15) return 'Offer code sample should be 1 to 15 characters.'
|
||||||
|
}
|
||||||
|
if (button.type === 'FLOW' && !button.flow_id.trim()) return 'A WhatsApp Flow button needs the Flow ID from Meta.'
|
||||||
|
}
|
||||||
|
if (urls > 2 || phones > 1 || copies > 1) return 'Use at most two website buttons, one phone button, and one offer code.'
|
||||||
|
const kinds = draft.buttons.map((button) => button.type)
|
||||||
|
const quick = kinds.flatMap((kind, index) => kind === 'QUICK_REPLY' ? [index] : [])
|
||||||
|
if (quick.length) {
|
||||||
|
const together = quick.every((value, index) => value === quick[0] + index)
|
||||||
|
const atEdge = quick[0] === 0 || quick[0] === kinds.length - quick.length
|
||||||
|
if (!together || !atEdge) return 'Keep quick reply buttons together, either all at the start or all at the end.'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
@ -2004,6 +2004,64 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
||||||
.meta-split, .meta-posts li { grid-template-columns: 1fr; }
|
.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 > span, .balance-card > span { font-size: 13px; }
|
||||||
.stat-card p { font-size: 12px; }
|
.stat-card p { font-size: 12px; }
|
||||||
.insight strong, .activity-item strong, .compliance-row { font-size: 13px; }
|
.insight strong, .activity-item strong, .compliance-row { font-size: 13px; }
|
||||||
|
|
|
||||||
|
|
@ -1335,3 +1335,90 @@ export const whatsappApi = {
|
||||||
body: JSON.stringify(payload),
|
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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue