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

@ -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