meta embed flow

This commit is contained in:
debu-2 2026-10-07 23:25:19 +05:30
parent ae72b59878
commit 99ed8f2477
10 changed files with 2256 additions and 151 deletions

View file

@ -1,144 +1,703 @@
import { Check, Copy, ExternalLink, MessageCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { whatsappApi, type WhatsAppSetup, type WhatsAppWebhookEvent } from '../../lib/api'
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import {
whatsappApi,
type WhatsAppAccount,
type WhatsAppAccountLogs,
type WhatsAppDeveloperConfig,
type WhatsAppSignupConfig,
type WhatsAppSignupResult,
type WhatsAppTemplateRow,
} from '../../lib/api'
function ConfigBadge({ ok, label }: { ok: boolean; label: string }) {
return (
<span className={`whatsapp-config-badge ${ok ? 'ok' : 'missing'}`}>
<i /> {label}: {ok ? 'Configured' : 'Missing'}
</span>
)
type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail'
type DetailTab = 'overview' | 'numbers' | 'templates' | 'configuration' | 'logs'
type SignupSession = {
event: string
wabaId?: string
phoneNumberId?: string
businessId?: string
errorMessage?: string
currentStep?: string
}
type FacebookSdk = {
init: (options: Record<string, unknown>) => void
login: (
callback: (response: { authResponse?: { code?: string } | null }) => void,
options: Record<string, unknown>,
) => void
}
const STEPS = [
'Connect or create a Meta business',
'Create or select a WhatsApp Business Account',
'Add and verify a business phone number',
'Grant Urmi access to manage messaging',
]
const WHY = [
['Send and receive messages', 'Use the number you verify with Meta for customer conversations.'],
['Manage templates', 'Review template status and quality for each WhatsApp Business Account.'],
['Multiple numbers', 'Connect the businesses, accounts, and numbers Meta makes available to you.'],
['Built for businesses of all sizes', 'Each connection stays inside this Urmi workspace.'],
]
function facebookSdk(): FacebookSdk | undefined {
return (window as Window & { FB?: FacebookSdk }).FB
}
function loadFacebookSdk(): Promise<void> {
if (facebookSdk()) return Promise.resolve()
return new Promise((resolve, reject) => {
const host = window as Window & { fbAsyncInit?: () => void }
host.fbAsyncInit = () => resolve()
if (document.getElementById('facebook-jssdk')) return
const script = document.createElement('script')
script.id = 'facebook-jssdk'
script.async = true
script.defer = true
script.crossOrigin = 'anonymous'
script.src = 'https://connect.facebook.net/en_US/sdk.js'
script.onerror = () => reject(new Error('Meta sign-in could not be loaded. Check that this browser can reach Facebook.'))
document.body.appendChild(script)
})
}
function statusLabel(status: string) {
return {
connected: 'Connected',
provisioning: 'Configuring',
incomplete: 'Needs verification',
failed: 'Failed',
disconnected: 'Disconnected',
expired: 'Authorization expired',
pending: 'Pending',
}[status] || status
}
function capabilityLabel(status: string) {
if (status === 'active') return 'Active'
if (status === 'unavailable') return 'Unavailable'
return statusLabel(status)
}
function formatWhen(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function parseSignupMessage(raw: unknown): SignupSession | null {
let data = raw
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch {
return null
}
}
if (!data || typeof data !== 'object') return null
const message = data as { type?: string; event?: string; data?: Record<string, unknown> }
if (message.type !== 'WA_EMBEDDED_SIGNUP' || !message.event) return null
const body = message.data || {}
return {
event: message.event,
wabaId: typeof body.waba_id === 'string' ? body.waba_id : undefined,
phoneNumberId: typeof body.phone_number_id === 'string' ? body.phone_number_id : undefined,
businessId: typeof body.business_id === 'string' ? body.business_id : undefined,
errorMessage: typeof body.error_message === 'string' ? body.error_message : undefined,
currentStep: typeof body.current_step === 'string' ? body.current_step : undefined,
}
}
export function WhatsAppSetupPage() {
const [setup, setSetup] = useState<WhatsAppSetup | null>(null)
const [events, setEvents] = useState<WhatsAppWebhookEvent[]>([])
const [loading, setLoading] = useState(true)
const [phase, setPhase] = useState<Phase>('loading')
const [config, setConfig] = useState<WhatsAppSignupConfig | null>(null)
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [tab, setTab] = useState<DetailTab>('overview')
const [result, setResult] = useState<WhatsAppSignupResult | null>(null)
const [error, setError] = useState('')
const [copied, setCopied] = useState<string | null>(null)
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [menuId, setMenuId] = useState<string | null>(null)
const [disconnecting, setDisconnecting] = useState<WhatsAppAccount | null>(null)
const [templates, setTemplates] = useState<WhatsAppTemplateRow[] | null>(null)
const [templatesAvailable, setTemplatesAvailable] = useState(false)
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
const [copied, setCopied] = useState(false)
const sessionRef = useRef<SignupSession | null>(null)
const codeRef = useRef<string | null>(null)
const finishingRef = useRef(false)
const accountsRef = useRef<WhatsAppAccount[]>([])
const reportRef = useRef<(session: SignupSession, code: string | null) => Promise<void>>(async () => {})
const finishRef = useRef<() => Promise<void>>(async () => {})
const selected = accounts.find((account) => account.id === selectedId) || null
accountsRef.current = accounts
useEffect(() => {
Promise.all([whatsappApi.setup(), whatsappApi.events()])
.then(([setupPayload, eventPayload]) => {
setSetup(setupPayload)
setEvents(eventPayload)
Promise.all([whatsappApi.signupConfig(), whatsappApi.accounts()])
.then(([signupConfig, rows]) => {
setConfig(signupConfig)
setAccounts(rows)
setPhase(rows.length ? 'list' : 'landing')
})
.catch((reason) => {
setError(reason instanceof Error ? reason.message : 'Unable to load WhatsApp')
setPhase('landing')
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load WhatsApp setup'))
.finally(() => setLoading(false))
}, [])
async function copyValue(key: string, value: string) {
await navigator.clipboard.writeText(value)
setCopied(key)
window.setTimeout(() => setCopied(null), 2000)
useEffect(() => {
function onMessage(event: MessageEvent) {
let host = ''
try {
host = new URL(event.origin).hostname
} catch {
return
}
if (host !== 'facebook.com' && !host.endsWith('.facebook.com')) return
const session = parseSignupMessage(event.data)
if (!session) return
sessionRef.current = session
if (session.event === 'CANCEL' || session.event === 'ERROR') {
void reportRef.current(session, null)
return
}
void finishRef.current()
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [])
useEffect(() => {
if (phase !== 'detail' || !selected) return
if (tab === 'templates') {
setTemplates(null)
whatsappApi.templates(selected.id)
.then((payload) => {
setTemplatesAvailable(payload.available)
setTemplates(payload.templates)
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load templates'))
}
if (tab === 'configuration') {
setDeveloper(null)
whatsappApi.developer(selected.id)
.then(setDeveloper)
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load configuration'))
}
if (tab === 'logs') {
setLogs(null)
whatsappApi.logs(selected.id)
.then(setLogs)
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load logs'))
}
}, [phase, tab, selected?.id])
function replaceAccount(next: WhatsAppAccount) {
setAccounts((current) => current.map((account) => (account.id === next.id ? next : account)))
}
if (loading) return <p className="explorer-loading">Loading WhatsApp setup…</p>
async function reportSession(session: SignupSession, code: string | null) {
if (finishingRef.current) return
finishingRef.current = true
const terminal = session.event === 'CANCEL' || session.event === 'ERROR'
if (!terminal) setPhase('provisioning')
try {
const payload = await whatsappApi.completeSignup({
event: session.event,
code,
waba_id: session.wabaId,
phone_number_id: session.phoneNumberId,
business_id: session.businessId,
error_message: session.errorMessage || session.currentStep,
})
setResult(payload)
if (payload.accounts.length) {
setAccounts((current) => {
const incoming = new Map(payload.accounts.map((account) => [account.id, account]))
const merged = current.map((account) => incoming.get(account.id) || account)
for (const account of payload.accounts) {
if (!merged.some((item) => item.id === account.id)) merged.unshift(account)
}
return merged
})
}
if (terminal) {
setNotice(payload.message)
setPhase(accountsRef.current.length || payload.accounts.length ? 'list' : 'landing')
} else {
setPhase('result')
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'WhatsApp setup could not be completed')
setPhase(accountsRef.current.length ? 'list' : 'landing')
} finally {
finishingRef.current = false
codeRef.current = null
sessionRef.current = null
}
}
reportRef.current = reportSession
finishRef.current = finishIfReady
async function finishIfReady() {
const session = sessionRef.current
const code = codeRef.current
if (!session || !code || finishingRef.current) return
if (!session.event.startsWith('FINISH')) return
await reportSession(session, code)
}
async function startEmbeddedSignup() {
setError('')
setNotice('')
setMenuId(null)
if (!config?.ready || !config.app_id || !config.config_id) {
setError('WhatsApp Embedded Signup is not configured yet. Add the Meta app id and Embedded Signup configuration id on the server.')
return
}
sessionRef.current = null
codeRef.current = null
finishingRef.current = false
setBusy(true)
try {
await loadFacebookSdk()
const sdk = facebookSdk()
if (!sdk) throw new Error('Meta sign-in did not initialize')
sdk.init({
appId: config.app_id,
cookie: true,
xfbml: false,
version: config.api_version,
})
sdk.login(
(response) => {
setBusy(false)
const code = response.authResponse?.code
if (!code) {
const session = sessionRef.current
if (session && (session.event === 'CANCEL' || session.event === 'ERROR')) return
setNotice('WhatsApp setup was closed before Meta returned an authorization. Nothing was connected.')
setPhase(accounts.length ? 'list' : 'landing')
return
}
codeRef.current = code
void finishIfReady()
window.setTimeout(() => {
if (codeRef.current !== code || finishingRef.current) return
if (!sessionRef.current) sessionRef.current = { event: 'FINISH' }
if (sessionRef.current.event.startsWith('FINISH')) void finishIfReady()
}, 1500)
},
{
config_id: config.config_id,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
sessionInfoVersion: '3',
},
},
)
} catch (reason) {
setBusy(false)
setError(reason instanceof Error ? reason.message : 'Unable to open Meta Embedded Signup')
}
}
async function refreshSelected() {
if (!selected) return
setBusy(true)
setError('')
try {
replaceAccount(await whatsappApi.refreshAccount(selected.id))
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to refresh this connection')
} finally {
setBusy(false)
}
}
async function confirmDisconnect() {
if (!disconnecting) return
setBusy(true)
setError('')
try {
const next = await whatsappApi.disconnectAccount(disconnecting.id)
replaceAccount(next)
setDisconnecting(null)
setNotice('This WhatsApp account was disconnected. Existing Urmi records were kept. Reconnect to authorize it again.')
if (selectedId === next.id) setPhase('list')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to disconnect this account')
} finally {
setBusy(false)
}
}
async function copyWebhook(url: string) {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
const resultAccount = result?.accounts[0]
return (
<div className="whatsapp-setup-page">
<div className="account-heading">
<span className="eyebrow">Communicate</span>
<h1>WhatsApp</h1>
<p>Configure Meta webhooks so Urmi can receive messages and delivery updates on behalf of your customers.</p>
</div>
<div className="wa-biz">
{phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>}
{phase !== 'loading' && phase !== 'detail' && (
<div className="account-heading">
<div className="wa-biz-heading">
<div>
<span className="eyebrow">Communicate</span>
<h1>WhatsApp Business</h1>
<p>
{accounts.length
? 'Manage WhatsApp Business accounts and phone numbers connected to this Urmi workspace.'
: 'Connect and manage WhatsApp Business accounts with Urmi.'}
</p>
</div>
{accounts.length > 0 && phase !== 'provisioning' && (
<button className="primary-button" type="button" onClick={() => void startEmbeddedSignup()} disabled={busy}>
<Plus size={16} /> Connect account
</button>
)}
</div>
</div>
)}
{error && <p className="auth-error">{error}</p>}
{notice && <p className="wa-biz-notice">{notice}</p>}
{setup && (
<>
<section className="panel whatsapp-panel">
<div className="panel-heading">
<div>
<h2><MessageCircle size={18} /> Meta webhook configuration</h2>
<p>Paste these into <strong>App Dashboard → WhatsApp → Configuration → Configure Webhooks</strong></p>
</div>
<a className="ghost-button" href={setup.docs_url} target="_blank" rel="noreferrer">
Meta docs <ExternalLink size={14} />
</a>
</div>
<div className="whatsapp-config-row">
<div>
<span>Callback URL</span>
<code>{setup.webhook_url}</code>
</div>
<button className="secondary-button" onClick={() => copyValue('url', setup.webhook_url)}>
<Copy size={14} /> {copied === 'url' ? 'Copied' : 'Copy'}
</button>
</div>
<div className="whatsapp-config-row">
<div>
<span>Verify token</span>
<code>{setup.verify_token || 'Not configured'}</code>
<small>Paste this exact value into Meta → Configure Webhooks → Verify token.</small>
</div>
{setup.verify_token && (
<button className="secondary-button" onClick={() => copyValue('token', setup.verify_token!)}>
<Copy size={14} /> {copied === 'token' ? 'Copied' : 'Copy'}
</button>
)}
</div>
<div className="whatsapp-badges">
<ConfigBadge ok={setup.verify_token_configured} label="Verify token" />
<ConfigBadge ok={setup.app_secret_configured} label="App secret" />
<ConfigBadge ok={setup.access_token_configured} label="Access token" />
<ConfigBadge ok={Boolean(setup.phone_number_id)} label="Phone number ID" />
<ConfigBadge ok={Boolean(setup.business_account_id)} label="WABA ID" />
</div>
</section>
<section className="panel whatsapp-panel">
<div className="panel-heading">
<div>
<h2>Subscribe to webhook fields</h2>
<p>In Meta, subscribe to these fields after verifying the callback URL.</p>
</div>
</div>
<ul className="whatsapp-field-list">
{setup.recommended_fields.map((field) => (
<li key={field}><Check size={14} /> <code>{field}</code></li>
{phase === 'landing' && (
<div className="wa-biz-hero">
<section className="wa-biz-card">
<div className="wa-biz-mark"><MessageCircle size={22} /></div>
<h2>Set up WhatsApp with Meta</h2>
<p>Set up WhatsApp Business Platform through Meta&apos;s secure onboarding process.</p>
<ul>
{STEPS.map((step) => (
<li key={step}><Check size={16} /> {step}</li>
))}
</ul>
<p className="relay-note">
Minimum for messaging: <code>messages</code>. For a tech provider / BSP, also enable template and account fields.
</p>
<button className="primary-button wa-biz-cta" onClick={() => void startEmbeddedSignup()} disabled={busy || !config?.ready}>
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
Set up WhatsApp with Meta
</button>
<small>
{config?.ready
? 'Setup usually takes a few minutes. Meta decides which businesses, accounts, and numbers you can use.'
: 'Embedded Signup is not available until the Meta configuration id is set for this environment.'}
</small>
</section>
<aside className="wa-biz-aside">
<h3>Why connect?</h3>
{WHY.map(([title, body]) => (
<div key={title}>
<strong>{title}</strong>
<p>{body}</p>
</div>
))}
</aside>
</div>
)}
<section className="panel whatsapp-panel">
<div className="panel-heading">
<div>
<h2>Recent webhook events</h2>
<p>Incoming payloads from Meta (after verification succeeds).</p>
</div>
</div>
{events.length === 0 ? (
<p className="analytics-empty">No webhook events yet. Click <strong>Verify and save</strong> in Meta to send a test.</p>
) : (
<div className="logs-table">
<div className="logs-table-head logs-cols-5">
<span>Field</span>
<span>Type</span>
<span>From</span>
<span>Status</span>
<span>Time</span>
</div>
{events.map((event) => (
<div className="logs-table-row logs-cols-5" key={event.id}>
<span>{event.field}</span>
<span>{event.event_type}</span>
<span>{event.from_wa_id || '—'}</span>
<span>{event.status || '—'}</span>
<span>{new Date(event.created_at).toLocaleString()}</span>
</div>
))}
</div>
{phase === 'provisioning' && (
<section className="wa-biz-card wa-biz-provision">
<div className="wa-biz-mark ok"><Check size={18} /></div>
<h2>Meta authorization completed</h2>
<p>We&apos;re configuring your WhatsApp Business connection. This screen updates from the setup result, not from a placeholder status.</p>
<div className="wa-biz-wait"><Loader2 size={18} className="wa-spin" /> Checking the business, account, and phone Meta returned</div>
</section>
)}
{phase === 'result' && result && (
<section className="wa-biz-card wa-biz-result">
<div className={`wa-biz-mark ${result.status === 'connected' ? 'ok' : 'warn'}`}>
{result.status === 'connected' ? <Check size={18} /> : <MessageCircle size={18} />}
</div>
<h2>{result.status === 'connected' ? 'WhatsApp Business' : 'Setup needs attention'}</h2>
<p>{result.message}</p>
{resultAccount && (
<dl className="wa-biz-facts">
<div><dt>Business</dt><dd>{resultAccount.business_name || '—'}</dd></div>
<div><dt>WhatsApp Business Account</dt><dd>{resultAccount.name}</dd></div>
<div><dt>Phone number</dt><dd>{resultAccount.primary_phone?.display_phone || 'Not returned yet'}</dd></div>
<div><dt>Display name</dt><dd>{resultAccount.primary_phone?.verified_name || '—'}</dd></div>
<div><dt>Status</dt><dd><StatusDot status={resultAccount.status} /></dd></div>
<div><dt>Messaging</dt><dd><StatusDot status={resultAccount.messaging_status} label={capabilityLabel(resultAccount.messaging_status)} /></dd></div>
<div><dt>Management</dt><dd><StatusDot status={resultAccount.management_status} label={capabilityLabel(resultAccount.management_status)} /></dd></div>
<div><dt>Webhooks</dt><dd><StatusDot status={resultAccount.webhooks_status} label={capabilityLabel(resultAccount.webhooks_status)} /></dd></div>
</dl>
)}
<div className="wa-biz-actions">
{resultAccount && (
<button className="primary-button" onClick={() => { setSelectedId(resultAccount.id); setTab('overview'); setPhase('detail') }}>
{resultAccount.usable ? 'Open WhatsApp' : 'View account'}
</button>
)}
</section>
</>
<button className="secondary-button" onClick={() => setPhase(accounts.length ? 'list' : 'landing')}>Back to accounts</button>
</div>
</section>
)}
{phase === 'list' && (
<div className="wa-biz-list">
{accounts.map((account) => (
<article key={account.id} className="wa-biz-account">
<header>
<div>
<h2>{account.business_name || account.name}</h2>
<StatusDot status={account.status} />
</div>
<div className="wa-biz-menu">
<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} />
</button>
{menuId === account.id && (
<div className="wa-biz-popover">
<button onClick={() => { setMenuId(null); void startEmbeddedSignup() }}>Reconnect</button>
<button onClick={() => { setMenuId(null); setDisconnecting(account) }}>Disconnect</button>
</div>
)}
</div>
</header>
<div className="wa-biz-account-grid">
<div>
<span>Phone</span>
<strong>{account.primary_phone?.display_phone || 'No number yet'}</strong>
<small>{account.primary_phone?.verified_name || account.name}</small>
</div>
<div>
<span>WABA</span>
<strong>{account.waba_id_masked || '—'}</strong>
</div>
<div>
<span>Messaging</span>
<strong>{capabilityLabel(account.messaging_status)}</strong>
</div>
<div>
<span>Templates</span>
<strong>{account.templates_status === 'active' ? account.template_count : '—'}</strong>
</div>
<div>
<span>Quality</span>
<strong>{account.quality || '—'}</strong>
</div>
</div>
{account.last_error && account.status !== 'connected' && <p className="wa-biz-inline-error">{account.last_error}</p>}
</article>
))}
</div>
)}
{phase === 'detail' && selected && (
<div className="wa-biz-detail">
<button className="wa-biz-back" onClick={() => setPhase('list')}><ChevronLeft size={16} /> Accounts</button>
<div className="wa-biz-detail-head">
<div>
<h1>{selected.business_name || selected.name}</h1>
<StatusDot status={selected.status} />
</div>
<div className="wa-biz-actions">
<button className="secondary-button" onClick={() => void refreshSelected()} disabled={busy}>
<RefreshCw size={14} /> Refresh
</button>
<button className="secondary-button" onClick={() => void startEmbeddedSignup()} disabled={busy}>Reconnect</button>
<button className="secondary-button wa-biz-danger" onClick={() => setDisconnecting(selected)}>Disconnect</button>
</div>
</div>
<div className="wa-biz-tabs">
{(['overview', 'numbers', 'templates', 'configuration', 'logs'] as DetailTab[]).map((item) => (
<button key={item} className={tab === item ? 'active' : ''} onClick={() => setTab(item)}>
{item === 'numbers' ? 'Phone Numbers' : item === 'configuration' ? 'Configuration' : item[0].toUpperCase() + item.slice(1)}
</button>
))}
</div>
{tab === 'overview' && (
<div className="wa-biz-overview">
<section className="wa-biz-card">
<h2>Overview</h2>
<dl className="wa-biz-facts">
<div><dt>Business portfolio</dt><dd>{selected.business_name || '—'}</dd></div>
<div><dt>WhatsApp Business Account</dt><dd>{selected.name}</dd></div>
<div><dt>WABA</dt><dd>{selected.waba_id_masked}</dd></div>
<div><dt>Connection</dt><dd><StatusDot status={selected.status === 'connected' ? 'connected' : selected.status} label={selected.status === 'connected' ? 'Healthy' : statusLabel(selected.status)} /></dd></div>
</dl>
{selected.last_error && <p className="wa-biz-inline-error">{selected.last_error}</p>}
</section>
<section className="wa-biz-card">
<h2>Capabilities</h2>
<ul className="wa-biz-capabilities">
<li>Messaging <StatusDot status={selected.messaging_status} label={capabilityLabel(selected.messaging_status)} /></li>
<li>Business management <StatusDot status={selected.management_status} label={capabilityLabel(selected.management_status)} /></li>
<li>Templates <StatusDot status={selected.templates_status} label={capabilityLabel(selected.templates_status)} /></li>
<li>Webhooks <StatusDot status={selected.webhooks_status} label={capabilityLabel(selected.webhooks_status)} /></li>
</ul>
{!selected.usable && (
<p className="wa-biz-inline-error">Messaging stays unavailable until Urmi can verify a connected phone on this account.</p>
)}
</section>
</div>
)}
{tab === 'numbers' && (
<section className="wa-biz-card">
<h2>Phone numbers</h2>
{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) => (
<li key={phone.id}>
<div>
<strong>{phone.display_phone || 'Number pending'}</strong>
<small>{phone.verified_name || 'Display name pending'}</small>
</div>
<StatusDot status={phone.status} />
<span>Quality: {phone.quality || '—'}</span>
</li>
))}
</ul>
</section>
)}
{tab === 'templates' && (
<section className="wa-biz-card">
<h2>Templates</h2>
{templates === null && <p className="wa-biz-muted">Loading templates…</p>}
{templates && !templatesAvailable && <p className="wa-biz-muted">Template access is unavailable until this account is authorized.</p>}
{templates && templatesAvailable && templates.length === 0 && <p className="wa-biz-muted">No templates were returned for this WhatsApp Business Account.</p>}
{templates && templates.length > 0 && (
<div className="wa-biz-table">
<div className="wa-biz-table-head"><span>Name</span><span>Category</span><span>Language</span><span>Status</span><span>Quality</span></div>
{templates.map((template) => (
<div key={`${template.name}-${template.language}`} className="wa-biz-table-row">
<span>{template.name}</span>
<span>{template.category}</span>
<span>{template.language}</span>
<span>{template.status}</span>
<span>{template.quality || '—'}</span>
</div>
))}
</div>
)}
</section>
)}
{tab === 'configuration' && (
<section className="wa-biz-card">
<div className="wa-biz-card-head">
<div>
<h2>Developer configuration</h2>
<p>Webhook delivery for this workspace. Verification credentials stay on Urmi&apos;s servers.</p>
</div>
{developer && (
<a className="ghost-button" href={developer.docs_url} target="_blank" rel="noreferrer">
Meta docs <ExternalLink size={14} />
</a>
)}
</div>
{!developer && <p className="wa-biz-muted">Loading configuration…</p>}
{developer && (
<>
<div className="whatsapp-config-row">
<div>
<span>Callback URL</span>
<code>{developer.webhook_url}</code>
</div>
<button className="secondary-button" onClick={() => void copyWebhook(developer.webhook_url)}>
<Copy size={14} /> {copied ? 'Copied' : 'Copy'}
</button>
</div>
<div className="whatsapp-config-row">
<div>
<span>Verify token</span>
<code>{developer.verify_token_configured ? 'Hidden — managed by Urmi' : 'Not configured'}</code>
<small>The verify token is not shown in the customer console.</small>
</div>
</div>
<p className="wa-biz-subhead">Webhook subscriptions</p>
<ul className="whatsapp-field-list">
{developer.recommended_fields.map((field) => (
<li key={field}><Check size={14} /> {field}</li>
))}
</ul>
<p className="wa-biz-subhead">Webhook status <StatusDot status={developer.webhooks_status} label={capabilityLabel(developer.webhooks_status)} /></p>
</>
)}
</section>
)}
{tab === 'logs' && (
<div className="wa-biz-overview">
<section className="wa-biz-card">
<h2>Connection history</h2>
{logs === null && <p className="wa-biz-muted">Loading logs…</p>}
{logs && logs.audits.length === 0 && <p className="wa-biz-muted">No connection events yet.</p>}
<ul className="wa-biz-log">
{logs?.audits.map((item) => (
<li key={item.id}>
<strong>{item.event.replace(/_/g, ' ')}</strong>
<span>{item.detail}</span>
<small>{formatWhen(item.created_at)}</small>
</li>
))}
</ul>
</section>
<section className="wa-biz-card">
<h2>Recent webhook events</h2>
{logs && logs.events.length === 0 && <p className="wa-biz-muted">No webhook events for this account.</p>}
<ul className="wa-biz-log">
{logs?.events.map((item) => (
<li key={item.id}>
<strong>{item.field}</strong>
<span>{item.event_type}{item.status ? ` · ${item.status}` : ''}</span>
<small>{formatWhen(item.created_at)}</small>
</li>
))}
</ul>
</section>
</div>
)}
</div>
)}
{disconnecting && (
<div className="modal-overlay" onClick={() => setDisconnecting(null)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h2>Disconnect {disconnecting.business_name || disconnecting.name}?</h2>
</div>
<p className="wa-biz-modal-copy">
Urmi will stop using this WhatsApp Business Account and clear its stored authorization.
The account record stays in this workspace so you can reconnect without starting over.
</p>
<div className="modal-actions">
<button className="secondary-button" onClick={() => setDisconnecting(null)}>Cancel</button>
<button className="primary-button wa-biz-danger-fill" onClick={() => void confirmDisconnect()} disabled={busy}>Disconnect</button>
</div>
</div>
</div>
)}
</div>
)
}
function StatusDot({ status, label }: { status: string; label?: string }) {
const tone = status === 'connected' || status === 'active' ? 'ok' : status === 'unavailable' || status === 'failed' || status === 'expired' || status === 'disconnected' ? 'bad' : 'wait'
return (
<span className={`wa-biz-status ${tone}`}>
<i /> {label || statusLabel(status)}
</span>
)
}

View file

@ -1819,3 +1819,83 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-list { max-height: 280px; }
.wa-setup form { grid-template-columns: 1fr; }
}
.wa-biz { max-width: 1080px; }
.wa-biz-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.wa-biz-heading h1 { margin: 6px 0; }
.wa-biz-heading p { max-width: 640px; }
.wa-biz-notice { margin: 0 0 16px; padding: 12px 14px; border: 1px solid #d7e6ff; border-radius: 12px; background: #f4f8ff; color: #1d4f91; font-size: 13px; }
.wa-biz-hero { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, .8fr); gap: 18px; }
.wa-biz-card { padding: 28px; border: 1px solid #e6ebf2; border-radius: 18px; background: #fff; box-shadow: 0 8px 28px rgba(16, 34, 63, .04); }
.wa-biz-card h2 { margin: 14px 0 8px; color: #14233b; font-size: 22px; letter-spacing: -.03em; }
.wa-biz-card > p, .wa-biz-muted { color: #6d7c90; font-size: 13px; line-height: 1.5; }
.wa-biz-mark { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; background: #e8f8ef; color: #12805c; }
.wa-biz-mark.ok { background: #e8f8ef; color: #12805c; }
.wa-biz-mark.warn { background: #fff6e8; color: #a56d12; }
.wa-biz-card ul { margin: 18px 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.wa-biz-card ul li { display: flex; align-items: center; gap: 8px; color: #24344c; font-size: 14px; }
.wa-biz-card ul li svg { color: #159768; }
.wa-biz-cta { width: 100%; margin-top: 8px; padding: 12px 16px; font-size: 14px; }
.wa-biz-card > small { display: block; margin-top: 12px; color: #8a95a7; font-size: 12px; }
.wa-biz-aside { padding: 24px; border-radius: 18px; background: #f4f7fb; }
.wa-biz-aside h3 { margin: 0 0 16px; color: #14233b; font-size: 16px; }
.wa-biz-aside div + div { margin-top: 16px; }
.wa-biz-aside strong { display: block; color: #1c2b42; font-size: 13px; }
.wa-biz-aside p { margin: 4px 0 0; color: #6d7c90; font-size: 12px; line-height: 1.45; }
.wa-biz-provision, .wa-biz-result { max-width: 720px; }
.wa-biz-wait { display: flex; align-items: center; gap: 8px; margin-top: 18px; color: #1d4f91; font-size: 13px; font-weight: 600; }
.wa-spin { animation: wa-spin .8s linear infinite; }
@keyframes wa-spin { to { transform: rotate(360deg); } }
.wa-biz-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 24px; margin: 18px 0 0; }
.wa-biz-facts div { display: grid; gap: 4px; }
.wa-biz-facts dt { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.wa-biz-facts dd { margin: 0; color: #1c2b42; font-size: 14px; }
.wa-biz-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.wa-biz-list { display: grid; gap: 14px; }
.wa-biz-account { padding: 20px 22px; border: 1px solid #e6ebf2; border-radius: 16px; background: #fff; }
.wa-biz-account header, .wa-biz-detail-head, .wa-biz-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.wa-biz-account h2, .wa-biz-detail h1 { margin: 0 0 6px; color: #14233b; font-size: 18px; letter-spacing: -.03em; }
.wa-biz-account-grid { display: grid; grid-template-columns: 1.4fr repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
.wa-biz-account-grid span, .wa-biz-numbers span { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.wa-biz-account-grid strong, .wa-biz-numbers strong { display: block; margin-top: 4px; color: #1c2b42; font-size: 14px; }
.wa-biz-account-grid small, .wa-biz-numbers small { display: block; color: #6d7c90; font-size: 12px; }
.wa-biz-menu { position: relative; display: flex; gap: 8px; }
.wa-biz-popover { position: absolute; top: 40px; right: 0; z-index: 5; display: grid; min-width: 160px; padding: 6px; border: 1px solid #e6ebf2; border-radius: 12px; background: #fff; box-shadow: 0 12px 32px rgba(16, 34, 63, .12); }
.wa-biz-popover button { border: 0; background: transparent; padding: 8px 10px; border-radius: 8px; color: #24344c; font-size: 13px; text-align: left; }
.wa-biz-popover button:hover { background: #f4f7fb; }
.wa-biz-status { display: inline-flex; align-items: center; gap: 6px; color: #8a5b00; font-size: 12px; font-weight: 700; }
.wa-biz-status i { width: 8px; height: 8px; border-radius: 50%; background: #d78219; }
.wa-biz-status.ok { color: #0f7a49; }
.wa-biz-status.ok i { background: #12b76a; }
.wa-biz-status.bad { color: #b42318; }
.wa-biz-status.bad i { background: #f04438; }
.wa-biz-inline-error { margin: 12px 0 0; color: #b42318; font-size: 12px; }
.wa-biz-back { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 12px; border: 0; background: transparent; color: #4b5a70; font-size: 13px; font-weight: 600; }
.wa-biz-tabs { display: flex; gap: 8px; margin: 16px 0; border-bottom: 1px solid #e6ebf2; }
.wa-biz-tabs button { border: 0; background: transparent; padding: 10px 12px; color: #7d8ba0; font-size: 13px; font-weight: 700; }
.wa-biz-tabs button.active { color: #1475ff; box-shadow: inset 0 -2px 0 #1475ff; }
.wa-biz-overview { display: grid; grid-template-columns: 1.2fr .8fr; gap: 16px; }
.wa-biz-capabilities { margin: 0; }
.wa-biz-capabilities li { justify-content: space-between; }
.wa-biz-numbers { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.wa-biz-numbers li { display: grid; grid-template-columns: 1fr auto auto; gap: 16px; align-items: center; padding: 12px 0; border-top: 1px solid #edf0f4; }
.wa-biz-table { margin-top: 12px; }
.wa-biz-table-head, .wa-biz-table-row { display: grid; grid-template-columns: 1.4fr .8fr .7fr .8fr .6fr; gap: 8px; padding: 10px 0; }
.wa-biz-table-head { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.wa-biz-table-row { border-top: 1px solid #edf0f4; color: #24344c; font-size: 13px; }
.wa-biz-subhead { display: flex; align-items: center; gap: 8px; margin: 16px 0 8px; color: #607089; font-size: 12px; font-weight: 700; text-transform: uppercase; }
.wa-biz-log { margin: 0; padding: 0; list-style: none; display: grid; gap: 12px; }
.wa-biz-log li { display: grid; gap: 2px; padding-top: 10px; border-top: 1px solid #edf0f4; }
.wa-biz-log strong { color: #1c2b42; font-size: 13px; text-transform: capitalize; }
.wa-biz-log span { color: #4b5a70; font-size: 12px; }
.wa-biz-log small { color: #8a95a7; font-size: 11px; }
.wa-biz-danger { color: #b42318; }
.wa-biz-danger-fill { background: #d92d20; border-color: #d92d20; }
.wa-biz-modal-copy { margin: 0; padding: 16px 20px 0; color: #4b5a70; font-size: 13px; line-height: 1.5; }
.wa-biz .ghost-button { display: inline-flex; align-items: center; gap: 6px; color: #1475ff; font-size: 12px; font-weight: 700; text-decoration: none; }
@media (max-width: 900px) {
.wa-biz-hero, .wa-biz-overview, .wa-biz-facts, .wa-biz-account-grid { grid-template-columns: 1fr; }
.wa-biz-heading { flex-direction: column; align-items: stretch; }
.wa-biz-table-head { display: none; }
.wa-biz-table-row, .wa-biz-numbers li { grid-template-columns: 1fr; }
}

View file

@ -1115,18 +1115,78 @@ export const analyticsApi = {
export type WhatsAppSetup = {
webhook_url: string
verify_token: string | null
verify_token_configured: boolean
app_secret_configured: boolean
access_token_configured: boolean
phone_number_id: string | null
business_account_id: string | null
meta_app_id: string | null
verify_token_visible: boolean
api_version: string
recommended_fields: string[]
docs_url: string
}
export type WhatsAppSignupConfig = {
app_id: string | null
config_id: string | null
api_version: string
ready: boolean
}
export type WhatsAppPhoneAsset = {
id: string
phone_number_id: string
display_phone: string
verified_name: string | null
quality: string | null
status: string
code_verification_status: string | null
}
export type WhatsAppAccount = {
id: string
waba_id_masked: string | null
name: string
business_name: string | null
business_id_masked: string | null
status: string
usable: boolean
messaging_status: string
management_status: string
templates_status: string
webhooks_status: string
template_count: number
quality: string | null
primary_phone: WhatsAppPhoneAsset | null
phones: WhatsAppPhoneAsset[]
last_error: string | null
updated_at: string
}
export type WhatsAppSignupResult = {
status: string
message: string
accounts: WhatsAppAccount[]
}
export type WhatsAppTemplateRow = {
name: string | null
category: string | null
language: string | null
status: string | null
quality: string | null
}
export type WhatsAppDeveloperConfig = {
webhook_url: string
verify_token_configured: boolean
verify_token_visible: boolean
recommended_fields: string[]
webhooks_status: string
docs_url: string
}
export type WhatsAppAccountLogs = {
audits: { id: string; event: string; detail: string; created_at: string }[]
events: { id: string; field: string; event_type: string; from_wa_id: string | null; status: string | null; created_at: string }[]
}
export type WhatsAppWebhookEvent = {
id: string
waba_id: string | null
@ -1142,7 +1202,30 @@ export type WhatsAppWebhookEvent = {
export const whatsappApi = {
setup: () => jsonRequest<WhatsAppSetup>('/api/v1/whatsapp/setup'),
events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'),
sendText: (payload: { to: string; body: string }) =>
signupConfig: () => jsonRequest<WhatsAppSignupConfig>('/api/v1/whatsapp/signup/config'),
accounts: () => jsonRequest<WhatsAppAccount[]>('/api/v1/whatsapp/accounts'),
completeSignup: (payload: {
event: string
code?: string | null
waba_id?: string | null
phone_number_id?: string | null
business_id?: string | null
error_message?: string | null
}) =>
jsonRequest<WhatsAppSignupResult>('/api/v1/whatsapp/signup/complete', {
method: 'POST',
body: JSON.stringify(payload),
}),
account: (accountId: string) => jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}`),
refreshAccount: (accountId: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/refresh`, { method: 'POST' }),
disconnectAccount: (accountId: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/disconnect`, { method: 'POST' }),
templates: (accountId: string) =>
jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`),
logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`),
developer: (accountId: string) => jsonRequest<WhatsAppDeveloperConfig>(`/api/v1/whatsapp/accounts/${accountId}/developer`),
sendText: (payload: { to: string; body: string; phone_number_id: string }) =>
jsonRequest<Record<string, unknown>>('/api/v1/whatsapp/messages/send', {
method: 'POST',
body: JSON.stringify(payload),