meta whatsapp chat
This commit is contained in:
parent
54f75387eb
commit
5ced0d0812
10 changed files with 1015 additions and 25 deletions
|
|
@ -63,6 +63,17 @@ async def lifespan(_: FastAPI):
|
||||||
"ADD COLUMN IF NOT EXISTS monthly_outbound_price NUMERIC(10,2) NOT NULL DEFAULT 0"
|
"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
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -536,6 +536,9 @@ class WhatsAppMessage(Base):
|
||||||
message_type: Mapped[str] = mapped_column(String(24), default="text")
|
message_type: Mapped[str] = mapped_column(String(24), default="text")
|
||||||
status: Mapped[str] = mapped_column(String(24), default="received", index=True)
|
status: Mapped[str] = mapped_column(String(24), default="received", index=True)
|
||||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=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)
|
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)
|
quality: Mapped[str | None] = mapped_column(String(24), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
code_verification_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
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")
|
account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -629,7 +629,12 @@ def admin_list_whatsapp_messages(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: str = Depends(current_admin),
|
_: str = Depends(current_admin),
|
||||||
) -> list[dict]:
|
) -> 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:
|
if peer:
|
||||||
try:
|
try:
|
||||||
normalized_peer = normalize_wa_number(peer)
|
normalized_peer = normalize_wa_number(peer)
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,12 @@ from .whatsapp_onboarding import (
|
||||||
customer_token_for_phone,
|
customer_token_for_phone,
|
||||||
developer_config,
|
developer_config,
|
||||||
disconnect_account,
|
disconnect_account,
|
||||||
|
download_account_media,
|
||||||
|
list_account_messages,
|
||||||
list_accounts,
|
list_accounts,
|
||||||
mask_identifier,
|
mask_identifier,
|
||||||
|
register_phone_number,
|
||||||
|
send_account_message,
|
||||||
list_templates,
|
list_templates,
|
||||||
refresh_account,
|
refresh_account,
|
||||||
signup_public_config,
|
signup_public_config,
|
||||||
|
|
@ -150,6 +154,26 @@ class SendWhatsAppTextRequest(BaseModel):
|
||||||
phone_number_id: str = Field(min_length=4, max_length=64)
|
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):
|
class SignupCompleteRequest(BaseModel):
|
||||||
event: str = Field(min_length=1, max_length=80)
|
event: str = Field(min_length=1, max_length=80)
|
||||||
code: str | None = Field(default=None, max_length=2048)
|
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)
|
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")
|
@router.post("/accounts/{account_id}/disconnect")
|
||||||
async def whatsapp_account_disconnect(
|
async def whatsapp_account_disconnect(
|
||||||
account_id: str,
|
account_id: str,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import settings
|
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}$")
|
PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$")
|
||||||
|
|
||||||
|
|
@ -50,6 +51,7 @@ def serialize_message(message: WhatsAppMessage) -> dict:
|
||||||
"message_type": message.message_type,
|
"message_type": message.message_type,
|
||||||
"status": message.status,
|
"status": message.status,
|
||||||
"media": media,
|
"media": media,
|
||||||
|
"contact_name": message.contact_name,
|
||||||
"created_at": message.created_at,
|
"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":
|
if payload.get("object") != "whatsapp_business_account":
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
business = business_phone()
|
platform_number = business_phone()
|
||||||
|
platform_phone_id = resolved_whatsapp_phone_number_id(db)
|
||||||
created = 0
|
created = 0
|
||||||
for entry in payload.get("entry", []):
|
for entry in payload.get("entry", []):
|
||||||
for change in entry.get("changes", []):
|
for change in entry.get("changes", []):
|
||||||
if change.get("field") != "messages":
|
if change.get("field") != "messages":
|
||||||
continue
|
continue
|
||||||
value = change.get("value", {})
|
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", []):
|
for message in value.get("messages", []):
|
||||||
from_number = message.get("from") or ""
|
from_number = message.get("from") or ""
|
||||||
body = _extract_text_body(message)
|
body = _extract_text_body(message)
|
||||||
|
|
@ -108,11 +136,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
|
||||||
direction="inbound",
|
direction="inbound",
|
||||||
wa_message_id=message.get("id"),
|
wa_message_id=message.get("id"),
|
||||||
from_number=from_number,
|
from_number=from_number,
|
||||||
to_number=business,
|
to_number=line_number,
|
||||||
body=body,
|
body=body,
|
||||||
message_type=message.get("type") or "text",
|
message_type=message.get("type") or "text",
|
||||||
status="received",
|
status="received",
|
||||||
payload=message,
|
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
|
created += 1
|
||||||
|
|
@ -130,12 +161,14 @@ def record_inbound_messages(db: Session, payload: dict[str, Any]) -> int:
|
||||||
WhatsAppMessage(
|
WhatsAppMessage(
|
||||||
direction="outbound",
|
direction="outbound",
|
||||||
wa_message_id=wa_id,
|
wa_message_id=wa_id,
|
||||||
from_number=business,
|
from_number=line_number,
|
||||||
to_number=status.get("recipient_id") or "",
|
to_number=status.get("recipient_id") or "",
|
||||||
body=None,
|
body=None,
|
||||||
message_type="status",
|
message_type="status",
|
||||||
status=status.get("status") or "unknown",
|
status=status.get("status") or "unknown",
|
||||||
payload=status,
|
payload=status,
|
||||||
|
user_id=owner_id,
|
||||||
|
phone_number_id=phone_number_id if owner_id else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
@ -153,16 +186,21 @@ def record_outbound_message(
|
||||||
wa_message_id: str | None,
|
wa_message_id: str | None,
|
||||||
status: str = "sent",
|
status: str = "sent",
|
||||||
payload: dict | None = None,
|
payload: dict | None = None,
|
||||||
|
from_number: str | None = None,
|
||||||
|
user_id: Any = None,
|
||||||
|
phone_number_id: str | None = None,
|
||||||
) -> WhatsAppMessage:
|
) -> WhatsAppMessage:
|
||||||
message = WhatsAppMessage(
|
message = WhatsAppMessage(
|
||||||
direction="outbound",
|
direction="outbound",
|
||||||
wa_message_id=wa_message_id,
|
wa_message_id=wa_message_id,
|
||||||
from_number=business_phone(),
|
from_number=from_number or business_phone(),
|
||||||
to_number=to_number,
|
to_number=to_number,
|
||||||
body=body,
|
body=body,
|
||||||
message_type=message_type,
|
message_type=message_type,
|
||||||
status=status,
|
status=status,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
user_id=user_id,
|
||||||
|
phone_number_id=phone_number_id,
|
||||||
)
|
)
|
||||||
db.add(message)
|
db.add(message)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ addressed by another tenant.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -22,11 +23,14 @@ from .models import (
|
||||||
UserAccount,
|
UserAccount,
|
||||||
WhatsAppBusinessAccount,
|
WhatsAppBusinessAccount,
|
||||||
WhatsAppConnectionAudit,
|
WhatsAppConnectionAudit,
|
||||||
|
WhatsAppMessage,
|
||||||
WhatsAppMetaConnection,
|
WhatsAppMetaConnection,
|
||||||
WhatsAppPhoneAsset,
|
WhatsAppPhoneAsset,
|
||||||
WhatsAppWebhookEvent,
|
WhatsAppWebhookEvent,
|
||||||
)
|
)
|
||||||
from .whatsapp_config import resolved_whatsapp_app_secret
|
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__)
|
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]:
|
def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
|
||||||
|
verified = (phone.code_verification_status or "").upper()
|
||||||
return {
|
return {
|
||||||
"id": str(phone.id),
|
"id": str(phone.id),
|
||||||
"phone_number_id": phone.phone_number_id,
|
"phone_number_id": phone.phone_number_id,
|
||||||
|
|
@ -207,6 +212,8 @@ def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
|
||||||
"quality": phone.quality,
|
"quality": phone.quality,
|
||||||
"status": phone.status,
|
"status": phone.status,
|
||||||
"code_verification_status": phone.code_verification_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")
|
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(
|
async def _provision_waba(
|
||||||
db: Session,
|
db: Session,
|
||||||
user: UserAccount,
|
user: UserAccount,
|
||||||
|
|
@ -416,29 +492,31 @@ async def _provision_waba(
|
||||||
account.last_error = str(exc.detail)[:500]
|
account.last_error = str(exc.detail)[:500]
|
||||||
_apply_phones(db, user, account, phones, session_phone_id)
|
_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)
|
subscribed = await _subscribe(token, waba_id)
|
||||||
template_count, templates_ok = await _template_count(token, waba_id)
|
template_count, templates_ok = await _template_count(token, waba_id)
|
||||||
account.template_count = template_count
|
account.template_count = template_count
|
||||||
account.templates_status = "active" if templates_ok else "unavailable"
|
account.templates_status = "active" if templates_ok else "unavailable"
|
||||||
account.webhooks_status = "active" if subscribed else "unavailable"
|
account.webhooks_status = "active" if subscribed else "unavailable"
|
||||||
|
_set_account_health(db, user, account)
|
||||||
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,
|
|
||||||
)
|
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -547,6 +625,49 @@ async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> di
|
||||||
return serialize_account(account)
|
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]:
|
async def disconnect_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
|
||||||
account = _owned_account(db, user, account_id)
|
account = _owned_account(db, user, account_id)
|
||||||
connection = account.connection
|
connection = account.connection
|
||||||
|
|
@ -692,3 +813,133 @@ def customer_token_for_phone(db: Session, user: UserAccount, phone_number_id: st
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending")
|
||||||
return token, phone.phone_number_id
|
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
|
||||||
|
|
|
||||||
510
frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx
Normal file
510
frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
|
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
|
||||||
|
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
whatsappApi,
|
whatsappApi,
|
||||||
|
|
@ -10,7 +11,7 @@ import {
|
||||||
type WhatsAppTemplateRow,
|
type WhatsAppTemplateRow,
|
||||||
} from '../../lib/api'
|
} 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 DetailTab = 'overview' | 'numbers' | 'templates' | 'configuration' | 'logs'
|
||||||
|
|
||||||
type SignupSession = {
|
type SignupSession = {
|
||||||
|
|
@ -137,6 +138,7 @@ export function WhatsAppSetupPage() {
|
||||||
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
|
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
|
||||||
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
|
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [pins, setPins] = useState<Record<string, string>>({})
|
||||||
const sessionRef = useRef<SignupSession | null>(null)
|
const sessionRef = useRef<SignupSession | null>(null)
|
||||||
const codeRef = useRef<string | null>(null)
|
const codeRef = useRef<string | null>(null)
|
||||||
const finishingRef = useRef(false)
|
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() {
|
async function confirmDisconnect() {
|
||||||
if (!disconnecting) return
|
if (!disconnecting) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
|
@ -355,6 +371,16 @@ export function WhatsAppSetupPage() {
|
||||||
|
|
||||||
const resultAccount = result?.accounts[0]
|
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 (
|
return (
|
||||||
<div className="wa-biz">
|
<div className="wa-biz">
|
||||||
{phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>}
|
{phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>}
|
||||||
|
|
@ -470,6 +496,11 @@ export function WhatsAppSetupPage() {
|
||||||
<StatusDot status={account.status} />
|
<StatusDot status={account.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="wa-biz-menu">
|
<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="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)}>
|
<button className="icon-button" aria-label="Account actions" onClick={() => setMenuId(menuId === account.id ? null : account.id)}>
|
||||||
<MoreHorizontal size={16} />
|
<MoreHorizontal size={16} />
|
||||||
|
|
@ -520,6 +551,11 @@ export function WhatsAppSetupPage() {
|
||||||
<StatusDot status={selected.status} />
|
<StatusDot status={selected.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="wa-biz-actions">
|
<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}>
|
<button className="secondary-button" onClick={() => void refreshSelected()} disabled={busy}>
|
||||||
<RefreshCw size={14} /> Refresh
|
<RefreshCw size={14} /> Refresh
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -565,6 +601,7 @@ export function WhatsAppSetupPage() {
|
||||||
{tab === 'numbers' && (
|
{tab === 'numbers' && (
|
||||||
<section className="wa-biz-card">
|
<section className="wa-biz-card">
|
||||||
<h2>Phone numbers</h2>
|
<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>}
|
{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">
|
<ul className="wa-biz-numbers">
|
||||||
{selected.phones.map((phone) => (
|
{selected.phones.map((phone) => (
|
||||||
|
|
@ -572,9 +609,24 @@ export function WhatsAppSetupPage() {
|
||||||
<div>
|
<div>
|
||||||
<strong>{phone.display_phone || 'Number pending'}</strong>
|
<strong>{phone.display_phone || 'Number pending'}</strong>
|
||||||
<small>{phone.verified_name || 'Display name pending'}</small>
|
<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>
|
</div>
|
||||||
<StatusDot status={phone.status} />
|
<StatusDot status={phone.status} />
|
||||||
<span>Quality: {phone.quality || '—'}</span>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -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 { 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-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-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-send:disabled { opacity: .55; }
|
||||||
.wa-error { margin: 0; color: #b42318; font-size: 12px; }
|
.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; }
|
.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-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 { 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-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 { 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, .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; }
|
.wa-biz-table-head { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||||
|
|
|
||||||
|
|
@ -1138,6 +1138,8 @@ export type WhatsAppPhoneAsset = {
|
||||||
quality: string | null
|
quality: string | null
|
||||||
status: string
|
status: string
|
||||||
code_verification_status: string | null
|
code_verification_status: string | null
|
||||||
|
needs_otp?: boolean
|
||||||
|
needs_registration?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WhatsAppAccount = {
|
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 }[]
|
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 = {
|
export type WhatsAppWebhookEvent = {
|
||||||
id: string
|
id: string
|
||||||
waba_id: string | null
|
waba_id: string | null
|
||||||
|
|
@ -1222,6 +1254,18 @@ export const whatsappApi = {
|
||||||
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/refresh`, { method: 'POST' }),
|
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/refresh`, { method: 'POST' }),
|
||||||
disconnectAccount: (accountId: string) =>
|
disconnectAccount: (accountId: string) =>
|
||||||
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/disconnect`, { method: 'POST' }),
|
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) =>
|
templates: (accountId: string) =>
|
||||||
jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`),
|
jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`),
|
||||||
logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`),
|
logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue