template
This commit is contained in:
parent
8901d99dd2
commit
29d867438c
7 changed files with 645 additions and 5 deletions
|
|
@ -1,7 +1,16 @@
|
|||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api'
|
||||
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppBroadcastJob, type WhatsAppTemplateRow } from '../../lib/api'
|
||||
|
||||
const COUNTRIES = [
|
||||
{ code: '91', label: 'India' },
|
||||
{ code: '1', label: 'United States' },
|
||||
{ code: '44', label: 'United Kingdom' },
|
||||
{ code: '971', label: 'United Arab Emirates' },
|
||||
{ code: '65', label: 'Singapore' },
|
||||
]
|
||||
|
||||
const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck'
|
||||
const TEAM_TEMPLATE_BODY = "Team, the broadcast desk is on air. This is the first note Urmi is sending through Meta's marketing line, and it is written for the people building it. Read it on your phone, then bring it to standup so we can close the test."
|
||||
|
|
@ -34,6 +43,13 @@ export function WhatsAppBroadcastPage() {
|
|||
const [created, setCreated] = useState<CreatedTemplate | null>(null)
|
||||
const [templateTick, setTemplateTick] = useState(0)
|
||||
const [recipients, setRecipients] = useState('')
|
||||
const [countryCode, setCountryCode] = useState('91')
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [csvKey, setCsvKey] = useState(0)
|
||||
const [csvBusy, setCsvBusy] = useState(false)
|
||||
const [jobs, setJobs] = useState<WhatsAppBroadcastJob[]>([])
|
||||
const [selectedJobId, setSelectedJobId] = useState('')
|
||||
const [jobTick, setJobTick] = useState(0)
|
||||
const [bodyValues, setBodyValues] = useState('')
|
||||
const [strict, setStrict] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -79,6 +95,29 @@ export function WhatsAppBroadcastPage() {
|
|||
.catch(() => setManaged([]))
|
||||
}, [accountId, templateTick])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) return
|
||||
let cancelled = false
|
||||
let timer = 0
|
||||
async function load() {
|
||||
try {
|
||||
const rows = await whatsappApi.broadcastJobs(accountId)
|
||||
if (cancelled) return
|
||||
setJobs(rows)
|
||||
if (rows.some((job) => job.status === 'processing')) {
|
||||
timer = window.setTimeout(() => { void load() }, 3000)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setJobs([])
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [accountId, jobTick])
|
||||
|
||||
async function createTemplate(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setCreating(true)
|
||||
|
|
@ -108,6 +147,11 @@ export function WhatsAppBroadcastPage() {
|
|||
setResult(null)
|
||||
const numbers = recipients.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)
|
||||
const parameters = bodyValues.split('\n').map((item) => item.trim()).filter(Boolean)
|
||||
if (!numbers.length) {
|
||||
setError('Enter at least one number, or start a CSV broadcast.')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
if (mediaHeader && !savedSample) {
|
||||
setError(`Add the sample ${headerLabel} on the template page. Meta rejects this send without it.`)
|
||||
setBusy(false)
|
||||
|
|
@ -142,6 +186,47 @@ export function WhatsAppBroadcastPage() {
|
|||
const mediaHeader = ['IMAGE', 'VIDEO', 'DOCUMENT'].includes(selectedTemplate?.header_format || '')
|
||||
const savedSample = Boolean(selectedTemplate?.sample_stored)
|
||||
const headerLabel = selectedTemplate?.header_format === 'VIDEO' ? 'video' : selectedTemplate?.header_format === 'DOCUMENT' ? 'document' : 'image'
|
||||
const selectedJob = jobs.find((job) => job.id === selectedJobId) || null
|
||||
|
||||
async function startCsv() {
|
||||
if (!csvFile) {
|
||||
setError('Choose a CSV with a phone_number column.')
|
||||
return
|
||||
}
|
||||
if (!templateName.trim()) {
|
||||
setError('Choose a marketing template.')
|
||||
return
|
||||
}
|
||||
if (mediaHeader && !savedSample) {
|
||||
setError(`Add the sample ${headerLabel} on the template page. Meta rejects this send without it.`)
|
||||
return
|
||||
}
|
||||
if (mediaHeader && !useSample) {
|
||||
setError(`Turn on the saved sample ${headerLabel}. This template’s header is a ${headerLabel}, and Meta rejects the send without it.`)
|
||||
return
|
||||
}
|
||||
setCsvBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const job = await whatsappApi.uploadBroadcastCsv(accountId, csvFile, {
|
||||
country_code: countryCode,
|
||||
template_name: templateName.trim(),
|
||||
template_language: templateLanguage.trim() || 'en',
|
||||
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
|
||||
use_sample_header: Boolean(mediaHeader && useSample && savedSample),
|
||||
body_parameters: bodyValues,
|
||||
})
|
||||
setJobs((current) => [job, ...current.filter((item) => item.id !== job.id)])
|
||||
setSelectedJobId(job.id)
|
||||
setCsvFile(null)
|
||||
setCsvKey((value) => value + 1)
|
||||
setJobTick((value) => value + 1)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'The CSV broadcast could not be started')
|
||||
} finally {
|
||||
setCsvBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wa-biz wa-token-page">
|
||||
|
|
@ -239,9 +324,32 @@ export function WhatsAppBroadcastPage() {
|
|||
</label>
|
||||
<label className="explorer-input">
|
||||
<strong>Recipients</strong>
|
||||
<span>One WhatsApp number per line, up to 20. Include the country code.</span>
|
||||
<textarea value={recipients} onChange={(event) => setRecipients(event.target.value)} rows={4} required placeholder="9198XXXXXXXX" />
|
||||
<span>Type numbers here, up to 20, or upload a CSV below. One number per line.</span>
|
||||
<textarea value={recipients} onChange={(event) => setRecipients(event.target.value)} rows={4} placeholder="9198XXXXXXXX" />
|
||||
</label>
|
||||
<label className="explorer-input">
|
||||
<strong>Country code</strong>
|
||||
<span>Ten-digit numbers get this code. After cleanup, every number must start with it. India must start with 91.</span>
|
||||
<select value={countryCode} onChange={(event) => setCountryCode(event.target.value)}>
|
||||
{COUNTRIES.map((country) => (
|
||||
<option key={country.code} value={country.code}>{country.label} (+{country.code})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="explorer-input">
|
||||
<strong>CSV</strong>
|
||||
<span>One column named phone_number, up to 1,000 numbers. Spaces, dashes, and plus signs are removed. A duplicate or a number that does not match the country code counts as failed. Urmi sends 10, waits 2 seconds, then continues, staying under Meta’s 20-per-second limit.</span>
|
||||
<input
|
||||
key={csvKey}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
onChange={(event) => setCsvFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
<button className="secondary-button" type="button" disabled={csvBusy || !accountId || !csvFile} onClick={() => void startCsv()}>
|
||||
{csvBusy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||
{csvFile ? `Start ${csvFile.name}` : 'Start CSV broadcast'}
|
||||
</button>
|
||||
{mediaHeader && (
|
||||
<label className="explorer-input">
|
||||
<strong>Header {headerLabel}</strong>
|
||||
|
|
@ -290,6 +398,52 @@ export function WhatsAppBroadcastPage() {
|
|||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && accounts.length > 0 && (
|
||||
<section className="wa-biz-card">
|
||||
<h2>CSV broadcasts</h2>
|
||||
<p>A file shows here as soon as it is accepted. This page checks every 3 seconds until sending finishes.</p>
|
||||
{jobs.length === 0 && <p className="wa-biz-muted">No CSV broadcasts yet.</p>}
|
||||
{jobs.length > 0 && (
|
||||
<div className="wa-broadcast-jobs">
|
||||
<div className="wa-broadcast-job wa-broadcast-job-head"><span>CSV</span><span>Started</span><span>Status</span></div>
|
||||
{jobs.map((job) => (
|
||||
<button
|
||||
type="button"
|
||||
key={job.id}
|
||||
className={job.id === selectedJobId ? 'wa-broadcast-job is-selected' : 'wa-broadcast-job'}
|
||||
onClick={() => setSelectedJobId((current) => current === job.id ? '' : job.id)}
|
||||
>
|
||||
<span>{job.filename}</span>
|
||||
<span>{job.created_at ? new Date(job.created_at).toLocaleString() : '—'}</span>
|
||||
<span>{job.status === 'processing' ? `Processing · ${job.sent_count + job.failed_count} of ${job.total}` : job.status === 'failed' ? 'Failed' : 'Completed'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={[
|
||||
{ name: 'Succeeded', count: selectedJob.sent_count },
|
||||
{ name: 'Failed', count: selectedJob.failed_count },
|
||||
]}>
|
||||
<CartesianGrid vertical={false} stroke="#edf0f4" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
|
||||
<Cell fill="#12805c" />
|
||||
<Cell fill="#c2362f" />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue