This commit is contained in:
debu-2 2026-10-15 03:28:51 +05:30
parent 6b6b929fea
commit 9f03e5df32
9 changed files with 225 additions and 37 deletions

View file

@ -78,6 +78,7 @@ async def lifespan(_: FastAPI):
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS contact_name VARCHAR(200)"))
conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_id VARCHAR(64)"))
conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_secret TEXT"))
conn.execute(text("ALTER TABLE whatsapp_broadcast_jobs ADD COLUMN IF NOT EXISTS failures JSON"))
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

@ -667,6 +667,7 @@ class WhatsAppBroadcastJob(Base):
sent_count: Mapped[int] = mapped_column(default=0)
failed_count: Mapped[int] = mapped_column(default=0)
next_index: Mapped[int] = mapped_column(default=0)
failures: Mapped[list | None] = mapped_column(JSON, nullable=True)
last_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -16,7 +16,7 @@ from .models import (
WhatsAppPhoneAsset,
WhatsAppWebhookEvent,
)
from .whatsapp_broadcast_jobs import create_csv_broadcast, list_broadcast_jobs
from .whatsapp_broadcast_jobs import broadcast_failure_csv, create_csv_broadcast, list_broadcast_jobs
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_config import resolved_whatsapp_app_secret
from .whatsapp_onboarding import (
@ -369,6 +369,21 @@ def whatsapp_broadcast_jobs(
return list_broadcast_jobs(db, user, account_id)
@router.get("/accounts/{account_id}/broadcasts/jobs/{job_id}/failures.csv")
def whatsapp_broadcast_failures(
account_id: str,
job_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> Response:
content, filename = broadcast_failure_csv(db, user, account_id, job_id)
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/accounts/{account_id}/broadcasts/csv")
async def whatsapp_broadcast_csv(
account_id: str,

View file

@ -7,7 +7,7 @@ import csv
import io
import logging
import re
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from uuid import UUID
@ -17,7 +17,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .database import SessionLocal
from .models import UserAccount, WhatsAppBroadcastJob
from .models import UserAccount, WhatsAppBroadcastJob, WhatsAppMessage
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_messages import extract_wa_message_id, record_outbound_message
from .whatsapp_onboarding import _messaging_phone, _owned_account
@ -66,7 +66,7 @@ def prepare_phone_number(raw: str, country_code: str) -> tuple[str | None, str |
return digits, None
def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], int]:
def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], list[dict[str, str]]]:
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError as exc:
@ -89,18 +89,22 @@ def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], int]:
)
ready: list[str] = []
seen: set[str] = set()
failed = 0
failures: list[dict[str, str]] = []
for row in filled:
number, reason = prepare_phone_number(str(row.get("phone_number") or ""), country_code)
original = str(row.get("phone_number") or "").strip()
number, reason = prepare_phone_number(original, country_code)
if reason or not number:
failed += 1
failures.append({
"phone_number": original,
"reason": reason or "This number does not match the selected country code.",
})
continue
if number in seen:
failed += 1
failures.append({"phone_number": original, "reason": "This number is already in the file."})
continue
seen.add(number)
ready.append(number)
return ready, failed
return ready, failures
def _serialize_job(job: WhatsAppBroadcastJob) -> dict[str, Any]:
@ -121,6 +125,66 @@ def _serialize_job(job: WhatsAppBroadcastJob) -> dict[str, Any]:
}
def _owned_job(db: Session, user: UserAccount, account_id: Any, job_id: Any) -> WhatsAppBroadcastJob:
account = _owned_account(db, user, account_id)
try:
parsed = job_id if isinstance(job_id, UUID) else UUID(str(job_id))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="This broadcast was not found.") from exc
job = db.get(WhatsAppBroadcastJob, parsed)
if job is None or job.user_id != user.id or job.account_id != account.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="This broadcast was not found.")
return job
def _legacy_failures(db: Session, job: WhatsAppBroadcastJob) -> list[dict[str, str]]:
"""Older broadcasts kept only a count. Recover numbers Meta actually rejected."""
recipients = [str(item) for item in (job.recipients or [])]
if not recipients or job.created_at is None:
return []
end = (job.completed_at or datetime.now(timezone.utc)) + timedelta(minutes=2)
sent = set(
db.scalars(
select(WhatsAppMessage.to_number).where(
WhatsAppMessage.user_id == job.user_id,
WhatsAppMessage.body == f"Broadcast: {job.template_name}",
WhatsAppMessage.created_at >= job.created_at,
WhatsAppMessage.created_at <= end,
)
).all()
)
missing = [number for number in recipients if number not in sent]
parse_failed = max(job.total - len(recipients), 0)
send_failed = max(job.failed_count - parse_failed, 0)
unsent = max(len(recipients) - job.next_index, 0) if job.status == "failed" else 0
if not missing or len(missing) > send_failed + unsent:
return []
reason = (job.last_error or "Meta did not accept this number.")[:500]
return [{"phone_number": number, "reason": reason} for number in missing]
def broadcast_failure_csv(db: Session, user: UserAccount, account_id: Any, job_id: Any) -> tuple[str, str]:
job = _owned_job(db, user, account_id, job_id)
rows = job.failures if isinstance(job.failures, list) else _legacy_failures(db, job)
cleaned = [
{"phone_number": str(item.get("phone_number") or ""), "reason": str(item.get("reason") or "")}
for item in rows
if isinstance(item, dict) and str(item.get("phone_number") or "").strip()
]
if not cleaned:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Failed numbers were not saved for this broadcast. New broadcasts keep each one.",
)
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["phone_number", "reason"])
for item in cleaned:
writer.writerow([item["phone_number"], item["reason"]])
stem = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(job.filename).stem)[:80] or "broadcast"
return buffer.getvalue(), f"{stem}-failed.csv"
def list_broadcast_jobs(db: Session, user: UserAccount, account_id: Any) -> list[dict[str, Any]]:
account = _owned_account(db, user, account_id)
rows = db.scalars(
@ -173,7 +237,7 @@ async def create_csv_broadcast(
status_code=status.HTTP_409_CONFLICT,
detail="Add the sample file on the template page before this broadcast can attach it.",
)
ready, failed = _parse_csv(raw, code)
ready, failures = _parse_csv(raw, code)
safe_name = Path(filename or "broadcast.csv").name[:200] or "broadcast.csv"
job = WhatsAppBroadcastJob(
user_id=user.id,
@ -187,10 +251,11 @@ async def create_csv_broadcast(
body_parameters=[str(item).strip() for item in body_parameters if str(item).strip()],
recipients=ready,
status="processing",
total=len(ready) + failed,
total=len(ready) + len(failures),
sent_count=0,
failed_count=failed,
failed_count=len(failures),
next_index=0,
failures=failures,
)
db.add(job)
db.commit()
@ -345,6 +410,8 @@ async def run_broadcast_job(job_id: UUID) -> None:
else:
job.failed_count += 1
job.last_error = (error or "Meta did not accept this number.")[:500]
if isinstance(job.failures, list):
job.failures = [*job.failures, {"phone_number": number, "reason": job.last_error}]
db.commit()
if len(batch) == BATCH_SIZE:
await asyncio.sleep(COOLDOWN_SECONDS)
@ -364,5 +431,14 @@ def _mark_failed(job_id: UUID, message: str) -> None:
return
job.status = "failed"
job.last_error = message[:500]
if isinstance(job.failures, list):
recipients = [str(item) for item in (job.recipients or [])]
pending = [
{"phone_number": number, "reason": "The broadcast stopped before this number was sent."}
for number in recipients[job.next_index:]
]
if pending:
job.failures = [*job.failures, *pending]
job.failed_count += len(pending)
job.completed_at = datetime.now(timezone.utc)
db.commit()

View file

@ -1,6 +1,7 @@
import {
Braces,
Check,
ChevronLeft,
CheckCheck,
Image as ImageIcon,
MessageSquareText,
@ -114,6 +115,7 @@ export function AdminWhatsAppPage() {
const [templates, setTemplates] = useState<AdminWhatsAppTemplate[]>([])
const [loading, setLoading] = useState(true)
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
const [profileOpen, setProfileOpen] = useState(false)
const [newPeer, setNewPeer] = useState('')
const [startingNew, setStartingNew] = useState(false)
const [sendMode, setSendMode] = useState<SendMode>('text')
@ -392,7 +394,7 @@ export function AdminWhatsAppPage() {
</section>
)}
<div className="wa-inbox">
<div className={`wa-inbox wa-split${selectedPeer || startingNew ? ' has-peer' : ''}${profileOpen ? ' is-profile' : ''}`}>
<aside className="wa-list">
<div className="wa-list-head">
<h2>Conversations</h2>
@ -443,6 +445,9 @@ export function AdminWhatsAppPage() {
<section className="wa-chat">
<div className="wa-chat-head">
<button type="button" className="wa-icon-btn wa-thread-back" aria-label="Back to conversations" onClick={() => { setSelectedPeer(null); setStartingNew(false); setProfileOpen(false) }}>
<ChevronLeft size={16} />
</button>
{active ? (
<>
{active.initials ? (
@ -462,6 +467,9 @@ export function AdminWhatsAppPage() {
</div>
)}
<div className="wa-chat-tools">
<button type="button" className={`wa-tool wa-profile-toggle${profileOpen ? ' on' : ''}`} onClick={() => setProfileOpen((open) => !open)}>
Details
</button>
<button
type="button"
className={`wa-tool ${active && starred.has(active.peer) ? 'on' : ''}`}
@ -654,6 +662,7 @@ export function AdminWhatsAppPage() {
<strong className="wa-name">{active?.title || 'No contact'}</strong>
<small>{active ? 'WhatsApp' : 'Select a conversation'}</small>
</div>
<button type="button" className="wa-icon-btn wa-profile-close" aria-label="Close details" onClick={() => setProfileOpen(false)}><X size={16} /></button>
</div>
<div className="wa-tabs">
{([

View file

@ -83,6 +83,7 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
const [loading, setLoading] = useState(true)
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
const [startingNew, setStartingNew] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
const [newPeer, setNewPeer] = useState('')
const [sendMode, setSendMode] = useState<SendMode>('text')
const [error, setError] = useState('')
@ -303,7 +304,7 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
<button className="primary-button" type="submit" disabled={deliveryBusy}>Turn on replies</button>
</form>
)}
<div className="wa-inbox">
<div className={`wa-inbox wa-split${selectedPeer || startingNew ? ' has-peer' : ''}${profileOpen ? ' is-profile' : ''}`}>
<aside className="wa-list">
<div className="wa-list-head">
<h2>Conversations</h2>
@ -340,6 +341,9 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
<section className="wa-chat">
<div className="wa-chat-head">
<button type="button" className="wa-icon-btn wa-thread-back" aria-label="Back to conversations" onClick={() => { setSelectedPeer(null); setStartingNew(false); setProfileOpen(false) }}>
<ChevronLeft size={16} />
</button>
{active ? (
<>
<span className="wa-avatar wa-avatar-green">{active.initials}</span>
@ -355,6 +359,9 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
</div>
)}
<div className="wa-chat-tools">
<button type="button" className={`wa-tool wa-profile-toggle${profileOpen ? ' on' : ''}`} onClick={() => setProfileOpen((open) => !open)}>
Details
</button>
<button
type="button"
className={`wa-tool ${active && starred.has(active.peer) ? 'on' : ''}`}
@ -503,6 +510,7 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
<strong className="wa-name">{active?.title || 'No contact'}</strong>
<small>{active ? 'WhatsApp' : 'Select a conversation'}</small>
</div>
<button type="button" className="wa-icon-btn wa-profile-close" aria-label="Close details" onClick={() => setProfileOpen(false)}><X size={16} /></button>
</div>
<div className="wa-tabs">
{(['details', 'events'] as const).map((id) => (

View file

@ -235,6 +235,16 @@ export function WhatsAppBroadcastPage() {
}
const selectedJob = jobs.find((job) => job.id === selectedJobId) || null
async function downloadFailures(job: WhatsAppBroadcastJob) {
if (!job.failed_count) return
setError('')
try {
await whatsappApi.downloadFailedNumbers(job.account_id, job.id)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not export the failed numbers')
}
}
async function startCsv() {
if (!csvFile) {
setError('Choose a CSV with a phone_number column.')
@ -483,7 +493,17 @@ export function WhatsAppBroadcastPage() {
{selectedJob && (
<div className="wa-broadcast-chart">
<h3>{selectedJob.filename}</h3>
<p>{selectedJob.sent_count} succeeded, {selectedJob.failed_count} failed{selectedJob.last_error && selectedJob.status === 'failed' ? `. ${selectedJob.last_error}` : ''}</p>
<p>
{selectedJob.sent_count} succeeded,{' '}
{selectedJob.failed_count > 0 ? (
<button type="button" className="wa-broadcast-failed" onClick={() => void downloadFailures(selectedJob)}>
{selectedJob.failed_count} failed
</button>
) : (
'0 failed'
)}
{selectedJob.last_error && selectedJob.status === 'failed' ? `. ${selectedJob.last_error}` : ''}
</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={[
{ name: 'Succeeded', count: selectedJob.sent_count },
@ -493,8 +513,16 @@ export function WhatsAppBroadcastPage() {
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
<Cell fill="#12805c" />
<Bar
dataKey="count"
radius={[6, 6, 0, 0]}
cursor={selectedJob.failed_count > 0 ? 'pointer' : 'default'}
onClick={(bar) => {
const name = typeof bar?.name === 'string' ? bar.name : bar?.payload?.name
if (name === 'Failed') void downloadFailures(selectedJob)
}}
>
<Cell fill="#12805c" cursor="default" />
<Cell fill="#c2362f" />
</Bar>
</BarChart>

View file

@ -1680,7 +1680,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-topbar { height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 22px; background: rgba(255,255,255,.92); border-bottom: 1px solid #e7edf5; }
.wa-topbar.plain { position: sticky; top: 0; }
.wa-product, .wa-ghost, .wa-tool, .wa-account-btn { display: inline-flex; align-items: center; gap: 7px; border: 1px solid #e6edf5; background: white; color: #31445f; border-radius: 999px; padding: 7px 12px; font-size: 12px; font-weight: 600; }
.wa-topbar-actions { display: flex; align-items: center; gap: 8px; }
.wa-topbar-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.wa-connected { display: inline-flex; align-items: center; gap: 6px; color: #0f7a49; background: #e8f8ef; border-radius: 999px; padding: 6px 10px; font-size: 12px; font-weight: 700; }
.wa-connected i, .wa-dot { width: 7px; height: 7px; border-radius: 50%; background: #18a36a; }
.wa-connected.pending { color: #8a5b00; background: #fff7df; }
@ -1693,8 +1693,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 5; min-width: 160px; padding: 6px; background: white; border: 1px solid #e6edf5; border-radius: 12px; box-shadow: 0 12px 30px rgba(18,31,54,.08); }
.wa-menu button { display: block; width: 100%; border: 0; background: transparent; text-align: left; border-radius: 8px; padding: 8px 10px; color: #24344c; font-size: 12px; }
.wa-menu button:hover { background: #f4f7fb; }
.wa-page { display: flex; flex-direction: column; gap: 14px; padding: 18px 22px 22px; min-height: 0; flex: 1; }
.wa-page-title { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.wa-page { display: flex; flex-direction: column; gap: 14px; padding: 18px 22px 22px; min-height: 0; min-width: 0; flex: 1; container: wapage / inline-size; }
.wa-page-title { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.wa-page-title > div:first-child { min-width: 0; }
.wa-page-title h1 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 22px; letter-spacing: -.03em; color: #14233b; }
.wa-page-title p { margin: 4px 0 0; color: #7d8ba0; font-size: 13px; }
.wa-setup { padding: 16px; background: white; border: 1px solid #e7edf5; border-radius: 14px; }
@ -1703,7 +1704,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-setup label { display: grid; gap: 6px; }
.wa-setup span { color: #607089; font-size: 11px; font-weight: 600; }
.wa-setup input, .wa-extra input, .wa-extra select, .wa-fields select, .wa-new-peer, .wa-tag-add input, .wa-profile-hero input { width: 100%; border: 1px solid #e6edf5; border-radius: 10px; padding: 9px 11px; font: inherit; font-size: 12px; color: #1c2b42; background: white; }
.wa-inbox { display: grid; grid-template-columns: 320px minmax(0, 1fr) 300px; min-height: calc(100vh - 168px); background: white; border: 1px solid #e7edf5; border-radius: 14px; overflow: hidden; box-shadow: 0 8px 24px rgba(18, 31, 54, .04); }
.wa-inbox { position: relative; display: grid; grid-template-columns: minmax(240px, 320px) minmax(0, 1fr) minmax(220px, 300px); width: 100%; min-width: 0; min-height: calc(100vh - 168px); background: white; border: 1px solid #e7edf5; border-radius: 14px; overflow: hidden; box-shadow: 0 8px 24px rgba(18, 31, 54, .04); }
.wa-list, .wa-profile { background: #fff; min-height: 0; }
.wa-list { display: flex; flex-direction: column; border-right: 1px solid #eef2f6; }
.wa-list-head, .wa-chat-head, .wa-profile-hero { display: flex; align-items: center; gap: 10px; }
@ -1712,7 +1713,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-list-tools { display: flex; gap: 2px; }
.wa-search { display: flex; align-items: center; gap: 8px; margin: 0 12px 10px; padding: 0 12px; border: 1px solid #e7edf5; border-radius: 999px; color: #8b97a8; background: #f8fafc; }
.wa-search input { width: 100%; border: 0; background: transparent; padding: 9px 0; outline: none; font: inherit; font-size: 12px; color: #1c2b42; }
.wa-filters { display: flex; gap: 6px; padding: 0 12px 10px; }
.wa-filters { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 10px; }
.wa-filters button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; color: #607089; border-radius: 999px; padding: 5px 10px; font-size: 12px; font-weight: 600; }
.wa-filters button.active { background: #1475ff; color: white; }
.wa-filters em { font-style: normal; font-size: 10px; }
@ -1724,18 +1725,19 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-avatar.icon { background: #e8eef8; color: #5d7190; }
.wa-avatar.large { width: 52px; height: 52px; font-size: 16px; }
.wa-thread-copy { display: grid; min-width: 0; }
.wa-thread-copy strong, .wa-chat-id strong { color: #17253e; font-size: 13px; font-weight: 650; }
.wa-thread-copy strong, .wa-chat-id strong { color: #17253e; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wa-thread-copy span { color: #7d8ba0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wa-thread-meta { display: grid; justify-items: end; gap: 6px; }
.wa-thread-meta small { color: #8b97a8; font-size: 11px; }
.wa-thread-meta em { min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: #1475ff; color: white; font-style: normal; font-size: 10px; line-height: 16px; text-align: center; }
.wa-chat { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; min-height: 0; background: #f7f9fc; }
.wa-chat-head { justify-content: flex-start; padding: 12px 14px; background: white; border-bottom: 1px solid #eef2f6; }
.wa-chat-id { display: grid; }
.wa-chat-head { justify-content: flex-start; flex-wrap: wrap; padding: 12px 14px; background: white; border-bottom: 1px solid #eef2f6; }
.wa-chat-id { display: grid; min-width: 0; flex: 1; }
.wa-chat-id small { color: #7d8ba0; font-size: 12px; }
.wa-chat-id small.online { color: #12805c; }
.wa-chat-id small.online::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: #18a36a; }
.wa-chat-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.wa-chat-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-left: auto; }
.wa-thread-back, .wa-profile-toggle, .wa-profile-close { display: none; }
.wa-tool { border-color: transparent; background: transparent; color: #607089; border-radius: 8px; }
.wa-tool.on { color: #1475ff; }
.wa-assigned { color: #1475ff; font-size: 11px; font-weight: 700; }
@ -1765,19 +1767,19 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-inspector strong { font-size: 11px; color: #17253e; }
.wa-inspector span { color: #607089; font-size: 11px; word-break: break-all; }
.wa-composer { padding: 10px 12px 12px; background: white; border-top: 1px solid #eef2f6; }
.wa-modes { display: flex; gap: 4px; margin-bottom: 8px; }
.wa-modes { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
.wa-modes button { border: 0; background: transparent; color: #607089; border-radius: 999px; padding: 5px 10px; font-size: 12px; font-weight: 650; }
.wa-modes button.active { background: #1475ff; color: white; }
.wa-quote { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 8px; padding: 8px 10px; border-left: 3px solid #1475ff; border-radius: 8px; background: #f3f7ff; }
.wa-quote strong { display: block; color: #1475ff; font-size: 11px; }
.wa-quote p { margin: 2px 0 0; color: #31445f; font-size: 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.wa-quote button { border: 0; background: transparent; color: #607089; }
.wa-extra { display: flex; gap: 8px; margin-bottom: 8px; }
.wa-extra > * { flex: 1; }
.wa-extra { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
.wa-extra > * { flex: 1 1 140px; min-width: 0; }
.wa-compose-box { position: relative; }
.wa-compose-box textarea { width: 100%; min-height: 64px; max-height: 140px; border: 1px solid #e7edf5; border-radius: 12px; padding: 10px 108px 10px 12px; resize: vertical; font: inherit; font-size: 13px; color: #1c2b42; background: #fbfcfe; }
.wa-ai { position: absolute; right: 8px; bottom: 8px; display: inline-flex; align-items: center; gap: 4px; border: 0; border-radius: 999px; padding: 5px 8px; background: #f3e8ff; color: #7c3aed; font-size: 11px; font-weight: 700; }
.wa-compose-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 8px; }
.wa-compose-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.wa-compose-icons { position: relative; display: flex; gap: 2px; }
.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; }
@ -1793,6 +1795,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.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-hero { align-items: center; gap: 12px; padding: 18px 16px 8px; }
.wa-profile-hero > div { min-width: 0; flex: 1; }
.wa-name { overflow-wrap: anywhere; }
.wa-name { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; padding: 0; color: #17253e; font-size: 14px; font-weight: 700; }
.wa-profile-hero small { display: block; margin-top: 2px; color: #7d8ba0; font-size: 12px; }
.wa-tabs { display: flex; gap: 4px; margin: 8px 12px 0; border-bottom: 1px solid #eef2f6; }
@ -1801,7 +1805,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-fields { margin: 0; padding: 8px 16px; }
.wa-fields div { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 8px; align-items: center; padding: 8px 0; }
.wa-fields dt { color: #8b97a8; font-size: 12px; }
.wa-fields dd { margin: 0; color: #1c2b42; font-size: 13px; }
.wa-fields dd { margin: 0; color: #1c2b42; font-size: 13px; overflow-wrap: anywhere; }
.wa-fields dd select { padding: 6px 8px; }
.wa-dot { display: inline-block; margin-right: 6px; }
.wa-tag-line { display: flex; flex-wrap: wrap; gap: 6px; }
@ -1816,9 +1820,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-tag-add { display: flex; gap: 6px; margin-top: 10px; }
.wa-tag-add button { display: grid; place-items: center; width: 34px; border: 0; border-radius: 10px; background: #1475ff; color: white; }
.wa-events { list-style: none; margin: 0; display: grid; gap: 10px; }
.wa-events li { display: flex; justify-content: space-between; gap: 10px; }
.wa-events li { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.wa-events strong { color: #17253e; font-size: 12px; }
.wa-events span { color: #8b97a8; font-size: 11px; white-space: nowrap; }
.wa-events span { color: #8b97a8; font-size: 11px; }
.wa-quick { display: grid; gap: 4px; margin-top: auto; padding: 8px 10px 14px; }
.wa-quick h3 { padding: 8px 6px; }
.wa-quick > button { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: transparent; border-radius: 8px; padding: 8px; color: #24344c; font-size: 13px; font-weight: 600; text-align: left; }
@ -1830,14 +1834,44 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-shell { grid-template-columns: 72px minmax(0, 1fr); }
.wa-nav button { font-size: 0; justify-content: center; padding: 10px; }
.wa-nav-brand, .wa-nav-foot { display: none; }
.wa-inbox { grid-template-columns: 280px minmax(0, 1fr); }
.wa-profile { display: none; }
}
@media (max-width: 860px) {
.wa-inbox { grid-template-columns: 1fr; }
.wa-list { max-height: 280px; }
.wa-setup form { grid-template-columns: 1fr; }
}
@container wapage (max-width: 1040px) {
.wa-inbox { grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); }
.wa-profile { display: none; }
.wa-profile-toggle { display: inline-flex; }
.wa-inbox.is-profile .wa-profile {
display: flex;
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(320px, 100%);
z-index: 6;
background: #fff;
box-shadow: -12px 0 30px rgba(18, 31, 54, .12);
}
.wa-inbox.is-profile .wa-profile-close { display: grid; margin-left: auto; }
}
@container wapage (max-width: 720px) {
.wa-page { padding: 14px 12px 18px; }
.wa-inbox { grid-template-columns: 1fr; min-height: 70vh; }
.wa-list { border-right: 0; border-bottom: 1px solid #eef2f6; }
.wa-inbox.wa-split { min-height: calc(100vh - 210px); }
.wa-inbox.wa-split .wa-list { border-bottom: 0; }
.wa-inbox.wa-split.has-peer .wa-list { display: none; }
.wa-inbox.wa-split:not(.has-peer) .wa-chat { display: none; }
.wa-inbox.wa-split.has-peer .wa-thread-back { display: inline-grid; }
.wa-inbox:not(.wa-split) .wa-list { max-height: 280px; }
.wa-fields div { grid-template-columns: 1fr; gap: 2px; }
.wa-bubble { max-width: 100%; }
.wa-bubble-row { flex-wrap: wrap; }
.wa-hover { opacity: 1; }
.wa-compose-box textarea { padding-right: 12px; }
.wa-page-title h1 { font-size: 18px; }
}
.wa-biz { max-width: 1080px; }
.wa-biz-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
@ -1861,6 +1895,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-broadcast-chart { margin-top: 16px; }
.wa-broadcast-chart h3 { margin: 0 0 4px; }
.wa-broadcast-chart p { margin: 0 0 8px; color: #526176; }
.wa-broadcast-failed { color: #c2362f; background: transparent; border: 0; padding: 0; font: inherit; font-weight: 700; cursor: pointer; text-decoration: underline; }
@media (max-width: 720px) {
.wa-broadcast-job, .wa-broadcast-job-head { grid-template-columns: 1fr; gap: 4px; }
}

View file

@ -1329,6 +1329,21 @@ export const whatsappApi = {
body.append('body_parameters', fields.body_parameters)
return jsonRequest<WhatsAppBroadcastJob>(`/api/v1/whatsapp/accounts/${accountId}/broadcasts/csv`, { method: 'POST', body })
},
downloadFailedNumbers: async (accountId: string, jobId: string) => {
const response = await fetch(`${API_BASE_URL}/api/v1/whatsapp/accounts/${accountId}/broadcasts/jobs/${jobId}/failures.csv`, { credentials: 'include' })
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Could not export the failed numbers')
}
const filename = /filename="([^"]+)"/.exec(response.headers.get('Content-Disposition') || '')?.[1] || 'failed-numbers.csv'
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
},
sendBroadcast: (accountId: string, payload: {
template_name: string
template_language: string