meta whatsapp chat

This commit is contained in:
debu-2 2026-10-08 00:50:36 +05:30
parent 54f75387eb
commit 5ced0d0812
10 changed files with 1015 additions and 25 deletions

View file

@ -0,0 +1,510 @@
import {
Check,
CheckCheck,
ChevronLeft,
Image as ImageIcon,
MessageCircle,
MessageSquareText,
MoreHorizontal,
Paperclip,
Plus,
RefreshCw,
Search,
Send,
Smile,
Star,
UserRound,
X,
} from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { API_BASE_URL } from '../../lib/apiBase'
import {
whatsappApi,
type CustomerWhatsAppMessage,
type CustomerWhatsAppSend,
type WhatsAppAccount,
type WhatsAppTemplateRow,
} from '../../lib/api'
type SendMode = 'text' | 'template' | 'media' | 'buttons'
type ListFilter = 'all' | 'unread' | 'important'
type ProfileTab = 'details' | 'events'
const REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
function formatWaNumber(value: string) {
const digits = value.replace(/\D/g, '')
if (digits.length === 12 && digits.startsWith('91')) {
const local = digits.slice(2)
return `+91 ${local.slice(0, 5)} ${local.slice(5)}`
}
if (digits.length === 10) return `+91 ${digits.slice(0, 5)} ${digits.slice(5)}`
return value.startsWith('+') ? value : `+${digits || value}`
}
function formatClock(value: string) {
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function formatChatTime(value: string) {
const date = new Date(value)
const now = new Date()
if (date.toDateString() === now.toDateString()) return formatClock(value)
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
function formatDayLabel(value: string) {
const date = new Date(value)
const now = new Date()
if (date.toDateString() === now.toDateString()) return 'Today'
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
}
function formatWhen(value: string) {
return new Date(value).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function initials(name: string) {
const parts = name.split(/\s+/).filter(Boolean)
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
return name.replace(/\D/g, '').slice(-2) || 'WA'
}
export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAccount; onBack: () => void }) {
const [messages, setMessages] = useState<CustomerWhatsAppMessage[]>([])
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
const [loading, setLoading] = useState(true)
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
const [startingNew, setStartingNew] = useState(false)
const [newPeer, setNewPeer] = useState('')
const [sendMode, setSendMode] = useState<SendMode>('text')
const [error, setError] = useState('')
const [sending, setSending] = useState(false)
const [textBody, setTextBody] = useState('')
const [replyTo, setReplyTo] = useState('')
const [replyPreview, setReplyPreview] = useState('')
const [templateName, setTemplateName] = useState('')
const [templateLanguage, setTemplateLanguage] = useState('en')
const [mediaType, setMediaType] = useState<'image' | 'document' | 'audio' | 'video'>('image')
const [mediaLink, setMediaLink] = useState('')
const [mediaCaption, setMediaCaption] = useState('')
const [buttonBody, setButtonBody] = useState('')
const [buttonOne, setButtonOne] = useState('')
const [buttonTwo, setButtonTwo] = useState('')
const [buttonThree, setButtonThree] = useState('')
const [threadQuery, setThreadQuery] = useState('')
const [listFilter, setListFilter] = useState<ListFilter>('all')
const [readPeers, setReadPeers] = useState<Set<string>>(new Set())
const [starred, setStarred] = useState<Set<string>>(new Set())
const [profileTab, setProfileTab] = useState<ProfileTab>('details')
const [moreOpen, setMoreOpen] = useState(false)
const [detailsId, setDetailsId] = useState<string | null>(null)
const [reactFor, setReactFor] = useState<string | null>(null)
const [emojiOpen, setEmojiOpen] = useState(false)
const chatEndRef = useRef<HTMLDivElement | null>(null)
const line = account.primary_phone?.display_phone || account.name
const canSend = account.messaging_status === 'active' && account.usable
async function loadMessages() {
setMessages(await whatsappApi.messages(account.id))
}
useEffect(() => {
Promise.all([
whatsappApi.messages(account.id),
whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })),
])
.then(([items, templatePayload]) => {
setMessages(items)
setTemplates(templatePayload.templates.filter((item) => item.status === 'APPROVED' || !item.status))
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load conversations'))
.finally(() => setLoading(false))
const timer = window.setInterval(() => {
loadMessages().catch(() => undefined)
}, 8000)
return () => window.clearInterval(timer)
}, [account.id])
const threads = useMemo(() => {
const grouped = new Map<string, CustomerWhatsAppMessage[]>()
for (const message of messages) {
if (message.message_type === 'status') continue
const peer = message.direction === 'inbound' ? message.from : message.to
if (!peer) continue
const list = grouped.get(peer) ?? []
list.push(message)
grouped.set(peer, list)
}
return [...grouped.entries()]
.map(([peer, thread]) => {
const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at))
const latest = sorted[sorted.length - 1]
const name = sorted.find((item) => item.contact_name)?.contact_name
const unread = readPeers.has(peer) ? 0 : sorted.filter((item) => item.direction === 'inbound' && item.status !== 'read').length
return {
peer,
title: name || formatWaNumber(peer),
phoneLabel: formatWaNumber(peer),
initials: initials(name || peer),
messages: sorted,
latest,
unread,
important: starred.has(peer),
}
})
.sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at))
}, [messages, readPeers, starred])
const visibleThreads = threads.filter((thread) => {
const query = threadQuery.trim().toLowerCase()
if (query && !`${thread.title} ${thread.phoneLabel} ${thread.latest.body || ''}`.toLowerCase().includes(query)) return false
if (listFilter === 'unread') return thread.unread > 0
if (listFilter === 'important') return thread.important
return true
})
const active = threads.find((thread) => thread.peer === selectedPeer) || null
const activeThread = active?.messages ?? []
const unreadTotal = threads.reduce((sum, thread) => sum + thread.unread, 0)
const importantTotal = threads.filter((thread) => thread.important).length
useEffect(() => {
chatEndRef.current?.scrollIntoView({ block: 'end' })
}, [activeThread.length, selectedPeer])
function openThread(peer: string) {
setSelectedPeer(peer)
setStartingNew(false)
setReadPeers((current) => new Set(current).add(peer))
setMoreOpen(false)
}
async function sendReaction(message: CustomerWhatsAppMessage, emoji: string) {
if (!message.wa_message_id) return
setSending(true)
setError('')
try {
await whatsappApi.sendAccountMessage(account.id, {
to: message.from,
message_type: 'reaction',
reaction_message_id: message.wa_message_id,
reaction_emoji: emoji,
})
setReactFor(null)
await loadMessages()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to send reaction')
} finally {
setSending(false)
}
}
async function sendMessage(event: FormEvent) {
event.preventDefault()
const peer = active?.peer || newPeer
if (!peer) {
setError('Enter a WhatsApp number')
return
}
setSending(true)
setError('')
const payload: CustomerWhatsAppSend = { to: peer, message_type: sendMode === 'buttons' ? 'interactive_buttons' : sendMode }
if (replyTo) payload.reply_to = replyTo
if (sendMode === 'text') payload.text = textBody.trim()
if (sendMode === 'template') {
payload.template_name = templateName
payload.template_language = templateLanguage
}
if (sendMode === 'media') {
payload.message_type = mediaType
payload.media_link = mediaLink.trim()
payload.media_caption = mediaCaption.trim()
}
if (sendMode === 'buttons') {
payload.interactive_body = buttonBody.trim()
payload.interactive_buttons = [buttonOne, buttonTwo, buttonThree]
.map((title) => title.trim())
.filter(Boolean)
.map((title, index) => ({ id: `btn_${index + 1}`, title }))
}
try {
const sent = await whatsappApi.sendAccountMessage(account.id, payload)
setSelectedPeer(sent.to)
setStartingNew(false)
setNewPeer('')
setTextBody('')
setButtonBody('')
setMediaCaption('')
setReplyTo('')
setReplyPreview('')
setSendMode('text')
await loadMessages()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to send message')
} finally {
setSending(false)
}
}
function mediaUrl(mediaId: string) {
return `${API_BASE_URL}/api/v1/whatsapp/accounts/${account.id}/media/${mediaId}`
}
if (loading) return <p className="explorer-loading">Loading WhatsApp conversations…</p>
return (
<div className="wa-page wa-customer">
<div className="wa-page-title">
<div>
<button type="button" className="wa-biz-back" onClick={onBack}><ChevronLeft size={16} /> Accounts</button>
<h1><MessageCircle size={20} /> {account.business_name || account.name}</h1>
<p>Conversations on {formatWaNumber(line)}.</p>
</div>
<div className="wa-topbar-actions">
<span className={canSend ? 'wa-connected' : 'wa-connected pending'}><i />{canSend ? 'Messaging' : 'Messaging unavailable'}</span>
<button type="button" className="wa-ghost" onClick={() => void loadMessages()}><RefreshCw size={14} /> Refresh</button>
</div>
</div>
{!canSend && <p className="wa-biz-notice">{account.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}</p>}
<div className="wa-inbox">
<aside className="wa-list">
<div className="wa-list-head">
<h2>Conversations</h2>
<button type="button" className="wa-icon-btn" aria-label="New conversation" onClick={() => { setStartingNew(true); setSelectedPeer(null) }}>
<Plus size={14} />
</button>
</div>
<label className="wa-search">
<Search size={14} />
<input value={threadQuery} onChange={(event) => setThreadQuery(event.target.value)} placeholder="Search by name, phone or message…" />
</label>
<div className="wa-filters">
<button type="button" className={listFilter === 'all' ? 'active' : ''} onClick={() => setListFilter('all')}>All <em>{threads.length}</em></button>
<button type="button" className={listFilter === 'unread' ? 'active' : ''} onClick={() => setListFilter('unread')}>Unread <em>{unreadTotal}</em></button>
<button type="button" className={listFilter === 'important' ? 'active' : ''} onClick={() => setListFilter('important')}>Important <em>{importantTotal}</em></button>
</div>
<div className="wa-thread-list">
{visibleThreads.length === 0 && <p className="wa-empty">No conversations yet.</p>}
{visibleThreads.map((thread) => (
<button key={thread.peer} type="button" className={`wa-thread ${selectedPeer === thread.peer ? 'active' : ''}`} onClick={() => openThread(thread.peer)}>
<span className="wa-avatar wa-avatar-green">{thread.initials}</span>
<span className="wa-thread-copy">
<strong>{thread.title}</strong>
<span>{thread.latest.body || 'Attachment'}</span>
</span>
<span className="wa-thread-meta">
<small>{formatChatTime(thread.latest.created_at)}</small>
{thread.unread > 0 && <em>{thread.unread}</em>}
</span>
</button>
))}
</div>
</aside>
<section className="wa-chat">
<div className="wa-chat-head">
{active ? (
<>
<span className="wa-avatar wa-avatar-green">{active.initials}</span>
<div className="wa-chat-id">
<strong>{active.title}</strong>
<small>WhatsApp</small>
</div>
</>
) : (
<div className="wa-chat-id">
<strong>{startingNew ? 'New conversation' : 'Select a conversation'}</strong>
<small>{startingNew ? 'Enter a WhatsApp number' : formatWaNumber(line)}</small>
</div>
)}
<div className="wa-chat-tools">
<button
type="button"
className={`wa-tool ${active && starred.has(active.peer) ? 'on' : ''}`}
onClick={() => {
if (!active) return
setStarred((current) => {
const next = new Set(current)
if (next.has(active.peer)) next.delete(active.peer)
else next.add(active.peer)
return next
})
}}
>
<Star size={14} /> Star
</button>
<div className="wa-pop">
<button type="button" className="wa-tool" onClick={() => setMoreOpen((value) => !value)}><MoreHorizontal size={14} /> More</button>
{moreOpen && active && (
<div className="wa-menu">
<button type="button" onClick={() => { navigator.clipboard.writeText(active.phoneLabel); setMoreOpen(false) }}>Copy number</button>
</div>
)}
</div>
</div>
</div>
<div className="wa-canvas">
{!active && !startingNew && <p className="wa-empty">Messages for this WhatsApp number appear here.</p>}
{activeThread.map((item, index) => {
const previous = activeThread[index - 1]
const showDay = !previous || new Date(previous.created_at).toDateString() !== new Date(item.created_at).toDateString()
const outbound = item.direction === 'outbound'
return (
<div key={item.id}>
{showDay && <div className="wa-day">{formatDayLabel(item.created_at)}</div>}
<div className={`wa-bubble-row ${outbound ? 'out' : 'in'}`}>
<div className={`wa-bubble ${outbound ? 'out' : 'in'}`}>
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && <img src={mediaUrl(item.media.id)} alt="" className="wa-media" />}
{item.media?.id && item.message_type === 'video' && <video src={mediaUrl(item.media.id)} controls className="wa-media" />}
{item.media?.id && item.message_type === 'audio' && <audio src={mediaUrl(item.media.id)} controls />}
{item.media?.id && item.message_type === 'document' && <a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Open document</a>}
{item.message_type === 'template' && <em className="wa-kind">Template</em>}
{item.body && <p>{item.body}</p>}
<footer>
<time>{formatClock(item.created_at)}</time>
{outbound && (item.status === 'delivered' || item.status === 'read' ? <CheckCheck size={13} className={item.status === 'read' ? 'read' : ''} /> : <Check size={13} />)}
</footer>
{detailsId === item.id && (
<div className="wa-inspector">
<strong>Message details</strong>
<span>Status · {item.status}</span>
<span>Type · {item.message_type.replace(/_/g, ' ')}</span>
</div>
)}
</div>
<div className="wa-hover">
{!outbound && <button type="button" onClick={() => { setReplyTo(item.wa_message_id || ''); setReplyPreview(item.body || 'Attachment'); setSendMode('text') }}>Reply</button>}
{!outbound && item.wa_message_id && <button type="button" onClick={() => setReactFor(reactFor === item.id ? null : item.id)}>React</button>}
<button type="button" onClick={() => setDetailsId(detailsId === item.id ? null : item.id)} aria-label="Message details"><MoreHorizontal size={14} /></button>
{reactFor === item.id && (
<span className="wa-reacts">
{REACTIONS.map((emoji) => <button key={emoji} type="button" onClick={() => void sendReaction(item, emoji)}>{emoji}</button>)}
</span>
)}
</div>
</div>
</div>
)
})}
<div ref={chatEndRef} />
</div>
<form className="wa-composer" onSubmit={(event) => void sendMessage(event)}>
<div className="wa-modes">
{(['text', 'template', 'media', 'buttons'] as const).map((id) => (
<button key={id} type="button" className={sendMode === id ? 'active' : ''} onClick={() => setSendMode(id)}>{id[0].toUpperCase() + id.slice(1)}</button>
))}
</div>
{replyPreview && (
<div className="wa-quote">
<div><strong>Replying</strong><p>{replyPreview}</p></div>
<button type="button" aria-label="Cancel reply" onClick={() => { setReplyPreview(''); setReplyTo('') }}><X size={14} /></button>
</div>
)}
{(startingNew || !active) && <input className="wa-new-peer" value={newPeer} onChange={(event) => setNewPeer(event.target.value)} placeholder="Recipient number, e.g. 919453226814" />}
{sendMode === 'template' && (
<div className="wa-extra">
<select value={templateName} onChange={(event) => setTemplateName(event.target.value)} required>
<option value="">Choose a template</option>
{templates.map((template) => <option key={`${template.name}-${template.language}`} value={template.name || ''}>{template.name} ({template.language})</option>)}
</select>
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} aria-label="Language code" />
</div>
)}
{sendMode === 'media' && (
<div className="wa-extra">
<select value={mediaType} onChange={(event) => setMediaType(event.target.value as typeof mediaType)}>
<option value="image">Image</option>
<option value="document">Document</option>
<option value="audio">Audio</option>
<option value="video">Video</option>
</select>
<input value={mediaLink} onChange={(event) => setMediaLink(event.target.value)} placeholder="Public file link" required />
</div>
)}
{sendMode === 'buttons' && (
<div className="wa-extra">
<input value={buttonOne} onChange={(event) => setButtonOne(event.target.value)} placeholder="Button 1" required />
<input value={buttonTwo} onChange={(event) => setButtonTwo(event.target.value)} placeholder="Button 2" />
<input value={buttonThree} onChange={(event) => setButtonThree(event.target.value)} placeholder="Button 3" />
</div>
)}
<div className="wa-compose-box">
<textarea
rows={2}
value={sendMode === 'media' ? mediaCaption : sendMode === 'buttons' ? buttonBody : textBody}
onChange={(event) => {
if (sendMode === 'media') setMediaCaption(event.target.value)
else if (sendMode === 'buttons') setButtonBody(event.target.value)
else setTextBody(event.target.value)
}}
placeholder={canSend ? 'Type a message…' : 'Sending is unavailable until this number is connected'}
required={sendMode !== 'template'}
disabled={!canSend}
/>
</div>
<div className="wa-compose-bar">
<div className="wa-compose-icons">
<button type="button" aria-label="Emoji" onClick={() => setEmojiOpen((value) => !value)}><Smile size={16} /></button>
<button type="button" aria-label="Attach file" onClick={() => { setSendMode('media'); setMediaType('document') }}><Paperclip size={16} /></button>
<button type="button" aria-label="Image" onClick={() => { setSendMode('media'); setMediaType('image') }}><ImageIcon size={16} /></button>
{emojiOpen && (
<span className="wa-reacts static">
{REACTIONS.map((emoji) => <button key={emoji} type="button" onClick={() => { setTextBody((value) => `${value}${emoji}`); setEmojiOpen(false); setSendMode('text') }}>{emoji}</button>)}
</span>
)}
</div>
{error && <p className="wa-error">{error}</p>}
<button className="wa-send" type="submit" disabled={sending || !canSend}><Send size={14} /> {sending ? 'Sending' : 'Send'}</button>
</div>
</form>
</section>
<aside className="wa-profile">
<div className="wa-profile-hero">
<span className="wa-avatar large wa-avatar-green">{active ? active.initials : <UserRound size={22} />}</span>
<div>
<strong className="wa-name">{active?.title || 'No contact'}</strong>
<small>{active ? 'WhatsApp' : 'Select a conversation'}</small>
</div>
</div>
<div className="wa-tabs">
{(['details', 'events'] as const).map((id) => (
<button key={id} type="button" className={profileTab === id ? 'active' : ''} onClick={() => setProfileTab(id)}>{id[0].toUpperCase() + id.slice(1)}</button>
))}
</div>
{profileTab === 'details' && active && (
<dl className="wa-fields">
<div><dt>Phone</dt><dd>{active.phoneLabel}</dd></div>
<div><dt>Status</dt><dd>{active.latest.status}</dd></div>
<div><dt>Messages</dt><dd>{active.messages.length}</dd></div>
<div><dt>First seen</dt><dd>{formatWhen(active.messages[0].created_at)}</dd></div>
<div><dt>Last message</dt><dd>{formatWhen(active.latest.created_at)}</dd></div>
<div><dt>Source</dt><dd>WhatsApp</dd></div>
</dl>
)}
{profileTab === 'details' && !active && <p className="wa-empty">Open a conversation to see the customer.</p>}
{profileTab === 'events' && (
<ul className="wa-events">
{activeThread.length === 0 && <li><strong>No messages yet</strong></li>}
{[...activeThread].reverse().map((item) => (
<li key={item.id}><strong>{item.direction === 'inbound' ? 'Customer message' : 'Reply sent'}</strong><span>{formatWhen(item.created_at)}</span></li>
))}
</ul>
)}
<div className="wa-quick">
<h3>Quick actions</h3>
<button type="button" onClick={() => setSendMode('template')}><MessageSquareText size={14} /> Send template</button>
<button type="button" onClick={() => setSendMode('media')}><ImageIcon size={14} /> Send media</button>
</div>
</aside>
</div>
</div>
)
}

View file

@ -1,4 +1,5 @@
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
import { useEffect, useRef, useState } from 'react'
import {
whatsappApi,
@ -10,7 +11,7 @@ import {
type WhatsAppTemplateRow,
} from '../../lib/api'
type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail'
type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail' | 'inbox'
type DetailTab = 'overview' | 'numbers' | 'templates' | 'configuration' | 'logs'
type SignupSession = {
@ -137,6 +138,7 @@ export function WhatsAppSetupPage() {
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
const [copied, setCopied] = useState(false)
const [pins, setPins] = useState<Record<string, string>>({})
const sessionRef = useRef<SignupSession | null>(null)
const codeRef = useRef<string | null>(null)
const finishingRef = useRef(false)
@ -330,6 +332,20 @@ export function WhatsAppSetupPage() {
}
}
async function registerPhone(phoneId: string) {
if (!selected) return
setBusy(true)
setError('')
try {
replaceAccount(await whatsappApi.registerPhone(selected.id, phoneId, pins[phoneId]))
setNotice('Urmi asked Meta to register this number for Cloud API.')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to register this number')
} finally {
setBusy(false)
}
}
async function confirmDisconnect() {
if (!disconnecting) return
setBusy(true)
@ -355,6 +371,16 @@ export function WhatsAppSetupPage() {
const resultAccount = result?.accounts[0]
function openInbox(accountId: string) {
setSelectedId(accountId)
setMenuId(null)
setPhase('inbox')
}
if (phase === 'inbox' && selected) {
return <CustomerWhatsAppInbox account={selected} onBack={() => setPhase('list')} />
}
return (
<div className="wa-biz">
{phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>}
@ -470,6 +496,11 @@ export function WhatsAppSetupPage() {
<StatusDot status={account.status} />
</div>
<div className="wa-biz-menu">
{account.webhooks_status === 'active' && (
<button type="button" className="wa-chat-launch" onClick={() => openInbox(account.id)}>
<MessageCircle size={15} /> Chat
</button>
)}
<button className="secondary-button" onClick={() => { setSelectedId(account.id); setTab('overview'); setPhase('detail') }}>Open</button>
<button className="icon-button" aria-label="Account actions" onClick={() => setMenuId(menuId === account.id ? null : account.id)}>
<MoreHorizontal size={16} />
@ -520,6 +551,11 @@ export function WhatsAppSetupPage() {
<StatusDot status={selected.status} />
</div>
<div className="wa-biz-actions">
{selected.webhooks_status === 'active' && (
<button type="button" className="wa-chat-launch" onClick={() => openInbox(selected.id)}>
<MessageCircle size={15} /> Chat
</button>
)}
<button className="secondary-button" onClick={() => void refreshSelected()} disabled={busy}>
<RefreshCw size={14} /> Refresh
</button>
@ -565,6 +601,7 @@ export function WhatsAppSetupPage() {
{tab === 'numbers' && (
<section className="wa-biz-card">
<h2>Phone numbers</h2>
<p className="wa-biz-muted">Messaging stays unavailable until Meta reports this number as connected for Cloud API. Pending means the number is on the account but not registered yet. Quality stays unknown until then.</p>
{selected.phones.length === 0 && <p className="wa-biz-muted">Meta has not returned a phone number for this account yet.</p>}
<ul className="wa-biz-numbers">
{selected.phones.map((phone) => (
@ -572,9 +609,24 @@ export function WhatsAppSetupPage() {
<div>
<strong>{phone.display_phone || 'Number pending'}</strong>
<small>{phone.verified_name || 'Display name pending'}</small>
{phone.needs_otp && <small>Complete the SMS or voice code in Meta, then refresh. Log this number out of the WhatsApp app first.</small>}
</div>
<StatusDot status={phone.status} />
<span>Quality: {phone.quality || '—'}</span>
{phone.needs_registration && !phone.needs_otp && (
<div className="wa-biz-register">
<input
inputMode="numeric"
maxLength={6}
placeholder="6-digit PIN"
value={pins[phone.id] || ''}
onChange={(event) => setPins((current) => ({ ...current, [phone.id]: event.target.value.replace(/\D/g, '').slice(0, 6) }))}
/>
<button className="primary-button" type="button" disabled={busy} onClick={() => void registerPhone(phone.id)}>
Register for Cloud API
</button>
</div>
)}
</li>
))}
</ul>