privacy policy and business integration

This commit is contained in:
debu-2 2026-10-02 14:09:03 +05:30
parent 141ca9fda3
commit cd3751219d
16 changed files with 1592 additions and 30 deletions

View file

@ -10,6 +10,7 @@ import {
type AdminTemplateConfig,
type AdminUrmiOtpProject,
} from '../../lib/api'
import { AdminWhatsAppPage } from './AdminWhatsAppPage'
const KYC_DOC_LABELS: Record<string, string> = {
business_pan_card: 'Business PAN card',
@ -391,6 +392,7 @@ function AdminCompliance() {
export function AdminApp() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null)
const [section, setSection] = useState<'compliance' | 'whatsapp'>('whatsapp')
useEffect(() => {
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
@ -406,11 +408,33 @@ export function AdminApp() {
return (
<div className="admin-shell">
<header className="admin-topbar">
<div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin · Global compliance</strong></div>
{authenticated && <button className="secondary-button" onClick={logout}>Log out</button>}
<div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin portal</strong></div>
{authenticated && (
<div className="admin-topbar-actions">
<nav className="admin-main-nav">
<button
type="button"
className={section === 'whatsapp' ? 'active' : ''}
onClick={() => setSection('whatsapp')}
>
WhatsApp Business
</button>
<button
type="button"
className={section === 'compliance' ? 'active' : ''}
onClick={() => setSection('compliance')}
>
Compliance
</button>
</nav>
<button className="secondary-button" onClick={logout}>Log out</button>
</div>
)}
</header>
<main>
{authenticated ? <AdminCompliance /> : <AdminLogin onSuccess={() => setAuthenticated(true)} />}
{authenticated
? (section === 'whatsapp' ? <AdminWhatsAppPage /> : <AdminCompliance />)
: <AdminLogin onSuccess={() => setAuthenticated(true)} />}
</main>
</div>
)

View file

@ -0,0 +1,488 @@
import { MessageSquareText, RefreshCw, Settings2 } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { API_BASE_URL } from '../../lib/apiBase'
import {
adminApi,
type AdminWhatsAppConfig,
type AdminWhatsAppMessage,
type AdminWhatsAppSendPayload,
type AdminWhatsAppTemplate,
} from '../../lib/api'
type SendMode = 'text' | 'template' | 'media' | 'buttons' | 'reaction'
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
}
function mediaUrl(mediaId: string) {
return `${API_BASE_URL}/api/v1/admin/whatsapp/media/${mediaId}`
}
export function AdminWhatsAppPage() {
const [config, setConfig] = useState<AdminWhatsAppConfig | null>(null)
const [messages, setMessages] = useState<AdminWhatsAppMessage[]>([])
const [templates, setTemplates] = useState<AdminWhatsAppTemplate[]>([])
const [loading, setLoading] = useState(true)
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
const [newPeer, setNewPeer] = useState('')
const [sendMode, setSendMode] = useState<SendMode>('text')
const [showSetup, setShowSetup] = useState(false)
const [error, setError] = useState('')
const [sending, setSending] = useState(false)
const [savingConfig, setSavingConfig] = useState(false)
const [phoneNumberId, setPhoneNumberId] = useState('')
const [businessAccountId, setBusinessAccountId] = useState('')
const [appSecret, setAppSecret] = useState('')
const [textBody, setTextBody] = useState('')
const [replyTo, setReplyTo] = 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 [mediaFilename, setMediaFilename] = useState('')
const [buttonBody, setButtonBody] = useState('')
const [buttonOne, setButtonOne] = useState('')
const [buttonTwo, setButtonTwo] = useState('')
const [buttonThree, setButtonThree] = useState('')
const [reactionEmoji, setReactionEmoji] = useState('👍')
const [reactionTarget, setReactionTarget] = useState('')
const chatEndRef = useRef<HTMLDivElement | null>(null)
async function loadMessages(peer?: string | null) {
const items = await adminApi.listWhatsAppMessages(300, peer || undefined)
setMessages(items)
}
async function loadAll() {
const [cfg, items, templateResponse] = await Promise.all([
adminApi.whatsappConfig(),
adminApi.listWhatsAppMessages(300),
adminApi.listWhatsAppTemplates().catch(() => ({ data: [] as AdminWhatsAppTemplate[] })),
])
setConfig(cfg)
setMessages(items)
setTemplates(templateResponse.data ?? [])
setPhoneNumberId(cfg.phone_number_id ?? '')
setBusinessAccountId(cfg.business_account_id ?? '')
if (!cfg.ready_to_send) setShowSetup(true)
}
useEffect(() => {
loadAll().finally(() => setLoading(false))
const timer = window.setInterval(() => {
loadMessages(selectedPeer).catch(() => undefined)
}, 5000)
return () => window.clearInterval(timer)
}, [selectedPeer])
const threads = useMemo(() => {
const map = new Map<string, AdminWhatsAppMessage[]>()
for (const message of messages) {
const peer = message.direction === 'inbound' ? message.from : message.to
if (!peer || message.message_type === 'status') continue
const list = map.get(peer) ?? []
list.push(message)
map.set(peer, list)
}
return [...map.entries()]
.map(([peer, thread]) => {
const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at))
return { peer, thread: sorted, latest: sorted[sorted.length - 1] }
})
.sort((a, b) => (b.latest?.created_at ?? '').localeCompare(a.latest?.created_at ?? ''))
}, [messages])
const activeThread = useMemo(() => {
if (!selectedPeer) return []
const found = threads.find((item) => item.peer === selectedPeer)
return found?.thread ?? []
}, [threads, selectedPeer])
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [activeThread.length, selectedPeer])
async function saveConfig(event: FormEvent) {
event.preventDefault()
setSavingConfig(true)
setError('')
try {
const updated = await adminApi.updateWhatsAppConfig({
phone_number_id: phoneNumberId.trim(),
business_account_id: businessAccountId.trim(),
app_secret: appSecret.trim() || undefined,
})
setConfig(updated)
setShowSetup(false)
const templateResponse = await adminApi.listWhatsAppTemplates().catch(() => ({ data: [] as AdminWhatsAppTemplate[] }))
setTemplates(templateResponse.data ?? [])
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to save WhatsApp config')
} finally {
setSavingConfig(false)
}
}
async function markRead(message: AdminWhatsAppMessage) {
if (!message.wa_message_id || message.direction !== 'inbound') return
try {
await adminApi.markWhatsAppRead(message.wa_message_id)
} catch {
// ignore read receipt failures
}
}
async function sendMessage(event: FormEvent) {
event.preventDefault()
if (!selectedPeer && !newPeer.trim()) {
setError('Select a conversation or enter a recipient number')
return
}
setSending(true)
setError('')
const recipient = selectedPeer ?? newPeer.trim()
const payload: AdminWhatsAppSendPayload = { to: recipient, message_type: 'text' }
try {
if (sendMode === 'text') {
payload.message_type = 'text'
payload.text = textBody.trim()
payload.reply_to = replyTo.trim() || undefined
} else if (sendMode === 'template') {
payload.message_type = 'template'
payload.template_name = templateName
payload.template_language = templateLanguage
} else if (sendMode === 'media') {
payload.message_type = mediaType
payload.media_link = mediaLink.trim()
payload.media_caption = mediaCaption.trim() || undefined
payload.media_filename = mediaFilename.trim() || undefined
payload.reply_to = replyTo.trim() || undefined
} else if (sendMode === 'buttons') {
payload.message_type = 'interactive_buttons'
payload.interactive_body = buttonBody.trim()
payload.interactive_buttons = [
{ id: 'btn1', title: buttonOne.trim() },
...(buttonTwo.trim() ? [{ id: 'btn2', title: buttonTwo.trim() }] : []),
...(buttonThree.trim() ? [{ id: 'btn3', title: buttonThree.trim() }] : []),
]
payload.reply_to = replyTo.trim() || undefined
} else if (sendMode === 'reaction') {
payload.message_type = 'reaction'
payload.reaction_message_id = reactionTarget.trim()
payload.reaction_emoji = reactionEmoji.trim()
}
const sent = await adminApi.sendWhatsAppMessage(payload)
if (!selectedPeer) {
const peer = sent.to
setSelectedPeer(peer)
setNewPeer('')
}
await loadMessages(selectedPeer ?? sent.to)
setTextBody('')
setMediaCaption('')
setButtonBody('')
setReplyTo('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to send message')
} finally {
setSending(false)
}
}
if (loading) return <div className="auth-loading">Loading WhatsApp Business…</div>
const readyToSend = Boolean(config?.ready_to_send)
return (
<div className="admin-content admin-whatsapp-page">
<div className="account-heading admin-whatsapp-heading">
<div>
<span className="eyebrow">Urmi business</span>
<h1><MessageSquareText size={24} /> WhatsApp Business</h1>
<p>
Send and receive on {config ? formatWaNumber(config.business_phone) : '+91 94112 57916'}.
Webhook: {config?.webhook_url ?? '—'}
</p>
</div>
<div className="admin-whatsapp-heading-actions">
<span className={`doc-status ${readyToSend ? 'approved' : 'pending'}`}>
{readyToSend ? 'Connected' : 'Setup required'}
</span>
<button type="button" className="secondary-button" onClick={() => setShowSetup((value) => !value)}>
<Settings2 size={15} /> Meta setup
</button>
<button type="button" className="secondary-button" onClick={() => loadAll()}>
<RefreshCw size={15} /> Refresh
</button>
</div>
</div>
{showSetup && (
<section className="panel admin-whatsapp-setup">
<div className="panel-heading">
<div>
<h2>Meta connection</h2>
<p>Paste IDs from Meta → WhatsApp → API Setup. Saved in admin without restarting the server.</p>
</div>
</div>
<form className="admin-whatsapp-setup-form" onSubmit={saveConfig}>
<label>
<span>Phone number ID</span>
<input value={phoneNumberId} onChange={(e) => setPhoneNumberId(e.target.value)} placeholder="106540352242922" />
</label>
<label>
<span>WhatsApp Business Account ID</span>
<input value={businessAccountId} onChange={(e) => setBusinessAccountId(e.target.value)} placeholder="WABA ID" />
</label>
<label>
<span>App secret (for webhook signature validation)</span>
<input value={appSecret} onChange={(e) => setAppSecret(e.target.value)} placeholder="From Meta App Dashboard → Basic" />
</label>
<div className="admin-whatsapp-setup-meta">
<p>Verify token: {config?.verify_token_configured ? 'Configured in server env' : 'Missing in env'}</p>
<p>Access token: {config?.access_token_configured ? 'Configured in server env' : 'Missing in env'}</p>
<p>Meta App ID: {config?.meta_app_id ?? '—'}</p>
</div>
<button className="primary-button" type="submit" disabled={savingConfig}>
{savingConfig ? 'Saving…' : 'Save Meta settings'}
</button>
</form>
</section>
)}
{!readyToSend && (
<p className="admin-whatsapp-hint">
Add your <strong>Phone number ID</strong> in Meta setup above to enable sending.
Inbound messages still arrive once Meta webhook is subscribed at {config?.webhook_url}.
</p>
)}
<div className="admin-whatsapp-messenger">
<aside className="panel admin-whatsapp-sidebar">
<div className="panel-heading">
<div>
<h2>Conversations</h2>
<p>{threads.length} active threads</p>
</div>
</div>
{threads.length === 0 ? (
<p className="admin-whatsapp-empty">No messages yet. Ask someone to message +91 94112 57916 or scan your Meta QR.</p>
) : (
<div className="admin-whatsapp-thread-list compact">
{threads.map(({ peer, latest, thread }) => (
<button
key={peer}
type="button"
className={`admin-whatsapp-thread-item ${selectedPeer === peer ? 'active' : ''}`}
onClick={() => {
setSelectedPeer(peer)
const lastInbound = [...thread].reverse().find((item) => item.direction === 'inbound')
if (lastInbound) markRead(lastInbound)
}}
>
<strong>{formatWaNumber(peer)}</strong>
<span>{latest.body || `[${latest.message_type}]`}</span>
<small>{new Date(latest.created_at).toLocaleString()}</small>
</button>
))}
</div>
)}
</aside>
<section className="panel admin-whatsapp-chat">
{!selectedPeer ? (
<div className="admin-whatsapp-chat-empty">
<p>Select a conversation to view messages and reply.</p>
</div>
) : (
<>
<div className="admin-whatsapp-chat-head">
<strong>{formatWaNumber(selectedPeer)}</strong>
<small>{activeThread.length} messages</small>
</div>
<div className="admin-whatsapp-chat-body">
{activeThread.map((item) => (
<div
key={item.id}
className={`admin-whatsapp-bubble ${item.direction === 'inbound' ? 'inbound' : 'outbound'}`}
>
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && (
<img src={mediaUrl(item.media.id)} alt={item.message_type} className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'video' && (
<video src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'audio' && (
<audio src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'document' && (
<a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Download document</a>
)}
<span>{item.body || `[${item.message_type} · ${item.status}]`}</span>
<small>
{new Date(item.created_at).toLocaleTimeString()}
{item.status ? ` · ${item.status}` : ''}
{item.wa_message_id ? ` · ${item.wa_message_id.slice(0, 12)}…` : ''}
</small>
{item.direction === 'inbound' && item.wa_message_id && (
<button
type="button"
className="link-button"
onClick={() => {
setSendMode('reaction')
setReactionTarget(item.wa_message_id || '')
}}
>
React
</button>
)}
</div>
))}
<div ref={chatEndRef} />
</div>
</>
)}
</section>
<section className="panel admin-whatsapp-compose-panel">
<div className="panel-heading">
<div>
<h2>Send</h2>
<p>Supported: text, template, media, buttons, reaction, read receipts.</p>
</div>
</div>
<div className="admin-whatsapp-mode-tabs">
{(['text', 'template', 'media', 'buttons', 'reaction'] as SendMode[]).map((mode) => (
<button
key={mode}
type="button"
className={sendMode === mode ? 'active' : ''}
onClick={() => setSendMode(mode)}
>
{mode}
</button>
))}
</div>
<form className="admin-whatsapp-form" onSubmit={sendMessage}>
{!selectedPeer && (
<label>
<span>New recipient</span>
<input
value={newPeer}
onChange={(e) => setNewPeer(e.target.value)}
placeholder="9411257916 or +91 94112 57916"
/>
</label>
)}
{sendMode === 'text' && (
<>
<label>
<span>Message</span>
<textarea value={textBody} onChange={(e) => setTextBody(e.target.value)} rows={5} required />
</label>
<label>
<span>Reply to message ID (optional)</span>
<input value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="wamid…" />
</label>
</>
)}
{sendMode === 'template' && (
<>
<label>
<span>Approved template</span>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)} required>
<option value="">Select template</option>
{templates.map((template) => (
<option key={template.id ?? template.name} value={template.name}>
{template.name} ({template.language})
</option>
))}
</select>
</label>
<label>
<span>Language code</span>
<input value={templateLanguage} onChange={(e) => setTemplateLanguage(e.target.value)} />
</label>
<small>Templates work outside the 24-hour customer service window.</small>
</>
)}
{sendMode === 'media' && (
<>
<label>
<span>Media type</span>
<select value={mediaType} onChange={(e) => setMediaType(e.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>
</label>
<label>
<span>Public HTTPS URL</span>
<input value={mediaLink} onChange={(e) => setMediaLink(e.target.value)} placeholder="https://…" required />
</label>
<label>
<span>Caption</span>
<input value={mediaCaption} onChange={(e) => setMediaCaption(e.target.value)} />
</label>
{mediaType === 'document' && (
<label>
<span>Filename</span>
<input value={mediaFilename} onChange={(e) => setMediaFilename(e.target.value)} />
</label>
)}
</>
)}
{sendMode === 'buttons' && (
<>
<label>
<span>Body</span>
<textarea value={buttonBody} onChange={(e) => setButtonBody(e.target.value)} rows={4} required />
</label>
<label><span>Button 1</span><input value={buttonOne} onChange={(e) => setButtonOne(e.target.value)} required /></label>
<label><span>Button 2</span><input value={buttonTwo} onChange={(e) => setButtonTwo(e.target.value)} /></label>
<label><span>Button 3</span><input value={buttonThree} onChange={(e) => setButtonThree(e.target.value)} /></label>
</>
)}
{sendMode === 'reaction' && (
<>
<label>
<span>Message ID</span>
<input value={reactionTarget} onChange={(e) => setReactionTarget(e.target.value)} required />
</label>
<label>
<span>Emoji</span>
<input value={reactionEmoji} onChange={(e) => setReactionEmoji(e.target.value)} required />
</label>
</>
)}
{error && <p className="auth-error">{error}</p>}
<button className="primary-button" type="submit" disabled={sending || !readyToSend || (!selectedPeer && !newPeer.trim())}>
{sending ? 'Sending…' : 'Send'}
</button>
<small>Within 24h of customer message: free-form text/media/buttons. Outside window: use template.</small>
</form>
</section>
</div>
</div>
)
}

View file

@ -71,9 +71,14 @@ export function WhatsAppSetupPage() {
<div className="whatsapp-config-row">
<div>
<span>Verify token</span>
<code>{setup.verify_token_configured ? 'Set in server env (WHATSAPP_VERIFY_TOKEN)' : 'Not configured'}</code>
<small>Use the exact same string in Meta and in your <code>.env</code> file.</small>
<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">

View file

@ -790,6 +790,22 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.admin-topbar > div { display: flex; align-items: center; gap: 14px; }
.admin-topbar img { width: 92px; height: 42px; object-fit: contain; }
.admin-topbar strong { font-size: 12px; color: #c7d6ea; }
.admin-topbar-actions { display: flex; align-items: center; gap: 12px; }
.admin-main-nav { display: flex; gap: 8px; }
.admin-main-nav button {
border: 1px solid rgba(255,255,255,.18);
background: transparent;
color: #c7d6ea;
border-radius: 8px;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
}
.admin-main-nav button.active {
background: #1475ff;
border-color: #1475ff;
color: white;
}
.admin-content, .admin-login-page { padding: 28px; }
.admin-login-page { min-height: calc(100vh - 72px); display: grid; place-items: center; }
.admin-login-card { width: min(420px, 100%); }
@ -833,6 +849,270 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.admin-otp-project a { color: #1475ff; word-break: break-all; }
.admin-otp-project p { margin: 0; line-height: 1.55; }
.admin-otp-project .admin-actions { margin-top: 5px; }
.admin-whatsapp { display: grid; gap: 18px; }
.admin-whatsapp-config { margin-bottom: 0; }
.admin-whatsapp-hint {
margin: 0;
padding: 12px 14px;
border-radius: 8px;
background: #fff8e8;
color: #7a5b12;
font-size: 11px;
line-height: 1.5;
}
.admin-whatsapp-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 18px;
align-items: start;
}
.admin-whatsapp-inbox, .admin-whatsapp-compose { min-height: 520px; }
.admin-whatsapp-empty {
margin: 0;
color: #8a95a7;
font-size: 12px;
line-height: 1.55;
}
.admin-whatsapp-thread-list { display: grid; gap: 14px; }
.admin-whatsapp-thread {
border: 1px solid #e6ebf2;
border-radius: 10px;
padding: 12px 14px;
background: #fbfcfe;
}
.admin-whatsapp-thread-head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.admin-whatsapp-thread-head strong { font-size: 12px; color: #1f2a3d; }
.admin-whatsapp-thread-head small, .admin-whatsapp-thread > small { color: #8a95a7; font-size: 10px; }
.admin-whatsapp-thread > p {
margin: 0 0 6px;
color: #46546a;
font-size: 11px;
line-height: 1.45;
}
.admin-whatsapp-thread-messages {
display: grid;
gap: 8px;
margin-top: 10px;
max-height: 220px;
overflow: auto;
}
.admin-whatsapp-bubble {
display: grid;
gap: 4px;
max-width: 88%;
padding: 8px 10px;
border-radius: 10px;
font-size: 11px;
line-height: 1.45;
}
.admin-whatsapp-bubble.inbound {
justify-self: start;
background: white;
border: 1px solid #dfe5ed;
}
.admin-whatsapp-bubble.outbound {
justify-self: end;
background: #e8f2ff;
border: 1px solid #c8ddff;
}
.admin-whatsapp-bubble small { color: #8a95a7; font-size: 9px; }
.admin-whatsapp-form {
display: grid;
gap: 12px;
}
.admin-whatsapp-form label {
display: grid;
gap: 6px;
}
.admin-whatsapp-form label > span {
color: #46546a;
font-size: 11px;
font-weight: 600;
}
.admin-whatsapp-form input,
.admin-whatsapp-form textarea {
width: 100%;
border: 1px solid #dfe5ed;
border-radius: 8px;
padding: 10px 12px;
font: inherit;
font-size: 12px;
color: #1f2a3d;
background: white;
}
.admin-whatsapp-form textarea { resize: vertical; min-height: 120px; }
.admin-whatsapp-form small {
color: #8a95a7;
font-size: 10px;
line-height: 1.45;
}
@media (max-width: 960px) {
.admin-whatsapp-grid { grid-template-columns: 1fr; }
}
.admin-whatsapp-page { max-width: 1400px; }
.admin-whatsapp-heading {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.admin-whatsapp-heading-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.admin-whatsapp-messenger {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 320px;
gap: 16px;
align-items: start;
}
.admin-whatsapp-sidebar,
.admin-whatsapp-chat,
.admin-whatsapp-compose-panel {
min-height: 640px;
}
.admin-whatsapp-thread-list.compact {
display: grid;
gap: 8px;
max-height: 560px;
overflow: auto;
}
.admin-whatsapp-thread-item {
display: grid;
gap: 4px;
text-align: left;
border: 1px solid #e6ebf2;
border-radius: 10px;
padding: 10px 12px;
background: white;
cursor: pointer;
}
.admin-whatsapp-thread-item.active {
border-color: #1475ff;
background: #f3f8ff;
}
.admin-whatsapp-thread-item strong { font-size: 12px; color: #1f2a3d; }
.admin-whatsapp-thread-item span {
font-size: 11px;
color: #607089;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-whatsapp-thread-item small { font-size: 10px; color: #8a95a7; }
.admin-whatsapp-chat {
display: grid;
grid-template-rows: auto 1fr;
}
.admin-whatsapp-chat-empty {
display: grid;
place-items: center;
min-height: 520px;
color: #8a95a7;
font-size: 12px;
}
.admin-whatsapp-chat-head {
display: flex;
justify-content: space-between;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid #e6ebf2;
margin-bottom: 12px;
}
.admin-whatsapp-chat-head strong { font-size: 13px; }
.admin-whatsapp-chat-head small { color: #8a95a7; font-size: 10px; }
.admin-whatsapp-chat-body {
display: grid;
gap: 10px;
align-content: start;
max-height: 560px;
overflow: auto;
padding-right: 4px;
}
.admin-whatsapp-media {
max-width: 100%;
border-radius: 8px;
margin-bottom: 6px;
}
.admin-whatsapp-mode-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.admin-whatsapp-mode-tabs button {
border: 1px solid #dfe5ed;
background: white;
color: #607089;
border-radius: 8px;
padding: 7px 10px;
font-size: 10px;
font-weight: 600;
text-transform: capitalize;
}
.admin-whatsapp-mode-tabs button.active {
background: #1475ff;
border-color: #1475ff;
color: white;
}
.admin-whatsapp-setup-form {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-whatsapp-setup-form label {
display: grid;
gap: 6px;
}
.admin-whatsapp-setup-form label > span {
color: #46546a;
font-size: 11px;
font-weight: 600;
}
.admin-whatsapp-setup-form input,
.admin-whatsapp-setup-form select,
.admin-whatsapp-form select {
width: 100%;
border: 1px solid #dfe5ed;
border-radius: 8px;
padding: 10px 12px;
font: inherit;
font-size: 12px;
background: white;
}
.admin-whatsapp-setup-meta {
grid-column: 1 / -1;
display: grid;
gap: 4px;
color: #607089;
font-size: 11px;
}
.admin-whatsapp-setup-form .primary-button {
grid-column: 1 / -1;
justify-self: start;
}
.link-button {
border: none;
background: none;
color: #1475ff;
font-size: 10px;
padding: 0;
cursor: pointer;
justify-self: start;
}
@media (max-width: 1100px) {
.admin-whatsapp-messenger { grid-template-columns: 1fr; }
.admin-whatsapp-sidebar,
.admin-whatsapp-chat,
.admin-whatsapp-compose-panel { min-height: auto; }
}
/* UrmiOTP */
.urmiotp-page, .urmiotp-docs-page { max-width: 1080px; }

View file

@ -648,6 +648,66 @@ export type AdminTemplateConfig = TemplateConfig & {
user: { id: string; name: string; email: string }
}
export type AdminWhatsAppConfig = {
business_phone: string
phone_number_id: string | null
business_account_id: string | null
meta_app_id: string | null
access_token_configured: boolean
app_secret_configured: boolean
webhook_url: string
verify_token_configured: boolean
ready_to_send: boolean
supported_operations: string[]
}
export type AdminWhatsAppMessage = {
id: string
direction: 'inbound' | 'outbound'
wa_message_id: string | null
from: string
to: string
body: string | null
message_type: string
status: string
media?: {
id?: string
mime_type?: string
caption?: string
filename?: string
} | null
created_at: string
}
export type AdminWhatsAppTemplate = {
id?: string
name: string
language: string
status?: string
category?: string
}
export type AdminWhatsAppSendPayload = {
to: string
message_type: 'text' | 'template' | 'image' | 'document' | 'audio' | 'video' | 'interactive_buttons' | 'interactive_list' | 'reaction'
text?: string
reply_to?: string
template_name?: string
template_language?: string
template_components?: Record<string, unknown>[]
media_link?: string
media_caption?: string
media_filename?: string
interactive_body?: string
interactive_header?: string
interactive_footer?: string
interactive_buttons?: Array<{ id: string; title: string }>
interactive_list_button?: string
interactive_list_sections?: Record<string, unknown>[]
reaction_message_id?: string
reaction_emoji?: string
}
export const adminApi = {
me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'),
login: (username: string, password: string) =>
@ -708,6 +768,30 @@ export const adminApi = {
method: 'PATCH',
body: JSON.stringify(payload),
}),
whatsappConfig: () => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config'),
updateWhatsAppConfig: (payload: {
phone_number_id?: string
business_account_id?: string
app_secret?: string
}) => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
listWhatsAppMessages: (limit = 50, peer?: string) =>
jsonRequest<AdminWhatsAppMessage[]>(
`/api/v1/admin/whatsapp/messages?limit=${limit}${peer ? `&peer=${encodeURIComponent(peer)}` : ''}`,
),
listWhatsAppTemplates: () =>
jsonRequest<{ data?: AdminWhatsAppTemplate[] }>('/api/v1/admin/whatsapp/templates'),
markWhatsAppRead: (waMessageId: string) =>
jsonRequest<{ success?: boolean }>(`/api/v1/admin/whatsapp/messages/${encodeURIComponent(waMessageId)}/read`, {
method: 'POST',
}),
sendWhatsAppMessage: (payload: AdminWhatsAppSendPayload) =>
jsonRequest<AdminWhatsAppMessage>('/api/v1/admin/whatsapp/messages/send', {
method: 'POST',
body: JSON.stringify(payload),
}),
}
export type LogType = 'outbound' | 'inbound' | 'sms' | 'otp'
@ -791,6 +875,7 @@ 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