template
This commit is contained in:
parent
6b6b929fea
commit
9f03e5df32
9 changed files with 225 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{([
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue