meta embed flow
This commit is contained in:
parent
ae72b59878
commit
99ed8f2477
10 changed files with 2256 additions and 151 deletions
|
|
@ -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'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'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'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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue