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_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_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_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_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)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)"))
yield yield

View file

@ -667,6 +667,7 @@ class WhatsAppBroadcastJob(Base):
sent_count: Mapped[int] = mapped_column(default=0) sent_count: Mapped[int] = mapped_column(default=0)
failed_count: Mapped[int] = mapped_column(default=0) failed_count: Mapped[int] = mapped_column(default=0)
next_index: 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) 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) 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) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -16,7 +16,7 @@ from .models import (
WhatsAppPhoneAsset, WhatsAppPhoneAsset,
WhatsAppWebhookEvent, 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_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_config import resolved_whatsapp_app_secret from .whatsapp_config import resolved_whatsapp_app_secret
from .whatsapp_onboarding import ( from .whatsapp_onboarding import (
@ -369,6 +369,21 @@ def whatsapp_broadcast_jobs(
return list_broadcast_jobs(db, user, account_id) 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") @router.post("/accounts/{account_id}/broadcasts/csv")
async def whatsapp_broadcast_csv( async def whatsapp_broadcast_csv(
account_id: str, account_id: str,

View file

@ -7,7 +7,7 @@ import csv
import io import io
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@ -17,7 +17,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .database import SessionLocal from .database import SessionLocal
from .models import UserAccount, WhatsAppBroadcastJob from .models import UserAccount, WhatsAppBroadcastJob, WhatsAppMessage
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_messages import extract_wa_message_id, record_outbound_message from .whatsapp_messages import extract_wa_message_id, record_outbound_message
from .whatsapp_onboarding import _messaging_phone, _owned_account 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 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: try:
text = raw.decode("utf-8-sig") text = raw.decode("utf-8-sig")
except UnicodeDecodeError as exc: except UnicodeDecodeError as exc:
@ -89,18 +89,22 @@ def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], int]:
) )
ready: list[str] = [] ready: list[str] = []
seen: set[str] = set() seen: set[str] = set()
failed = 0 failures: list[dict[str, str]] = []
for row in filled: 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: 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 continue
if number in seen: if number in seen:
failed += 1 failures.append({"phone_number": original, "reason": "This number is already in the file."})
continue continue
seen.add(number) seen.add(number)
ready.append(number) ready.append(number)
return ready, failed return ready, failures
def _serialize_job(job: WhatsAppBroadcastJob) -> dict[str, Any]: 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]]: def list_broadcast_jobs(db: Session, user: UserAccount, account_id: Any) -> list[dict[str, Any]]:
account = _owned_account(db, user, account_id) account = _owned_account(db, user, account_id)
rows = db.scalars( rows = db.scalars(
@ -173,7 +237,7 @@ async def create_csv_broadcast(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Add the sample file on the template page before this broadcast can attach it.", 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" safe_name = Path(filename or "broadcast.csv").name[:200] or "broadcast.csv"
job = WhatsAppBroadcastJob( job = WhatsAppBroadcastJob(
user_id=user.id, 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()], body_parameters=[str(item).strip() for item in body_parameters if str(item).strip()],
recipients=ready, recipients=ready,
status="processing", status="processing",
total=len(ready) + failed, total=len(ready) + len(failures),
sent_count=0, sent_count=0,
failed_count=failed, failed_count=len(failures),
next_index=0, next_index=0,
failures=failures,
) )
db.add(job) db.add(job)
db.commit() db.commit()
@ -345,6 +410,8 @@ async def run_broadcast_job(job_id: UUID) -> None:
else: else:
job.failed_count += 1 job.failed_count += 1
job.last_error = (error or "Meta did not accept this number.")[:500] 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() db.commit()
if len(batch) == BATCH_SIZE: if len(batch) == BATCH_SIZE:
await asyncio.sleep(COOLDOWN_SECONDS) await asyncio.sleep(COOLDOWN_SECONDS)
@ -364,5 +431,14 @@ def _mark_failed(job_id: UUID, message: str) -> None:
return return
job.status = "failed" job.status = "failed"
job.last_error = message[:500] 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) job.completed_at = datetime.now(timezone.utc)
db.commit() db.commit()

View file

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

View file

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

View file

@ -235,6 +235,16 @@ export function WhatsAppBroadcastPage() {
} }
const selectedJob = jobs.find((job) => job.id === selectedJobId) || null 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() { async function startCsv() {
if (!csvFile) { if (!csvFile) {
setError('Choose a CSV with a phone_number column.') setError('Choose a CSV with a phone_number column.')
@ -483,7 +493,17 @@ export function WhatsAppBroadcastPage() {
{selectedJob && ( {selectedJob && (
<div className="wa-broadcast-chart"> <div className="wa-broadcast-chart">
<h3>{selectedJob.filename}</h3> <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}> <ResponsiveContainer width="100%" height={220}>
<BarChart data={[ <BarChart data={[
{ name: 'Succeeded', count: selectedJob.sent_count }, { name: 'Succeeded', count: selectedJob.sent_count },
@ -493,8 +513,16 @@ export function WhatsAppBroadcastPage() {
<XAxis dataKey="name" tick={{ fontSize: 12 }} /> <XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} /> <YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip /> <Tooltip />
<Bar dataKey="count" radius={[6, 6, 0, 0]}> <Bar
<Cell fill="#12805c" /> 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" /> <Cell fill="#c2362f" />
</Bar> </Bar>
</BarChart> </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 { 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-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-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 { 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 i, .wa-dot { width: 7px; height: 7px; border-radius: 50%; background: #18a36a; }
.wa-connected.pending { color: #8a5b00; background: #fff7df; } .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 { 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 { 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-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 { 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; } .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 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-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; } .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 label { display: grid; gap: 6px; }
.wa-setup span { color: #607089; font-size: 11px; font-weight: 600; } .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-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, .wa-profile { background: #fff; min-height: 0; }
.wa-list { display: flex; flex-direction: column; border-right: 1px solid #eef2f6; } .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; } .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-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 { 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-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 { 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 button.active { background: #1475ff; color: white; }
.wa-filters em { font-style: normal; font-size: 10px; } .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.icon { background: #e8eef8; color: #5d7190; }
.wa-avatar.large { width: 52px; height: 52px; font-size: 16px; } .wa-avatar.large { width: 52px; height: 52px; font-size: 16px; }
.wa-thread-copy { display: grid; min-width: 0; } .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-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 { display: grid; justify-items: end; gap: 6px; }
.wa-thread-meta small { color: #8b97a8; font-size: 11px; } .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-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 { 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-head { justify-content: flex-start; flex-wrap: wrap; padding: 12px 14px; background: white; border-bottom: 1px solid #eef2f6; }
.wa-chat-id { display: grid; } .wa-chat-id { display: grid; min-width: 0; flex: 1; }
.wa-chat-id small { color: #7d8ba0; font-size: 12px; } .wa-chat-id small { color: #7d8ba0; font-size: 12px; }
.wa-chat-id small.online { color: #12805c; } .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-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 { border-color: transparent; background: transparent; color: #607089; border-radius: 8px; }
.wa-tool.on { color: #1475ff; } .wa-tool.on { color: #1475ff; }
.wa-assigned { color: #1475ff; font-size: 11px; font-weight: 700; } .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 strong { font-size: 11px; color: #17253e; }
.wa-inspector span { color: #607089; font-size: 11px; word-break: break-all; } .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-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 { 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-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 { 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 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 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-quote button { border: 0; background: transparent; color: #607089; }
.wa-extra { display: flex; gap: 8px; margin-bottom: 8px; } .wa-extra { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
.wa-extra > * { flex: 1; } .wa-extra > * { flex: 1 1 140px; min-width: 0; }
.wa-compose-box { position: relative; } .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-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-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 { 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 { 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; }
@ -1793,6 +1795,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.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; }
.wa-profile-hero { align-items: center; gap: 12px; padding: 18px 16px 8px; } .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-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-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; } .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 { 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 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 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-fields dd select { padding: 6px 8px; }
.wa-dot { display: inline-block; margin-right: 6px; } .wa-dot { display: inline-block; margin-right: 6px; }
.wa-tag-line { display: flex; flex-wrap: wrap; gap: 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 { 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-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 { 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 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 { display: grid; gap: 4px; margin-top: auto; padding: 8px 10px 14px; }
.wa-quick h3 { padding: 8px 6px; } .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; } .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-shell { grid-template-columns: 72px minmax(0, 1fr); }
.wa-nav button { font-size: 0; justify-content: center; padding: 10px; } .wa-nav button { font-size: 0; justify-content: center; padding: 10px; }
.wa-nav-brand, .wa-nav-foot { display: none; } .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) { @media (max-width: 860px) {
.wa-inbox { grid-template-columns: 1fr; }
.wa-list { max-height: 280px; }
.wa-setup form { grid-template-columns: 1fr; } .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 { max-width: 1080px; }
.wa-biz-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; } .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 { margin-top: 16px; }
.wa-broadcast-chart h3 { margin: 0 0 4px; } .wa-broadcast-chart h3 { margin: 0 0 4px; }
.wa-broadcast-chart p { margin: 0 0 8px; color: #526176; } .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) { @media (max-width: 720px) {
.wa-broadcast-job, .wa-broadcast-job-head { grid-template-columns: 1fr; gap: 4px; } .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) body.append('body_parameters', fields.body_parameters)
return jsonRequest<WhatsAppBroadcastJob>(`/api/v1/whatsapp/accounts/${accountId}/broadcasts/csv`, { method: 'POST', body }) 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: { sendBroadcast: (accountId: string, payload: {
template_name: string template_name: string
template_language: string template_language: string