meta whatsapp chat

This commit is contained in:
debu-2 2026-10-08 00:50:36 +05:30
parent 54f75387eb
commit 5ced0d0812
10 changed files with 1015 additions and 25 deletions

View file

@ -63,6 +63,17 @@ async def lifespan(_: FastAPI):
"ADD COLUMN IF NOT EXISTS monthly_outbound_price NUMERIC(10,2) NOT NULL DEFAULT 0"
)
)
conn.execute(
text(
"ALTER TABLE whatsapp_phone_assets "
"ADD COLUMN IF NOT EXISTS two_step_pin VARCHAR(6)"
)
)
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS user_id UUID"))
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS phone_number_id VARCHAR(64)"))
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS contact_name VARCHAR(200)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_user_id ON whatsapp_messages (user_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)"))
yield

View file

@ -536,6 +536,9 @@ class WhatsAppMessage(Base):
message_type: Mapped[str] = mapped_column(String(24), default="text")
status: Mapped[str] = mapped_column(String(24), default="received", index=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), nullable=True, index=True)
phone_number_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
contact_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
@ -605,6 +608,7 @@ class WhatsAppPhoneAsset(Base):
quality: Mapped[str | None] = mapped_column(String(24), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="pending")
code_verification_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
two_step_pin: Mapped[str | None] = mapped_column(String(6), nullable=True)
account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones")

View file

@ -629,7 +629,12 @@ def admin_list_whatsapp_messages(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
query = select(WhatsAppMessage).order_by(WhatsAppMessage.created_at.desc()).limit(min(limit, 500))
query = (
select(WhatsAppMessage)
.where(WhatsAppMessage.user_id.is_(None))
.order_by(WhatsAppMessage.created_at.desc())
.limit(min(limit, 500))
)
if peer:
try:
normalized_peer = normalize_wa_number(peer)

View file

@ -20,8 +20,12 @@ from .whatsapp_onboarding import (
customer_token_for_phone,
developer_config,
disconnect_account,
download_account_media,
list_account_messages,
list_accounts,
mask_identifier,
register_phone_number,
send_account_message,
list_templates,
refresh_account,
signup_public_config,
@ -150,6 +154,26 @@ class SendWhatsAppTextRequest(BaseModel):
phone_number_id: str = Field(min_length=4, max_length=64)
class CustomerSendRequest(BaseModel):
to: str = Field(min_length=8, max_length=20)
message_type: str = "text"
text: str | None = Field(default=None, max_length=4096)
reply_to: str | None = None
template_name: str | None = None
template_language: str = "en"
media_link: str | None = None
media_caption: str | None = None
media_filename: str | None = None
interactive_body: str | None = None
interactive_buttons: list[dict[str, str]] | None = None
reaction_message_id: str | None = None
reaction_emoji: str | None = None
class RegisterPhoneRequest(BaseModel):
pin: str | None = Field(default=None, max_length=6)
class SignupCompleteRequest(BaseModel):
event: str = Field(min_length=1, max_length=80)
code: str | None = Field(default=None, max_length=2048)
@ -202,6 +226,48 @@ async def whatsapp_account_refresh(
return await refresh_account(db, user, account_id)
@router.get("/accounts/{account_id}/messages")
def whatsapp_account_messages(
account_id: str,
limit: int = Query(default=300, ge=1, le=500),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
return list_account_messages(db, user, account_id, limit)
@router.post("/accounts/{account_id}/messages/send")
async def whatsapp_account_send(
account_id: str,
payload: CustomerSendRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await send_account_message(db, user, account_id, payload.model_dump())
@router.get("/accounts/{account_id}/media/{media_id}")
async def whatsapp_account_media(
account_id: str,
media_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> Response:
content, mime_type = await download_account_media(db, user, account_id, media_id)
return Response(content=content, media_type=mime_type)
@router.post("/accounts/{account_id}/phones/{phone_id}/register")
async def whatsapp_register_phone(
account_id: str,
phone_id: str,
payload: RegisterPhoneRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await register_phone_number(db, user, account_id, phone_id, payload.pin)
@router.post("/accounts/{account_id}/disconnect")
async def whatsapp_account_disconnect(
account_id: str,

View file

@ -5,7 +5,8 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .config import settings
from .models import WhatsAppMessage
from .models import WhatsAppMessage, WhatsAppPhoneAsset
from .whatsapp_config import resolved_whatsapp_phone_number_id
PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$")
@ -50,6 +51,7 @@ def serialize_message(message: WhatsAppMessage) -> dict:
"message_type": message.message_type,
"status": message.status,
"media": media,
"contact_name": message.contact_name,
"created_at": message.created_at,
}
@ -93,13 +95,39 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
if payload.get("object") != "whatsapp_business_account":
return 0
business = business_phone()
platform_number = business_phone()
platform_phone_id = resolved_whatsapp_phone_number_id(db)
created = 0
for entry in payload.get("entry", []):
for change in entry.get("changes", []):
if change.get("field") != "messages":
continue
value = change.get("value", {})
metadata = value.get("metadata") if isinstance(value.get("metadata"), dict) else {}
phone_number_id = str(metadata.get("phone_number_id") or "") or None
owner_id = None
line_number = platform_number
if phone_number_id and phone_number_id != platform_phone_id:
asset = db.scalar(
select(WhatsAppPhoneAsset).where(WhatsAppPhoneAsset.phone_number_id == phone_number_id)
)
if asset is None:
continue
owner_id = asset.user_id
raw_line = asset.display_phone or str(metadata.get("display_phone_number") or "")
try:
line_number = normalize_wa_number(raw_line)
except ValueError:
line_number = re.sub(r"\D", "", raw_line) or platform_number
names: dict[str, str] = {}
for contact in value.get("contacts") or []:
if not isinstance(contact, dict):
continue
wa_id = str(contact.get("wa_id") or "")
profile = contact.get("profile") if isinstance(contact.get("profile"), dict) else {}
name = profile.get("name")
if wa_id and isinstance(name, str) and name.strip():
names[wa_id] = name.strip()
for message in value.get("messages", []):
from_number = message.get("from") or ""
body = _extract_text_body(message)
@ -108,11 +136,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
direction="inbound",
wa_message_id=message.get("id"),
from_number=from_number,
to_number=business,
to_number=line_number,
body=body,
message_type=message.get("type") or "text",
status="received",
payload=message,
user_id=owner_id,
phone_number_id=phone_number_id if owner_id else None,
contact_name=names.get(from_number),
)
)
created += 1
@ -130,12 +161,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
WhatsAppMessage(
direction="outbound",
wa_message_id=wa_id,
from_number=business,
from_number=line_number,
to_number=status.get("recipient_id") or "",
body=None,
message_type="status",
status=status.get("status") or "unknown",
payload=status,
user_id=owner_id,
phone_number_id=phone_number_id if owner_id else None,
)
)
created += 1
@ -153,16 +186,21 @@ def record_outbound_message(
wa_message_id: str | None,
status: str = "sent",
payload: dict | None = None,
from_number: str | None = None,
user_id: Any = None,
phone_number_id: str | None = None,
) -> WhatsAppMessage:
message = WhatsAppMessage(
direction="outbound",
wa_message_id=wa_message_id,
from_number=business_phone(),
from_number=from_number or business_phone(),
to_number=to_number,
body=body,
message_type=message_type,
status=status,
payload=payload,
user_id=user_id,
phone_number_id=phone_number_id,
)
db.add(message)
db.commit()

View file

@ -8,6 +8,7 @@ addressed by another tenant.
from __future__ import annotations
import logging
import secrets
import uuid
from datetime import datetime, timezone
from typing import Any
@ -22,11 +23,14 @@ from .models import (
UserAccount,
WhatsAppBusinessAccount,
WhatsAppConnectionAudit,
WhatsAppMessage,
WhatsAppMetaConnection,
WhatsAppPhoneAsset,
WhatsAppWebhookEvent,
)
from .whatsapp_config import resolved_whatsapp_app_secret
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_messages import extract_wa_message_id, normalize_wa_number, record_outbound_message, serialize_message
logger = logging.getLogger(__name__)
@ -199,6 +203,7 @@ def _owned_account(db: Session, user: UserAccount, account_id: Any) -> WhatsAppB
def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
verified = (phone.code_verification_status or "").upper()
return {
"id": str(phone.id),
"phone_number_id": phone.phone_number_id,
@ -207,6 +212,8 @@ def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
"quality": phone.quality,
"status": phone.status,
"code_verification_status": phone.code_verification_status,
"needs_otp": phone.status != "connected" and verified in {"NOT_VERIFIED", "EXPIRED"},
"needs_registration": phone.status != "connected",
}
@ -344,6 +351,75 @@ def _apply_phones(
row.code_verification_status = item.get("code_verification_status")
def _pin_for_phone(phone: WhatsAppPhoneAsset, requested: str | None = None) -> str:
pin = (requested or phone.two_step_pin or "").strip()
if pin.isdigit() and len(pin) == 6:
phone.two_step_pin = pin
return pin
pin = "".join(secrets.choice("0123456789") for _ in range(6))
phone.two_step_pin = pin
return pin
async def _register_phone(token: str, phone: WhatsAppPhoneAsset, pin: str) -> str | None:
try:
await _graph(
"POST",
f"{phone.phone_number_id}/register",
token=token,
json_body={"messaging_product": "whatsapp", "pin": pin},
)
return None
except HTTPException as exc:
detail = str(exc.detail)
lowered = detail.lower()
if "already" in lowered and "register" in lowered:
return None
return detail[:500]
def _set_account_health(db: Session, user: UserAccount, account: WhatsAppBusinessAccount) -> None:
connected = any(phone.status == "connected" for phone in account.phones)
otp_pending = any(
phone.status != "connected"
and (phone.code_verification_status or "").upper() in {"NOT_VERIFIED", "EXPIRED"}
for phone in account.phones
)
if connected and account.management_status == "active":
account.status = "connected"
account.messaging_status = "active"
account.last_error = None
_audit(
db,
user,
"connected",
"WhatsApp Business connection verified",
connection_id=account.connection_id,
waba_id=account.waba_id,
)
return
account.status = "incomplete"
account.messaging_status = "unavailable"
if otp_pending:
account.last_error = (
"This number still needs the SMS or voice code from Meta. "
"It cannot stay logged into the WhatsApp or WhatsApp Business app while Cloud API uses it."
)
else:
account.last_error = account.last_error or (
"This number is attached to the WhatsApp Business Account, but Meta has not registered it for Cloud API yet. "
"Enter the 6-digit two-step PIN if this number already has one, then register it."
)
_audit(
db,
user,
"provision_incomplete",
account.last_error,
connection_id=account.connection_id,
waba_id=account.waba_id,
)
async def _provision_waba(
db: Session,
user: UserAccount,
@ -416,29 +492,31 @@ async def _provision_waba(
account.last_error = str(exc.detail)[:500]
_apply_phones(db, user, account, phones, session_phone_id)
for phone in account.phones:
verified = (phone.code_verification_status or "").upper()
if phone.status == "connected":
continue
if verified in {"NOT_VERIFIED", "EXPIRED"}:
continue
pin = _pin_for_phone(phone)
register_error = await _register_phone(token, phone, pin)
if register_error:
account.last_error = register_error
else:
phone.status = "connected"
try:
phones = await _load_phones(token, waba_id)
_apply_phones(db, user, account, phones, session_phone_id)
except HTTPException:
pass
subscribed = await _subscribe(token, waba_id)
template_count, templates_ok = await _template_count(token, waba_id)
account.template_count = template_count
account.templates_status = "active" if templates_ok else "unavailable"
account.webhooks_status = "active" if subscribed else "unavailable"
has_connected_phone = any(phone.status == "connected" for phone in account.phones)
if has_connected_phone and account.management_status == "active":
account.status = "connected"
account.messaging_status = "active"
_audit(db, user, "connected", "WhatsApp Business connection verified", connection_id=connection.id, waba_id=waba_id)
else:
account.status = "incomplete"
account.messaging_status = "unavailable"
account.last_error = account.last_error or "Phone number verification is not complete"
_audit(
db,
user,
"provision_incomplete",
account.last_error,
connection_id=connection.id,
waba_id=waba_id,
)
_set_account_health(db, user, account)
return account
@ -547,6 +625,49 @@ async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> di
return serialize_account(account)
async def register_phone_number(
db: Session,
user: UserAccount,
account_id: Any,
phone_id: Any,
pin: str | None,
) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
connection = account.connection
token = connection.access_token if connection else None
if connection is None or not token or connection.status == "disconnected":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before registering the number")
phone = next((item for item in account.phones if str(item.id) == str(phone_id)), None)
if phone is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found on this account")
verified = (phone.code_verification_status or "").upper()
if verified in {"NOT_VERIFIED", "EXPIRED"}:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Complete the SMS or voice code for this number in Meta first. Then register it for Cloud API.",
)
submitted = (pin or "").strip()
if submitted and (not submitted.isdigit() or len(submitted) != 6):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Enter the 6-digit two-step PIN for this number")
register_pin = _pin_for_phone(phone, submitted or None)
register_error = await _register_phone(token, phone, register_pin)
if register_error:
account.last_error = register_error
_audit(db, user, "register_failed", register_error, connection_id=connection.id, waba_id=account.waba_id)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=register_error)
try:
phones = await _load_phones(token, account.waba_id)
_apply_phones(db, user, account, phones, phone.phone_number_id)
except HTTPException:
phone.status = "connected"
_audit(db, user, "number_registered", "Registered this number for Cloud API", connection_id=connection.id, waba_id=account.waba_id)
_set_account_health(db, user, account)
db.commit()
db.refresh(account)
return serialize_account(account)
async def disconnect_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
connection = account.connection
@ -692,3 +813,133 @@ def customer_token_for_phone(db: Session, user: UserAccount, phone_number_id: st
if not token:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending")
return token, phone.phone_number_id
def list_account_messages(db: Session, user: UserAccount, account_id: Any, limit: int = 300) -> list[dict[str, Any]]:
account = _owned_account(db, user, account_id)
phone_ids = [phone.phone_number_id for phone in account.phones]
if not phone_ids:
return []
rows = db.scalars(
select(WhatsAppMessage)
.where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.phone_number_id.in_(phone_ids))
.order_by(WhatsAppMessage.created_at.desc())
.limit(min(limit, 500))
).all()
return [serialize_message(row) for row in rows]
def _messaging_phone(account: WhatsAppBusinessAccount) -> tuple[WhatsAppPhoneAsset, str]:
connection = account.connection
token = connection.access_token if connection else None
phone = next((item for item in account.phones if item.status == "connected"), None)
if phone is None or account.messaging_status != "active" or not token:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=account.last_error or "Messaging is unavailable until this number is connected on Cloud API",
)
return phone, token
async def send_account_message(db: Session, user: UserAccount, account_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
phone, token = _messaging_phone(account)
try:
recipient = normalize_wa_number(str(payload.get("to") or ""))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone.phone_number_id, business_account_id=account.waba_id)
message_type = str(payload.get("message_type") or "text")
reply_to = payload.get("reply_to") or None
try:
if message_type == "text":
body = str(payload.get("text") or "").strip()
if not body:
raise HTTPException(status_code=422, detail="text is required")
result = await client.send_text(recipient, body, reply_to=reply_to)
elif message_type == "template":
name = str(payload.get("template_name") or "").strip()
if not name:
raise HTTPException(status_code=422, detail="template_name is required")
result = await client.send_template(recipient, name, language_code=str(payload.get("template_language") or "en"))
body = f"Template: {name}"
message_type = "template"
elif message_type in {"image", "document", "audio", "video"}:
link = str(payload.get("media_link") or "").strip()
if not link:
raise HTTPException(status_code=422, detail="media_link is required")
result = await client.send_media(
recipient,
message_type,
link=link,
caption=payload.get("media_caption"),
filename=payload.get("media_filename"),
reply_to=reply_to,
)
body = payload.get("media_caption") or f"[{message_type}]"
elif message_type == "interactive_buttons":
buttons = payload.get("interactive_buttons") or []
text = str(payload.get("interactive_body") or "").strip()
if not text or not buttons:
raise HTTPException(status_code=422, detail="interactive_body and interactive_buttons are required")
result = await client.send_interactive_buttons(recipient, text, buttons, reply_to=reply_to)
body = text
message_type = "interactive"
elif message_type == "reaction":
target = str(payload.get("reaction_message_id") or "")
emoji = str(payload.get("reaction_emoji") or "")
if not target or not emoji:
raise HTTPException(status_code=422, detail="reaction_message_id and reaction_emoji are required")
result = await client.send_reaction(recipient, target, emoji)
body = emoji
else:
raise HTTPException(status_code=422, detail="Unsupported message type")
except WhatsAppGraphError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
try:
from_number = normalize_wa_number(phone.display_phone or "")
except ValueError:
from_number = phone.display_phone or phone.phone_number_id
message = record_outbound_message(
db,
to_number=recipient,
body=body,
message_type=message_type,
wa_message_id=extract_wa_message_id(result),
status="sent",
payload=result if isinstance(result, dict) else None,
from_number=from_number,
user_id=user.id,
phone_number_id=phone.phone_number_id,
)
return serialize_message(message)
async def download_account_media(db: Session, user: UserAccount, account_id: Any, media_id: str) -> tuple[bytes, str]:
account = _owned_account(db, user, account_id)
token = account.connection.access_token if account.connection else None
if not token:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before opening media")
phone_ids = [phone.phone_number_id for phone in account.phones]
if not phone_ids:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found")
rows = db.scalars(
select(WhatsAppMessage).where(
WhatsAppMessage.user_id == user.id,
WhatsAppMessage.phone_number_id.in_(phone_ids),
)
).all()
allowed = False
for row in rows:
payload = row.payload if isinstance(row.payload, dict) else {}
media = payload.get(row.message_type)
if isinstance(media, dict) and str(media.get("id") or "") == media_id:
allowed = True
break
if not allowed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media not found")
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_ids[0] if phone_ids else None)
try:
return await client.download_media(media_id)
except WhatsAppGraphError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc

View file

@ -0,0 +1,510 @@
import {
Check,
CheckCheck,
ChevronLeft,
Image as ImageIcon,
MessageCircle,
MessageSquareText,
MoreHorizontal,
Paperclip,
Plus,
RefreshCw,
Search,
Send,
Smile,
Star,
UserRound,
X,
} from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { API_BASE_URL } from '../../lib/apiBase'
import {
whatsappApi,
type CustomerWhatsAppMessage,
type CustomerWhatsAppSend,
type WhatsAppAccount,
type WhatsAppTemplateRow,
} from '../../lib/api'
type SendMode = 'text' | 'template' | 'media' | 'buttons'
type ListFilter = 'all' | 'unread' | 'important'
type ProfileTab = 'details' | 'events'
const REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
function formatWaNumber(value: string) {
const digits = value.replace(/\D/g, '')
if (digits.length === 12 && digits.startsWith('91')) {
const local = digits.slice(2)
return `+91 ${local.slice(0, 5)} ${local.slice(5)}`
}
if (digits.length === 10) return `+91 ${digits.slice(0, 5)} ${digits.slice(5)}`
return value.startsWith('+') ? value : `+${digits || value}`
}
function formatClock(value: string) {
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function formatChatTime(value: string) {
const date = new Date(value)
const now = new Date()
if (date.toDateString() === now.toDateString()) return formatClock(value)
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
function formatDayLabel(value: string) {
const date = new Date(value)
const now = new Date()
if (date.toDateString() === now.toDateString()) return 'Today'
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
}
function formatWhen(value: string) {
return new Date(value).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function initials(name: string) {
const parts = name.split(/\s+/).filter(Boolean)
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
return name.replace(/\D/g, '').slice(-2) || 'WA'
}
export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAccount; onBack: () => void }) {
const [messages, setMessages] = useState<CustomerWhatsAppMessage[]>([])
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
const [loading, setLoading] = useState(true)
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
const [startingNew, setStartingNew] = useState(false)
const [newPeer, setNewPeer] = useState('')
const [sendMode, setSendMode] = useState<SendMode>('text')
const [error, setError] = useState('')
const [sending, setSending] = useState(false)
const [textBody, setTextBody] = useState('')
const [replyTo, setReplyTo] = useState('')
const [replyPreview, setReplyPreview] = useState('')
const [templateName, setTemplateName] = useState('')
const [templateLanguage, setTemplateLanguage] = useState('en')
const [mediaType, setMediaType] = useState<'image' | 'document' | 'audio' | 'video'>('image')
const [mediaLink, setMediaLink] = useState('')
const [mediaCaption, setMediaCaption] = useState('')
const [buttonBody, setButtonBody] = useState('')
const [buttonOne, setButtonOne] = useState('')
const [buttonTwo, setButtonTwo] = useState('')
const [buttonThree, setButtonThree] = useState('')
const [threadQuery, setThreadQuery] = useState('')
const [listFilter, setListFilter] = useState<ListFilter>('all')
const [readPeers, setReadPeers] = useState<Set<string>>(new Set())
const [starred, setStarred] = useState<Set<string>>(new Set())
const [profileTab, setProfileTab] = useState<ProfileTab>('details')
const [moreOpen, setMoreOpen] = useState(false)
const [detailsId, setDetailsId] = useState<string | null>(null)
const [reactFor, setReactFor] = useState<string | null>(null)
const [emojiOpen, setEmojiOpen] = useState(false)
const chatEndRef = useRef<HTMLDivElement | null>(null)
const line = account.primary_phone?.display_phone || account.name
const canSend = account.messaging_status === 'active' && account.usable
async function loadMessages() {
setMessages(await whatsappApi.messages(account.id))
}
useEffect(() => {
Promise.all([
whatsappApi.messages(account.id),
whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })),
])
.then(([items, templatePayload]) => {
setMessages(items)
setTemplates(templatePayload.templates.filter((item) => item.status === 'APPROVED' || !item.status))
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load conversations'))
.finally(() => setLoading(false))
const timer = window.setInterval(() => {
loadMessages().catch(() => undefined)
}, 8000)
return () => window.clearInterval(timer)
}, [account.id])
const threads = useMemo(() => {
const grouped = new Map<string, CustomerWhatsAppMessage[]>()
for (const message of messages) {
if (message.message_type === 'status') continue
const peer = message.direction === 'inbound' ? message.from : message.to
if (!peer) continue
const list = grouped.get(peer) ?? []
list.push(message)
grouped.set(peer, list)
}
return [...grouped.entries()]
.map(([peer, thread]) => {
const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at))
const latest = sorted[sorted.length - 1]
const name = sorted.find((item) => item.contact_name)?.contact_name
const unread = readPeers.has(peer) ? 0 : sorted.filter((item) => item.direction === 'inbound' && item.status !== 'read').length
return {
peer,
title: name || formatWaNumber(peer),
phoneLabel: formatWaNumber(peer),
initials: initials(name || peer),
messages: sorted,
latest,
unread,
important: starred.has(peer),
}
})
.sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at))
}, [messages, readPeers, starred])
const visibleThreads = threads.filter((thread) => {
const query = threadQuery.trim().toLowerCase()
if (query && !`${thread.title} ${thread.phoneLabel} ${thread.latest.body || ''}`.toLowerCase().includes(query)) return false
if (listFilter === 'unread') return thread.unread > 0
if (listFilter === 'important') return thread.important
return true
})
const active = threads.find((thread) => thread.peer === selectedPeer) || null
const activeThread = active?.messages ?? []
const unreadTotal = threads.reduce((sum, thread) => sum + thread.unread, 0)
const importantTotal = threads.filter((thread) => thread.important).length
useEffect(() => {
chatEndRef.current?.scrollIntoView({ block: 'end' })
}, [activeThread.length, selectedPeer])
function openThread(peer: string) {
setSelectedPeer(peer)
setStartingNew(false)
setReadPeers((current) => new Set(current).add(peer))
setMoreOpen(false)
}
async function sendReaction(message: CustomerWhatsAppMessage, emoji: string) {
if (!message.wa_message_id) return
setSending(true)
setError('')
try {
await whatsappApi.sendAccountMessage(account.id, {
to: message.from,
message_type: 'reaction',
reaction_message_id: message.wa_message_id,
reaction_emoji: emoji,
})
setReactFor(null)
await loadMessages()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to send reaction')
} finally {
setSending(false)
}
}
async function sendMessage(event: FormEvent) {
event.preventDefault()
const peer = active?.peer || newPeer
if (!peer) {
setError('Enter a WhatsApp number')
return
}
setSending(true)
setError('')
const payload: CustomerWhatsAppSend = { to: peer, message_type: sendMode === 'buttons' ? 'interactive_buttons' : sendMode }
if (replyTo) payload.reply_to = replyTo
if (sendMode === 'text') payload.text = textBody.trim()
if (sendMode === 'template') {
payload.template_name = templateName
payload.template_language = templateLanguage
}
if (sendMode === 'media') {
payload.message_type = mediaType
payload.media_link = mediaLink.trim()
payload.media_caption = mediaCaption.trim()
}
if (sendMode === 'buttons') {
payload.interactive_body = buttonBody.trim()
payload.interactive_buttons = [buttonOne, buttonTwo, buttonThree]
.map((title) => title.trim())
.filter(Boolean)
.map((title, index) => ({ id: `btn_${index + 1}`, title }))
}
try {
const sent = await whatsappApi.sendAccountMessage(account.id, payload)
setSelectedPeer(sent.to)
setStartingNew(false)
setNewPeer('')
setTextBody('')
setButtonBody('')
setMediaCaption('')
setReplyTo('')
setReplyPreview('')
setSendMode('text')
await loadMessages()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to send message')
} finally {
setSending(false)
}
}
function mediaUrl(mediaId: string) {
return `${API_BASE_URL}/api/v1/whatsapp/accounts/${account.id}/media/${mediaId}`
}
if (loading) return <p className="explorer-loading">Loading WhatsApp conversations…</p>
return (
<div className="wa-page wa-customer">
<div className="wa-page-title">
<div>
<button type="button" className="wa-biz-back" onClick={onBack}><ChevronLeft size={16} /> Accounts</button>
<h1><MessageCircle size={20} /> {account.business_name || account.name}</h1>
<p>Conversations on {formatWaNumber(line)}.</p>
</div>
<div className="wa-topbar-actions">
<span className={canSend ? 'wa-connected' : 'wa-connected pending'}><i />{canSend ? 'Messaging' : 'Messaging unavailable'}</span>
<button type="button" className="wa-ghost" onClick={() => void loadMessages()}><RefreshCw size={14} /> Refresh</button>
</div>
</div>
{!canSend && <p className="wa-biz-notice">{account.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}</p>}
<div className="wa-inbox">
<aside className="wa-list">
<div className="wa-list-head">
<h2>Conversations</h2>
<button type="button" className="wa-icon-btn" aria-label="New conversation" onClick={() => { setStartingNew(true); setSelectedPeer(null) }}>
<Plus size={14} />
</button>
</div>
<label className="wa-search">
<Search size={14} />
<input value={threadQuery} onChange={(event) => setThreadQuery(event.target.value)} placeholder="Search by name, phone or message…" />
</label>
<div className="wa-filters">
<button type="button" className={listFilter === 'all' ? 'active' : ''} onClick={() => setListFilter('all')}>All <em>{threads.length}</em></button>
<button type="button" className={listFilter === 'unread' ? 'active' : ''} onClick={() => setListFilter('unread')}>Unread <em>{unreadTotal}</em></button>
<button type="button" className={listFilter === 'important' ? 'active' : ''} onClick={() => setListFilter('important')}>Important <em>{importantTotal}</em></button>
</div>
<div className="wa-thread-list">
{visibleThreads.length === 0 && <p className="wa-empty">No conversations yet.</p>}
{visibleThreads.map((thread) => (
<button key={thread.peer} type="button" className={`wa-thread ${selectedPeer === thread.peer ? 'active' : ''}`} onClick={() => openThread(thread.peer)}>
<span className="wa-avatar wa-avatar-green">{thread.initials}</span>
<span className="wa-thread-copy">
<strong>{thread.title}</strong>
<span>{thread.latest.body || 'Attachment'}</span>
</span>
<span className="wa-thread-meta">
<small>{formatChatTime(thread.latest.created_at)}</small>
{thread.unread > 0 && <em>{thread.unread}</em>}
</span>
</button>
))}
</div>
</aside>
<section className="wa-chat">
<div className="wa-chat-head">
{active ? (
<>
<span className="wa-avatar wa-avatar-green">{active.initials}</span>
<div className="wa-chat-id">
<strong>{active.title}</strong>
<small>WhatsApp</small>
</div>
</>
) : (
<div className="wa-chat-id">
<strong>{startingNew ? 'New conversation' : 'Select a conversation'}</strong>
<small>{startingNew ? 'Enter a WhatsApp number' : formatWaNumber(line)}</small>
</div>
)}
<div className="wa-chat-tools">
<button
type="button"
className={`wa-tool ${active && starred.has(active.peer) ? 'on' : ''}`}
onClick={() => {
if (!active) return
setStarred((current) => {
const next = new Set(current)
if (next.has(active.peer)) next.delete(active.peer)
else next.add(active.peer)
return next
})
}}
>
<Star size={14} /> Star
</button>
<div className="wa-pop">
<button type="button" className="wa-tool" onClick={() => setMoreOpen((value) => !value)}><MoreHorizontal size={14} /> More</button>
{moreOpen && active && (
<div className="wa-menu">
<button type="button" onClick={() => { navigator.clipboard.writeText(active.phoneLabel); setMoreOpen(false) }}>Copy number</button>
</div>
)}
</div>
</div>
</div>
<div className="wa-canvas">
{!active && !startingNew && <p className="wa-empty">Messages for this WhatsApp number appear here.</p>}
{activeThread.map((item, index) => {
const previous = activeThread[index - 1]
const showDay = !previous || new Date(previous.created_at).toDateString() !== new Date(item.created_at).toDateString()
const outbound = item.direction === 'outbound'
return (
<div key={item.id}>
{showDay && <div className="wa-day">{formatDayLabel(item.created_at)}</div>}
<div className={`wa-bubble-row ${outbound ? 'out' : 'in'}`}>
<div className={`wa-bubble ${outbound ? 'out' : 'in'}`}>
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && <img src={mediaUrl(item.media.id)} alt="" className="wa-media" />}
{item.media?.id && item.message_type === 'video' && <video src={mediaUrl(item.media.id)} controls className="wa-media" />}
{item.media?.id && item.message_type === 'audio' && <audio src={mediaUrl(item.media.id)} controls />}
{item.media?.id && item.message_type === 'document' && <a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Open document</a>}
{item.message_type === 'template' && <em className="wa-kind">Template</em>}
{item.body && <p>{item.body}</p>}
<footer>
<time>{formatClock(item.created_at)}</time>
{outbound && (item.status === 'delivered' || item.status === 'read' ? <CheckCheck size={13} className={item.status === 'read' ? 'read' : ''} /> : <Check size={13} />)}
</footer>
{detailsId === item.id && (
<div className="wa-inspector">
<strong>Message details</strong>
<span>Status · {item.status}</span>
<span>Type · {item.message_type.replace(/_/g, ' ')}</span>
</div>
)}
</div>
<div className="wa-hover">
{!outbound && <button type="button" onClick={() => { setReplyTo(item.wa_message_id || ''); setReplyPreview(item.body || 'Attachment'); setSendMode('text') }}>Reply</button>}
{!outbound && item.wa_message_id && <button type="button" onClick={() => setReactFor(reactFor === item.id ? null : item.id)}>React</button>}
<button type="button" onClick={() => setDetailsId(detailsId === item.id ? null : item.id)} aria-label="Message details"><MoreHorizontal size={14} /></button>
{reactFor === item.id && (
<span className="wa-reacts">
{REACTIONS.map((emoji) => <button key={emoji} type="button" onClick={() => void sendReaction(item, emoji)}>{emoji}</button>)}
</span>
)}
</div>
</div>
</div>
)
})}
<div ref={chatEndRef} />
</div>
<form className="wa-composer" onSubmit={(event) => void sendMessage(event)}>
<div className="wa-modes">
{(['text', 'template', 'media', 'buttons'] as const).map((id) => (
<button key={id} type="button" className={sendMode === id ? 'active' : ''} onClick={() => setSendMode(id)}>{id[0].toUpperCase() + id.slice(1)}</button>
))}
</div>
{replyPreview && (
<div className="wa-quote">
<div><strong>Replying</strong><p>{replyPreview}</p></div>
<button type="button" aria-label="Cancel reply" onClick={() => { setReplyPreview(''); setReplyTo('') }}><X size={14} /></button>
</div>
)}
{(startingNew || !active) && <input className="wa-new-peer" value={newPeer} onChange={(event) => setNewPeer(event.target.value)} placeholder="Recipient number, e.g. 919453226814" />}
{sendMode === 'template' && (
<div className="wa-extra">
<select value={templateName} onChange={(event) => setTemplateName(event.target.value)} required>
<option value="">Choose a template</option>
{templates.map((template) => <option key={`${template.name}-${template.language}`} value={template.name || ''}>{template.name} ({template.language})</option>)}
</select>
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} aria-label="Language code" />
</div>
)}
{sendMode === 'media' && (
<div className="wa-extra">
<select value={mediaType} onChange={(event) => setMediaType(event.target.value as typeof mediaType)}>
<option value="image">Image</option>
<option value="document">Document</option>
<option value="audio">Audio</option>
<option value="video">Video</option>
</select>
<input value={mediaLink} onChange={(event) => setMediaLink(event.target.value)} placeholder="Public file link" required />
</div>
)}
{sendMode === 'buttons' && (
<div className="wa-extra">
<input value={buttonOne} onChange={(event) => setButtonOne(event.target.value)} placeholder="Button 1" required />
<input value={buttonTwo} onChange={(event) => setButtonTwo(event.target.value)} placeholder="Button 2" />
<input value={buttonThree} onChange={(event) => setButtonThree(event.target.value)} placeholder="Button 3" />
</div>
)}
<div className="wa-compose-box">
<textarea
rows={2}
value={sendMode === 'media' ? mediaCaption : sendMode === 'buttons' ? buttonBody : textBody}
onChange={(event) => {
if (sendMode === 'media') setMediaCaption(event.target.value)
else if (sendMode === 'buttons') setButtonBody(event.target.value)
else setTextBody(event.target.value)
}}
placeholder={canSend ? 'Type a message…' : 'Sending is unavailable until this number is connected'}
required={sendMode !== 'template'}
disabled={!canSend}
/>
</div>
<div className="wa-compose-bar">
<div className="wa-compose-icons">
<button type="button" aria-label="Emoji" onClick={() => setEmojiOpen((value) => !value)}><Smile size={16} /></button>
<button type="button" aria-label="Attach file" onClick={() => { setSendMode('media'); setMediaType('document') }}><Paperclip size={16} /></button>
<button type="button" aria-label="Image" onClick={() => { setSendMode('media'); setMediaType('image') }}><ImageIcon size={16} /></button>
{emojiOpen && (
<span className="wa-reacts static">
{REACTIONS.map((emoji) => <button key={emoji} type="button" onClick={() => { setTextBody((value) => `${value}${emoji}`); setEmojiOpen(false); setSendMode('text') }}>{emoji}</button>)}
</span>
)}
</div>
{error && <p className="wa-error">{error}</p>}
<button className="wa-send" type="submit" disabled={sending || !canSend}><Send size={14} /> {sending ? 'Sending' : 'Send'}</button>
</div>
</form>
</section>
<aside className="wa-profile">
<div className="wa-profile-hero">
<span className="wa-avatar large wa-avatar-green">{active ? active.initials : <UserRound size={22} />}</span>
<div>
<strong className="wa-name">{active?.title || 'No contact'}</strong>
<small>{active ? 'WhatsApp' : 'Select a conversation'}</small>
</div>
</div>
<div className="wa-tabs">
{(['details', 'events'] as const).map((id) => (
<button key={id} type="button" className={profileTab === id ? 'active' : ''} onClick={() => setProfileTab(id)}>{id[0].toUpperCase() + id.slice(1)}</button>
))}
</div>
{profileTab === 'details' && active && (
<dl className="wa-fields">
<div><dt>Phone</dt><dd>{active.phoneLabel}</dd></div>
<div><dt>Status</dt><dd>{active.latest.status}</dd></div>
<div><dt>Messages</dt><dd>{active.messages.length}</dd></div>
<div><dt>First seen</dt><dd>{formatWhen(active.messages[0].created_at)}</dd></div>
<div><dt>Last message</dt><dd>{formatWhen(active.latest.created_at)}</dd></div>
<div><dt>Source</dt><dd>WhatsApp</dd></div>
</dl>
)}
{profileTab === 'details' && !active && <p className="wa-empty">Open a conversation to see the customer.</p>}
{profileTab === 'events' && (
<ul className="wa-events">
{activeThread.length === 0 && <li><strong>No messages yet</strong></li>}
{[...activeThread].reverse().map((item) => (
<li key={item.id}><strong>{item.direction === 'inbound' ? 'Customer message' : 'Reply sent'}</strong><span>{formatWhen(item.created_at)}</span></li>
))}
</ul>
)}
<div className="wa-quick">
<h3>Quick actions</h3>
<button type="button" onClick={() => setSendMode('template')}><MessageSquareText size={14} /> Send template</button>
<button type="button" onClick={() => setSendMode('media')}><ImageIcon size={14} /> Send media</button>
</div>
</aside>
</div>
</div>
)
}

View file

@ -1,4 +1,5 @@
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
import { useEffect, useRef, useState } from 'react'
import {
whatsappApi,
@ -10,7 +11,7 @@ import {
type WhatsAppTemplateRow,
} from '../../lib/api'
type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail'
type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail' | 'inbox'
type DetailTab = 'overview' | 'numbers' | 'templates' | 'configuration' | 'logs'
type SignupSession = {
@ -137,6 +138,7 @@ export function WhatsAppSetupPage() {
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
const [copied, setCopied] = useState(false)
const [pins, setPins] = useState<Record<string, string>>({})
const sessionRef = useRef<SignupSession | null>(null)
const codeRef = useRef<string | null>(null)
const finishingRef = useRef(false)
@ -330,6 +332,20 @@ export function WhatsAppSetupPage() {
}
}
async function registerPhone(phoneId: string) {
if (!selected) return
setBusy(true)
setError('')
try {
replaceAccount(await whatsappApi.registerPhone(selected.id, phoneId, pins[phoneId]))
setNotice('Urmi asked Meta to register this number for Cloud API.')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to register this number')
} finally {
setBusy(false)
}
}
async function confirmDisconnect() {
if (!disconnecting) return
setBusy(true)
@ -355,6 +371,16 @@ export function WhatsAppSetupPage() {
const resultAccount = result?.accounts[0]
function openInbox(accountId: string) {
setSelectedId(accountId)
setMenuId(null)
setPhase('inbox')
}
if (phase === 'inbox' && selected) {
return <CustomerWhatsAppInbox account={selected} onBack={() => setPhase('list')} />
}
return (
<div className="wa-biz">
{phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>}
@ -470,6 +496,11 @@ export function WhatsAppSetupPage() {
<StatusDot status={account.status} />
</div>
<div className="wa-biz-menu">
{account.webhooks_status === 'active' && (
<button type="button" className="wa-chat-launch" onClick={() => openInbox(account.id)}>
<MessageCircle size={15} /> Chat
</button>
)}
<button className="secondary-button" onClick={() => { setSelectedId(account.id); setTab('overview'); setPhase('detail') }}>Open</button>
<button className="icon-button" aria-label="Account actions" onClick={() => setMenuId(menuId === account.id ? null : account.id)}>
<MoreHorizontal size={16} />
@ -520,6 +551,11 @@ export function WhatsAppSetupPage() {
<StatusDot status={selected.status} />
</div>
<div className="wa-biz-actions">
{selected.webhooks_status === 'active' && (
<button type="button" className="wa-chat-launch" onClick={() => openInbox(selected.id)}>
<MessageCircle size={15} /> Chat
</button>
)}
<button className="secondary-button" onClick={() => void refreshSelected()} disabled={busy}>
<RefreshCw size={14} /> Refresh
</button>
@ -565,6 +601,7 @@ export function WhatsAppSetupPage() {
{tab === 'numbers' && (
<section className="wa-biz-card">
<h2>Phone numbers</h2>
<p className="wa-biz-muted">Messaging stays unavailable until Meta reports this number as connected for Cloud API. Pending means the number is on the account but not registered yet. Quality stays unknown until then.</p>
{selected.phones.length === 0 && <p className="wa-biz-muted">Meta has not returned a phone number for this account yet.</p>}
<ul className="wa-biz-numbers">
{selected.phones.map((phone) => (
@ -572,9 +609,24 @@ export function WhatsAppSetupPage() {
<div>
<strong>{phone.display_phone || 'Number pending'}</strong>
<small>{phone.verified_name || 'Display name pending'}</small>
{phone.needs_otp && <small>Complete the SMS or voice code in Meta, then refresh. Log this number out of the WhatsApp app first.</small>}
</div>
<StatusDot status={phone.status} />
<span>Quality: {phone.quality || '—'}</span>
{phone.needs_registration && !phone.needs_otp && (
<div className="wa-biz-register">
<input
inputMode="numeric"
maxLength={6}
placeholder="6-digit PIN"
value={pins[phone.id] || ''}
onChange={(event) => setPins((current) => ({ ...current, [phone.id]: event.target.value.replace(/\D/g, '').slice(0, 6) }))}
/>
<button className="primary-button" type="button" disabled={busy} onClick={() => void registerPhone(phone.id)}>
Register for Cloud API
</button>
</div>
)}
</li>
))}
</ul>

View file

@ -1770,6 +1770,13 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-compose-icons > button { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 8px; background: transparent; color: #607089; }
.wa-compose-icons > button:hover { background: #f4f7fb; color: #1475ff; }
.wa-send { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 10px; padding: 9px 14px; background: #1475ff; color: white; font-size: 13px; font-weight: 700; }
.wa-chat-launch { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 999px; padding: 8px 14px; background: #25D366; color: #fff; font-size: 13px; font-weight: 700; box-shadow: 0 6px 14px rgba(37, 211, 102, .28); }
.wa-chat-launch:hover { background: #1ebe5d; }
.wa-customer .wa-send { background: #25D366; }
.wa-customer .wa-send:disabled { background: #b7e7c7; }
.wa-customer .wa-bubble.out { background: #d9fdd3; }
.wa-customer .wa-filters button.active, .wa-customer .wa-modes button.active { color: #128C7E; }
.wa-avatar-green { background: #128C7E; color: white; }
.wa-send:disabled { opacity: .55; }
.wa-error { margin: 0; color: #b42318; font-size: 12px; }
.wa-profile { display: flex; flex-direction: column; border-left: 1px solid #eef2f6; background: #fff; overflow: auto; }
@ -1879,6 +1886,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-biz-capabilities li { justify-content: space-between; }
.wa-biz-numbers { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.wa-biz-numbers li { display: grid; grid-template-columns: 1fr auto auto; gap: 16px; align-items: center; padding: 12px 0; border-top: 1px solid #edf0f4; }
.wa-biz-register { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.wa-biz-register input { width: 140px; padding: 10px 12px; border: 1px solid #dfe5ed; border-radius: 8px; font-size: 13px; letter-spacing: .2em; }
.wa-biz-table { margin-top: 12px; }
.wa-biz-table-head, .wa-biz-table-row { display: grid; grid-template-columns: 1.4fr .8fr .7fr .8fr .6fr; gap: 8px; padding: 10px 0; }
.wa-biz-table-head { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }

View file

@ -1138,6 +1138,8 @@ export type WhatsAppPhoneAsset = {
quality: string | null
status: string
code_verification_status: string | null
needs_otp?: boolean
needs_registration?: boolean
}
export type WhatsAppAccount = {
@ -1188,6 +1190,36 @@ export type WhatsAppAccountLogs = {
events: { id: string; field: string; event_type: string; from_wa_id: string | null; status: string | null; created_at: string }[]
}
export type CustomerWhatsAppMessage = {
id: string
direction: 'inbound' | 'outbound'
wa_message_id: string | null
from: string
to: string
body: string | null
message_type: string
status: string
contact_name?: string | null
media?: { id?: string; mime_type?: string; caption?: string; filename?: string } | null
created_at: string
}
export type CustomerWhatsAppSend = {
to: string
message_type?: string
text?: string
reply_to?: string
template_name?: string
template_language?: string
media_link?: string
media_caption?: string
media_filename?: string
interactive_body?: string
interactive_buttons?: { id: string; title: string }[]
reaction_message_id?: string
reaction_emoji?: string
}
export type WhatsAppWebhookEvent = {
id: string
waba_id: string | null
@ -1222,6 +1254,18 @@ export const whatsappApi = {
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/refresh`, { method: 'POST' }),
disconnectAccount: (accountId: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/disconnect`, { method: 'POST' }),
registerPhone: (accountId: string, phoneId: string, pin?: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/phones/${phoneId}/register`, {
method: 'POST',
body: JSON.stringify({ pin: pin || null }),
}),
messages: (accountId: string) =>
jsonRequest<CustomerWhatsAppMessage[]>(`/api/v1/whatsapp/accounts/${accountId}/messages`),
sendAccountMessage: (accountId: string, payload: CustomerWhatsAppSend) =>
jsonRequest<CustomerWhatsAppMessage>(`/api/v1/whatsapp/accounts/${accountId}/messages/send`, {
method: 'POST',
body: JSON.stringify(payload),
}),
templates: (accountId: string) =>
jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`),
logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`),