first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
417
frontend/src/features/admin/AdminPortal.tsx
Normal file
417
frontend/src/features/admin/AdminPortal.tsx
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
import { ChevronRight, LockKeyhole, MessageSquareText, Network, Phone, ShieldCheck } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import {
|
||||
adminApi,
|
||||
type AdminDltApplication,
|
||||
type AdminKycApplication,
|
||||
type AdminPhoneNumber,
|
||||
type AdminSenderConfig,
|
||||
type AdminTemplateConfig,
|
||||
type AdminUrmiOtpProject,
|
||||
} from '../../lib/api'
|
||||
|
||||
const KYC_DOC_LABELS: Record<string, string> = {
|
||||
business_pan_card: 'Business PAN card',
|
||||
business_documents: 'Business documents',
|
||||
gst_certificate: 'GST certificate',
|
||||
kyc_form: 'Signed KYC form',
|
||||
}
|
||||
|
||||
const DLT_DOC_LABELS: Record<string, string> = {
|
||||
proof_of_directorship: 'Proof of directorship',
|
||||
pe_tm_chain_binding: 'PE TM chain binding status',
|
||||
}
|
||||
|
||||
const PE_TM_CHAIN_BINDING_ID = '1302158098680292888'
|
||||
|
||||
function AdminLogin({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [username, setUsername] = useState('root')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await adminApi.login(username, password)
|
||||
onSuccess()
|
||||
} catch {
|
||||
setError('Invalid admin credentials')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-login-page">
|
||||
<form className="auth-card admin-login-card" onSubmit={submit}>
|
||||
<div className="auth-heading">
|
||||
<ShieldCheck size={28} />
|
||||
<h2>Urmi Admin</h2>
|
||||
<p>Global compliance portal</p>
|
||||
</div>
|
||||
<label><span>Username</span><input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" /></label>
|
||||
<label><span>Password</span><div><LockKeyhole size={17} /><input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" placeholder="Admin password" /></div></label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<button className="auth-submit">Sign in to admin <ChevronRight size={17} /></button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminCompliance() {
|
||||
const [tab, setTab] = useState<'kyc' | 'dlt' | 'senders' | 'templates' | 'urmiotp' | 'numbers'>('kyc')
|
||||
const [kycItems, setKycItems] = useState<AdminKycApplication[]>([])
|
||||
const [dltItems, setDltItems] = useState<AdminDltApplication[]>([])
|
||||
const [senderItems, setSenderItems] = useState<AdminSenderConfig[]>([])
|
||||
const [templateItems, setTemplateItems] = useState<AdminTemplateConfig[]>([])
|
||||
const [otpProjects, setOtpProjects] = useState<AdminUrmiOtpProject[]>([])
|
||||
const [phoneNumbers, setPhoneNumbers] = useState<AdminPhoneNumber[]>([])
|
||||
const [phoneNumber, setPhoneNumber] = useState('')
|
||||
const [monthlyNumberPrice, setMonthlyNumberPrice] = useState('299')
|
||||
const [inboundPrice, setInboundPrice] = useState('0.45')
|
||||
const [outboundPrice, setOutboundPrice] = useState('0.60')
|
||||
const [assignEmail, setAssignEmail] = useState('')
|
||||
const [phoneError, setPhoneError] = useState('')
|
||||
const [phoneSaving, setPhoneSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
async function load() {
|
||||
const [kyc, dlt, senders, templates, projects, numbers] = await Promise.all([
|
||||
adminApi.listKyc(),
|
||||
adminApi.listDlt(),
|
||||
adminApi.listSenders(),
|
||||
adminApi.listTemplates(),
|
||||
adminApi.listUrmiOtpProjects(),
|
||||
adminApi.listPhoneNumbers(),
|
||||
])
|
||||
setKycItems(kyc)
|
||||
setDltItems(dlt)
|
||||
setSenderItems(senders)
|
||||
setTemplateItems(templates)
|
||||
setOtpProjects(projects)
|
||||
setPhoneNumbers(numbers)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function reviewKyc(applicationId: string, documentId: string, status: 'approved' | 'rejected') {
|
||||
const note = status === 'rejected' ? 'Document rejected. Please re-upload a valid file.' : undefined
|
||||
await adminApi.reviewDocument(applicationId, documentId, status, note)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function reviewDlt(applicationId: string, documentId: string, status: 'approved' | 'rejected') {
|
||||
const note = status === 'rejected' ? 'Document rejected. Please re-upload a valid file.' : undefined
|
||||
await adminApi.reviewDltDocument(applicationId, documentId, status, note)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function reviewSender(senderId: string, status: 'approved' | 'rejected') {
|
||||
const note = status === 'rejected' ? 'Sender ID rejected. Update details and submit again.' : undefined
|
||||
await adminApi.reviewSender(senderId, status, note)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function reviewTemplate(templateId: string, status: 'approved' | 'rejected') {
|
||||
const note = status === 'rejected' ? 'Template rejected. Update details and submit again.' : undefined
|
||||
await adminApi.reviewTemplate(templateId, status, note)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function reviewOtpProject(projectId: string, status: 'approved' | 'rejected') {
|
||||
const note = status === 'rejected'
|
||||
? 'Project registration rejected. Review the app details and register again.'
|
||||
: undefined
|
||||
await adminApi.reviewUrmiOtpProject(projectId, status, note)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function createPhoneNumber(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setPhoneError('')
|
||||
setPhoneSaving(true)
|
||||
try {
|
||||
await adminApi.createPhoneNumber({
|
||||
phone_number: phoneNumber.trim(),
|
||||
monthly_number_price: Number(monthlyNumberPrice),
|
||||
inbound_price: Number(inboundPrice),
|
||||
outbound_price: Number(outboundPrice),
|
||||
user_email: assignEmail.trim() || null,
|
||||
})
|
||||
setPhoneNumber('')
|
||||
setAssignEmail('')
|
||||
await load()
|
||||
} catch (reason) {
|
||||
setPhoneError(reason instanceof Error ? reason.message : 'Unable to add phone number')
|
||||
} finally {
|
||||
setPhoneSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function releasePhoneNumber(numberId: string) {
|
||||
await adminApi.updatePhoneNumber(numberId, { release: true })
|
||||
await load()
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading compliance queue…</div>
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Global compliance</span>
|
||||
<h1>Compliance review</h1>
|
||||
<p>Approve KYC, DLT, messaging configuration, and UrmiOTP projects before they go live.</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button className={tab === 'kyc' ? 'active' : ''} onClick={() => setTab('kyc')}>KYC submissions</button>
|
||||
<button className={tab === 'dlt' ? 'active' : ''} onClick={() => setTab('dlt')}>DLT submissions</button>
|
||||
<button className={tab === 'senders' ? 'active' : ''} onClick={() => setTab('senders')}>Sender IDs</button>
|
||||
<button className={tab === 'templates' ? 'active' : ''} onClick={() => setTab('templates')}>Templates</button>
|
||||
<button className={tab === 'urmiotp' ? 'active' : ''} onClick={() => setTab('urmiotp')}>UrmiOTP projects</button>
|
||||
<button className={tab === 'numbers' ? 'active' : ''} onClick={() => setTab('numbers')}>Phone numbers</button>
|
||||
</div>
|
||||
|
||||
{tab === 'kyc' && (
|
||||
kycItems.length === 0 ? <section className="panel placeholder"><p>No KYC submissions yet.</p></section> : kycItems.map((item) => (
|
||||
<section className="panel admin-kyc-card" key={item.id}>
|
||||
<div className="panel-heading">
|
||||
<div><h2>{item.user.name}</h2><p>{item.user.email} · PAN {item.pan_number} · GST {item.gst_number}</p></div>
|
||||
<span className={`doc-status ${item.completed ? 'approved' : item.status}`}>{item.completed ? 'KYC completed' : item.status}</span>
|
||||
</div>
|
||||
{item.documents.map((doc) => (
|
||||
<div className="kyc-doc-row" key={doc.id}>
|
||||
<div><strong>{KYC_DOC_LABELS[doc.document_type] ?? doc.document_type}</strong><span>{doc.original_filename ?? 'Missing'}</span></div>
|
||||
<span className={`doc-status ${doc.status}`}>{doc.status}</span>
|
||||
{doc.status === 'pending' && (
|
||||
<div className="admin-actions">
|
||||
<button className="primary-button" onClick={() => reviewKyc(item.id, doc.id, 'approved')}>Approve</button>
|
||||
<button className="secondary-button" onClick={() => reviewKyc(item.id, doc.id, 'rejected')}>Reject</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
{tab === 'dlt' && (
|
||||
dltItems.length === 0 ? <section className="panel placeholder"><p>No DLT submissions yet.</p></section> : dltItems.map((item) => (
|
||||
<section className="panel admin-kyc-card" key={item.id}>
|
||||
<div className="panel-heading">
|
||||
<div><h2>{item.user.name}</h2><p>{item.user.email}</p></div>
|
||||
<span className={`doc-status ${item.completed ? 'approved' : item.status}`}>{item.completed ? 'DLT completed' : item.status}</span>
|
||||
</div>
|
||||
{item.documents.map((doc) => (
|
||||
<div className="kyc-doc-row" key={doc.id}>
|
||||
<div>
|
||||
<strong>{DLT_DOC_LABELS[doc.document_type] ?? doc.document_type}</strong>
|
||||
<span>
|
||||
{doc.document_type === 'pe_tm_chain_binding'
|
||||
? `Chain ID ${PE_TM_CHAIN_BINDING_ID}`
|
||||
: (doc.original_filename ?? 'Missing')}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`doc-status ${doc.status}`}>{doc.status}</span>
|
||||
{doc.status === 'pending' && (
|
||||
<div className="admin-actions">
|
||||
<button className="primary-button" onClick={() => reviewDlt(item.id, doc.id, 'approved')}>Approve</button>
|
||||
<button className="secondary-button" onClick={() => reviewDlt(item.id, doc.id, 'rejected')}>Reject</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
{tab === 'senders' && (
|
||||
senderItems.length === 0 ? <section className="panel placeholder"><p>No sender ID requests yet.</p></section> : senderItems.map((item) => (
|
||||
<section className="panel admin-kyc-card" key={item.id}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>{item.sender_id}</h2>
|
||||
<p>{item.user.name} · {item.user.email}</p>
|
||||
</div>
|
||||
<span className={`doc-status ${item.status}`}>{item.status}</span>
|
||||
</div>
|
||||
<div className="kyc-doc-row">
|
||||
<div>
|
||||
<strong><MessageSquareText size={14} /> {item.sender_type}</strong>
|
||||
<span>DLT Entity {item.dlt_entity_id}</span>
|
||||
<small>{item.purpose}</small>
|
||||
</div>
|
||||
{item.status === 'pending' && (
|
||||
<div className="admin-actions">
|
||||
<button className="primary-button" onClick={() => reviewSender(item.id, 'approved')}>Approve</button>
|
||||
<button className="secondary-button" onClick={() => reviewSender(item.id, 'rejected')}>Reject</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
{tab === 'templates' && (
|
||||
templateItems.length === 0 ? <section className="panel placeholder"><p>No template requests yet.</p></section> : templateItems.map((item) => (
|
||||
<section className="panel admin-kyc-card" key={item.id}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>{item.template_name}</h2>
|
||||
<p>{item.user.name} · {item.user.email} · Sender {item.sender_id}</p>
|
||||
</div>
|
||||
<span className={`doc-status ${item.status}`}>{item.status}</span>
|
||||
</div>
|
||||
<div className="kyc-doc-row">
|
||||
<div>
|
||||
<strong><ShieldCheck size={14} /> DLT {item.dlt_template_id}</strong>
|
||||
<small>{item.template_text}</small>
|
||||
</div>
|
||||
{item.status === 'pending' && (
|
||||
<div className="admin-actions">
|
||||
<button className="primary-button" onClick={() => reviewTemplate(item.id, 'approved')}>Approve</button>
|
||||
<button className="secondary-button" onClick={() => reviewTemplate(item.id, 'rejected')}>Reject</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
{tab === 'urmiotp' && (
|
||||
otpProjects.length === 0
|
||||
? <section className="panel placeholder"><p>No UrmiOTP project requests yet.</p></section>
|
||||
: otpProjects.map((project) => (
|
||||
<section className="panel admin-kyc-card" key={project.id}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>{project.project_name}</h2>
|
||||
<p>{project.user.name} · {project.user.email} · {project.app_type} · label: {project.message_label}</p>
|
||||
</div>
|
||||
<span className={`doc-status ${project.status}`}>{project.status}</span>
|
||||
</div>
|
||||
<div className="admin-otp-project">
|
||||
<a href={project.app_url} target="_blank" rel="noreferrer">{project.app_url}</a>
|
||||
<p>{project.purpose}</p>
|
||||
<small>Terms accepted {new Date(project.terms_accepted_at).toLocaleString()}</small>
|
||||
{project.status === 'pending' && (
|
||||
<div className="admin-actions">
|
||||
<button className="primary-button" onClick={() => reviewOtpProject(project.id, 'approved')}>Approve</button>
|
||||
<button className="secondary-button" onClick={() => reviewOtpProject(project.id, 'rejected')}>Reject</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
{tab === 'numbers' && (
|
||||
<>
|
||||
<section className="panel admin-phone-form">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Add phone number</h2>
|
||||
<p>List in Buy Numbers, or assign directly to a user for My Numbers.</p>
|
||||
</div>
|
||||
<Phone size={19} />
|
||||
</div>
|
||||
<form className="admin-phone-form-grid" onSubmit={createPhoneNumber}>
|
||||
<label className="admin-phone-number">
|
||||
<span>Phone number</span>
|
||||
<input
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
placeholder="9876543210"
|
||||
inputMode="numeric"
|
||||
pattern="[6-9][0-9]{9}"
|
||||
minLength={10}
|
||||
maxLength={10}
|
||||
required
|
||||
/>
|
||||
<small>10-digit mobile number without +91.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Number cost (monthly)</span>
|
||||
<input type="number" min="0" step="1" value={monthlyNumberPrice} onChange={(e) => setMonthlyNumberPrice(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Inbound (per min)</span>
|
||||
<input type="number" min="0" step="0.01" value={inboundPrice} onChange={(e) => setInboundPrice(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Outbound (per min)</span>
|
||||
<input type="number" min="0" step="0.01" value={outboundPrice} onChange={(e) => setOutboundPrice(e.target.value)} required />
|
||||
</label>
|
||||
<label className="admin-phone-email">
|
||||
<span>Assign to user email (optional)</span>
|
||||
<input type="email" value={assignEmail} onChange={(e) => setAssignEmail(e.target.value)} placeholder="user@company.com" />
|
||||
<small>Leave blank to list the number in Buy Numbers instead.</small>
|
||||
</label>
|
||||
{phoneError && <p className="auth-error">{phoneError}</p>}
|
||||
<button type="submit" className="primary-button" disabled={phoneSaving}>
|
||||
{phoneSaving ? 'Adding…' : 'Add number'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{phoneNumbers.length === 0 ? (
|
||||
<section className="panel placeholder"><p>No phone numbers yet.</p></section>
|
||||
) : phoneNumbers.map((item) => (
|
||||
<section className="panel admin-kyc-card" key={item.id}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>{item.phone_number.length === 10 ? `${item.phone_number.slice(0, 5)} ${item.phone_number.slice(5)}` : item.phone_number}</h2>
|
||||
<p>
|
||||
Number ₹{item.monthly_number_price.toFixed(0)}/mo · Inbound ₹{item.inbound_price.toFixed(2)}/min · Outbound ₹{item.outbound_price.toFixed(2)}/min
|
||||
{item.user ? ` · ${item.user.name} (${item.user.email})` : ' · Available in catalog'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`doc-status ${item.status === 'assigned' ? 'approved' : 'pending'}`}>{item.status}</span>
|
||||
</div>
|
||||
<div className="kyc-doc-row">
|
||||
<div>
|
||||
<strong><Network size={14} /> {item.acquisition_type ?? 'catalog'}</strong>
|
||||
<small>{item.assigned_at ? `Assigned ${new Date(item.assigned_at).toLocaleString()}` : 'Not assigned yet'}</small>
|
||||
</div>
|
||||
{item.status === 'assigned' && (
|
||||
<div className="admin-actions">
|
||||
<button className="secondary-button" onClick={() => releasePhoneNumber(item.id)}>Release to catalog</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminApp() {
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
|
||||
}, [])
|
||||
|
||||
async function logout() {
|
||||
await adminApi.logout()
|
||||
setAuthenticated(false)
|
||||
}
|
||||
|
||||
if (authenticated === null) return <div className="auth-loading">Checking admin session…</div>
|
||||
|
||||
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>}
|
||||
</header>
|
||||
<main>
|
||||
{authenticated ? <AdminCompliance /> : <AdminLogin onSuccess={() => setAuthenticated(true)} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
frontend/src/features/agent/UrmiOtpDocsPage.tsx
Normal file
100
frontend/src/features/agent/UrmiOtpDocsPage.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { ArrowLeft, Check, Code2, Copy, KeyRound } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { urmiOtpApi, type UrmiOtpDocumentation } from '../../lib/api'
|
||||
|
||||
export function UrmiOtpDocsPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [docs, setDocs] = useState<UrmiOtpDocumentation | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
urmiOtpApi.documentation(projectId)
|
||||
.then(setDocs)
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load documentation'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading API documentation…</div>
|
||||
if (!docs) return <section className="panel"><p className="auth-error">{error}</p></section>
|
||||
|
||||
const requestExample = JSON.stringify(docs.api.example, null, 2)
|
||||
|
||||
async function copyExample() {
|
||||
await navigator.clipboard.writeText(requestExample)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="urmiotp-docs-page">
|
||||
<button className="text-button otp-back" onClick={() => navigate('/console/agent/urmiotp')}>
|
||||
<ArrowLeft size={14} /> Back to projects
|
||||
</button>
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">UrmiOTP · {docs.project.project_name}</span>
|
||||
<h1>API documentation</h1>
|
||||
<p className="otp-message-label">
|
||||
Message label: <strong>{docs.project.message_label}</strong>
|
||||
<span> — your product identification in each message. URMI branding remains unchanged.</span>
|
||||
</p>
|
||||
<p>Unified verification templates for login, recovery, account changes, transactions, and devices.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel otp-api-overview">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Generate verification OTP</h2><p>{docs.api.summary}</p></div>
|
||||
<span className="explorer-method">{docs.api.method}</span>
|
||||
</div>
|
||||
<div className="explorer-field-row"><span>Endpoint</span><code>{docs.api.path}</code></div>
|
||||
<div className="otp-api-notice">
|
||||
<Code2 size={16} />
|
||||
<span>This is documentation only. The UrmiOTP API is not enabled yet.</span>
|
||||
</div>
|
||||
<div className="otp-parameter-list">
|
||||
{Object.entries(docs.api.parameters).map(([name, description]) => (
|
||||
<div key={name}><code>{name}</code><span>{description}</span></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="otp-code-heading">
|
||||
<strong>Request body</strong>
|
||||
<button className="secondary-button" onClick={copyExample}><Copy size={13} /> {copied ? 'Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
<pre className="explorer-code">{requestExample}</pre>
|
||||
</section>
|
||||
|
||||
<div className="otp-template-grid">
|
||||
{docs.templates.map((template, index) => (
|
||||
<section className="panel otp-template-card" key={template.type}>
|
||||
<div className="otp-template-number">{String(index + 1).padStart(2, '0')}</div>
|
||||
<div className="panel-heading">
|
||||
<div><h2>{template.name}</h2><p><code>{template.type}</code></p></div>
|
||||
<KeyRound size={17} />
|
||||
</div>
|
||||
<div className="otp-template-copy">
|
||||
<span>Approved content</span>
|
||||
<p>{template.content}</p>
|
||||
</div>
|
||||
<div className="otp-template-copy sample">
|
||||
<span>Sample</span>
|
||||
<p>{template.sample}</p>
|
||||
</div>
|
||||
<div className="otp-template-columns">
|
||||
<div>
|
||||
<strong>Use for</strong>
|
||||
<ul>{template.uses.map((use) => <li key={use}><Check size={12} /> {use}</li>)}</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Variables</strong>
|
||||
<ol>{template.variables.map((variable) => <li key={variable}>{variable}</li>)}</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
frontend/src/features/agent/UrmiOtpPage.tsx
Normal file
237
frontend/src/features/agent/UrmiOtpPage.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { ArrowRight, CheckCircle2, ExternalLink, KeyRound, Plus, ShieldCheck, Smartphone, X } from 'lucide-react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { urmiOtpApi, type UrmiOtpProject } from '../../lib/api'
|
||||
|
||||
const PROJECT_NAME_RE = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/
|
||||
const MESSAGE_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9 -]{0,18}[A-Za-z0-9]$|^[A-Za-z0-9]$/
|
||||
|
||||
function ProjectModal({ onClose, onCreated }: {
|
||||
onClose: () => void
|
||||
onCreated: (project: UrmiOtpProject) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [messageLabel, setMessageLabel] = useState('')
|
||||
const [appType, setAppType] = useState<'web' | 'mobile'>('web')
|
||||
const [appUrl, setAppUrl] = useState('')
|
||||
const [purpose, setPurpose] = useState('')
|
||||
const [terms, setTerms] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
if (!PROJECT_NAME_RE.test(name) || name.includes('--')) {
|
||||
setError('Use 6–30 lowercase letters, numbers, or hyphens; start with a letter and end with a letter or number')
|
||||
return
|
||||
}
|
||||
const trimmedLabel = messageLabel.trim()
|
||||
if (!trimmedLabel || trimmedLabel.length > 20 || !MESSAGE_LABEL_RE.test(trimmedLabel)) {
|
||||
setError('Message label must be 1–20 characters: letters, numbers, spaces, or hyphens; start and end with a letter or number')
|
||||
return
|
||||
}
|
||||
if (purpose.trim().length < 20) {
|
||||
setError('Describe your app and OTP use case in at least 20 characters')
|
||||
return
|
||||
}
|
||||
if (!terms) {
|
||||
setError('You must agree to the terms and conditions')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const project = await urmiOtpApi.createProject({
|
||||
project_name: name,
|
||||
message_label: trimmedLabel,
|
||||
app_type: appType,
|
||||
app_url: appUrl.trim(),
|
||||
purpose: purpose.trim(),
|
||||
terms_accepted: terms,
|
||||
})
|
||||
onCreated(project)
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to register project')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<form className="modal-card otp-project-modal" onSubmit={submit} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Register an UrmiOTP project</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="otp-project-form">
|
||||
<label>
|
||||
<span>Project name</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder="acme-login"
|
||||
minLength={6}
|
||||
maxLength={30}
|
||||
required
|
||||
/>
|
||||
<small>Globally unique. Lowercase letters, numbers, and hyphens only.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Message label</span>
|
||||
<input
|
||||
value={messageLabel}
|
||||
onChange={(event) => setMessageLabel(event.target.value.slice(0, 20))}
|
||||
placeholder="your product identification in message"
|
||||
maxLength={20}
|
||||
required
|
||||
/>
|
||||
<small>Your product name in the message body; URMI stays as the platform identifier (max 20 characters).</small>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Application type</legend>
|
||||
<label><input type="radio" checked={appType === 'web'} onChange={() => setAppType('web')} /> Web application</label>
|
||||
<label><input type="radio" checked={appType === 'mobile'} onChange={() => setAppType('mobile')} /> Mobile application</label>
|
||||
</fieldset>
|
||||
<label>
|
||||
<span>{appType === 'web' ? 'Website or domain URL' : 'App store URL'}</span>
|
||||
<input
|
||||
type="url"
|
||||
value={appUrl}
|
||||
onChange={(event) => setAppUrl(event.target.value)}
|
||||
placeholder={appType === 'web' ? 'https://app.example.com' : 'https://play.google.com/store/apps/details?id=...'}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Purpose and OTP use case</span>
|
||||
<textarea
|
||||
value={purpose}
|
||||
onChange={(event) => setPurpose(event.target.value)}
|
||||
placeholder="Describe your app, its users, and how verification codes will be used."
|
||||
rows={4}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="otp-terms">
|
||||
<input type="checkbox" checked={terms} onChange={(event) => setTerms(event.target.checked)} />
|
||||
<span>I agree to the UrmiOTP terms, acceptable-use policy, and anti-abuse requirements.</span>
|
||||
</label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="primary-button" disabled={saving}>{saving ? 'Submitting…' : 'Submit for approval'}</button>
|
||||
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OtpMap() {
|
||||
return (
|
||||
<section className="panel otp-map">
|
||||
<div className="otp-map-root"><KeyRound size={20} /><strong>URMI OTP</strong></div>
|
||||
<div className="otp-map-branches">
|
||||
<div><strong>Verification</strong><span>Signup · KYC · Identity</span></div>
|
||||
<div><strong>Login</strong><span>MFA · 2FA · Passwordless</span></div>
|
||||
<div><strong>Recovery</strong><span>Password reset · Access recovery</span></div>
|
||||
<div><strong>Account changes</strong><span>Email · Phone · Security</span></div>
|
||||
<div><strong>Action authorization</strong><span>Sensitive operations</span></div>
|
||||
<div><strong>Transactions</strong><span>Payments · Transfers</span></div>
|
||||
<div><strong>Device verification</strong><span>Pairing · IoT · Activation</span></div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function UrmiOtpPage() {
|
||||
const navigate = useNavigate()
|
||||
const [projects, setProjects] = useState<UrmiOtpProject[]>([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
urmiOtpApi.listProjects()
|
||||
.then(setProjects)
|
||||
.catch(() => setError('Unable to load UrmiOTP projects'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading UrmiOTP…</div>
|
||||
|
||||
return (
|
||||
<div className="urmiotp-page">
|
||||
<div className="account-heading otp-page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Agent · Unified OTP</span>
|
||||
<h1>UrmiOTP</h1>
|
||||
<p>One verification platform and a small set of generic templates for every app and OTP journey.</p>
|
||||
</div>
|
||||
<button className="primary-button" onClick={() => setModalOpen(true)}><Plus size={15} /> Add a project</button>
|
||||
</div>
|
||||
|
||||
<OtpMap />
|
||||
|
||||
<section className="panel otp-projects-panel">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Registered projects</h2><p>Each app must be approved before its API documentation becomes available.</p></div>
|
||||
<Smartphone size={19} />
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<div className="otp-empty">
|
||||
<ShieldCheck size={24} />
|
||||
<strong>No projects registered</strong>
|
||||
<span>Add your web or mobile app to begin compliance review.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="otp-project-table">
|
||||
<div className="otp-project-table-head">
|
||||
<span>Project</span><span>Application</span><span>Status</span><span />
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<div className="otp-project-row" key={project.id}>
|
||||
<div>
|
||||
<strong>{project.project_name}</strong>
|
||||
<small>Label: {project.message_label}</small>
|
||||
<small>{project.purpose}</small>
|
||||
</div>
|
||||
<a href={project.app_url} target="_blank" rel="noreferrer">
|
||||
{project.app_type}<ExternalLink size={12} />
|
||||
</a>
|
||||
<div>
|
||||
<span className={`doc-status ${project.status}`}>{project.status}</span>
|
||||
{project.review_note && <small>{project.review_note}</small>}
|
||||
</div>
|
||||
{project.status === 'approved' ? (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(`/console/agent/urmiotp/projects/${project.id}/docs`)}
|
||||
>
|
||||
API documentation <ArrowRight size={14} />
|
||||
</button>
|
||||
) : project.status === 'pending' ? (
|
||||
<span className="otp-awaiting"><CheckCircle2 size={13} /> Awaiting admin review</span>
|
||||
) : (
|
||||
<button className="secondary-button" onClick={() => setModalOpen(true)}>Register another</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</section>
|
||||
|
||||
{modalOpen && (
|
||||
<ProjectModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onCreated={(project) => setProjects((current) => [project, ...current])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
331
frontend/src/features/analytics/AnalyticsPage.tsx
Normal file
331
frontend/src/features/analytics/AnalyticsPage.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import {
|
||||
analyticsApi,
|
||||
type AnalyticsGroupBy,
|
||||
type AnalyticsPeriod,
|
||||
type AnalyticsService,
|
||||
type UsageAnalytics,
|
||||
} from '../../lib/api'
|
||||
|
||||
const PERIODS: Array<{ value: AnalyticsPeriod; label: string; hint: string }> = [
|
||||
{ value: 'weekly', label: 'Weekly', hint: 'Last 12 weeks' },
|
||||
{ value: 'monthly', label: 'Monthly', hint: 'Last 12 months' },
|
||||
{ value: 'quarterly', label: 'Quarterly', hint: 'Last 4 quarters' },
|
||||
]
|
||||
|
||||
const SERVICES: Array<{ value: AnalyticsService; label: string; hint: string }> = [
|
||||
{ value: 'sms', label: 'SMS', hint: 'DLT template messaging' },
|
||||
{ value: 'inbound', label: 'Inbound', hint: 'Calls to your Urmi numbers' },
|
||||
{ value: 'outbound', label: 'Outbound', hint: 'Calls placed from your numbers' },
|
||||
{ value: 'otp', label: 'OTP', hint: 'UrmiOTP verification messages' },
|
||||
]
|
||||
|
||||
const GROUP_BY: Record<AnalyticsService, Array<{ value: AnalyticsGroupBy; label: string; hint: string }>> = {
|
||||
sms: [
|
||||
{ value: 'dlt_sender', label: 'DLT sender', hint: 'Approved sender header' },
|
||||
{ value: 'dlt_template', label: 'DLT template', hint: 'Template name' },
|
||||
],
|
||||
inbound: [
|
||||
{ value: 'did', label: 'DID number', hint: 'Which Urmi number received the call' },
|
||||
],
|
||||
outbound: [
|
||||
{ value: 'socket', label: 'Voice socket name', hint: 'Workspace name linked to a number' },
|
||||
{ value: 'did', label: 'DID number', hint: 'Which Urmi number placed the call' },
|
||||
],
|
||||
otp: [
|
||||
{ value: 'project', label: 'OTP project', hint: 'UrmiOTP project name' },
|
||||
{ value: 'otp_type', label: 'OTP type', hint: 'Verification template type' },
|
||||
],
|
||||
}
|
||||
|
||||
const SERVICE_COLORS = {
|
||||
sms: '#11b7c9',
|
||||
inbound: '#8b5cf6',
|
||||
outbound: '#1475ff',
|
||||
otp: '#f59e0b',
|
||||
} as const
|
||||
|
||||
const MAX_BAR_SIZE = 56
|
||||
|
||||
function formatCount(value: number) {
|
||||
return value.toLocaleString('en-IN')
|
||||
}
|
||||
|
||||
function formatPhone(value: string) {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
if (digits.length === 10) {
|
||||
return `+91 ${digits.slice(0, 5)} ${digits.slice(5)}`
|
||||
}
|
||||
if (digits.length === 12 && digits.startsWith('91')) {
|
||||
return `+91 ${digits.slice(2, 7)} ${digits.slice(7)}`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function barCategoryGap(count: number) {
|
||||
if (count <= 1) return '72%'
|
||||
if (count <= 3) return '45%'
|
||||
return '22%'
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, tone }: { label: string; value: number; tone: keyof typeof SERVICE_COLORS }) {
|
||||
return (
|
||||
<article className="analytics-stat">
|
||||
<span>{label}</span>
|
||||
<strong>{formatCount(value)}</strong>
|
||||
<i style={{ background: SERVICE_COLORS[tone] }} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: Array<{ name?: string; value?: number; color?: string; payload?: { detail?: string } }>
|
||||
label?: string
|
||||
}) {
|
||||
if (!active || !payload?.length) return null
|
||||
const detail = payload[0]?.payload?.detail
|
||||
return (
|
||||
<div className="analytics-tooltip">
|
||||
<strong>{label}</strong>
|
||||
{detail && <p>{detail}</p>}
|
||||
{payload.map((entry) => (
|
||||
<div key={entry.name}>
|
||||
<span style={{ background: entry.color }} />
|
||||
{entry.name}: {formatCount(entry.value ?? 0)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function breakdownTitle(service: AnalyticsService, groupBy: AnalyticsGroupBy) {
|
||||
const serviceLabel = SERVICES.find((item) => item.value === service)?.label ?? service
|
||||
const groupLabel = GROUP_BY[service].find((item) => item.value === groupBy)?.label ?? groupBy
|
||||
return `${serviceLabel} by ${groupLabel.toLowerCase()}`
|
||||
}
|
||||
|
||||
function formatBreakdownLabel(label: string, service: AnalyticsService, groupBy: AnalyticsGroupBy) {
|
||||
if (groupBy === 'did' || (service === 'outbound' && groupBy === 'socket' && label !== 'No voice socket linked')) {
|
||||
if (/^\d+$/.test(label.replace(/\D/g, ''))) {
|
||||
return formatPhone(label)
|
||||
}
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [period, setPeriod] = useState<AnalyticsPeriod>('weekly')
|
||||
const [service, setService] = useState<AnalyticsService>('outbound')
|
||||
const [groupBy, setGroupBy] = useState<AnalyticsGroupBy>('socket')
|
||||
const [data, setData] = useState<UsageAnalytics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const groupOptions = GROUP_BY[service]
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupOptions.some((item) => item.value === groupBy)) {
|
||||
setGroupBy(groupOptions[0].value)
|
||||
}
|
||||
}, [groupBy, groupOptions])
|
||||
|
||||
const loadAnalytics = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await analyticsApi.usage({ period, service, group_by: groupBy })
|
||||
setData(response)
|
||||
} catch (reason) {
|
||||
setData(null)
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to load analytics')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [groupBy, period, service])
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics()
|
||||
}, [loadAnalytics])
|
||||
|
||||
const breakdownChartData = useMemo(
|
||||
() => (data?.breakdown ?? []).map((item) => ({
|
||||
...item,
|
||||
displayLabel: formatBreakdownLabel(item.label, service, groupBy),
|
||||
})),
|
||||
[data?.breakdown, groupBy, service],
|
||||
)
|
||||
|
||||
const hasBreakdown = breakdownChartData.length > 0
|
||||
const hasTimeline = (data?.timeline.length ?? 0) > 0
|
||||
const breakdownGap = barCategoryGap(breakdownChartData.length)
|
||||
const timelineGap = barCategoryGap(data?.timeline.length ?? 0)
|
||||
|
||||
function changeService(nextService: AnalyticsService) {
|
||||
setService(nextService)
|
||||
setGroupBy(GROUP_BY[nextService][0].value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Test & observe</span>
|
||||
<h1>Analytics</h1>
|
||||
<p>Pick a service first, then choose how to break it down — by DLT sender, voice socket name, DID number, or OTP project.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel analytics-toolbar">
|
||||
<div className="analytics-toolbar-row">
|
||||
<div className="analytics-toggle-group">
|
||||
<span className="analytics-toggle-label">Period</span>
|
||||
<div className="analytics-segmented">
|
||||
{PERIODS.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={period === item.value ? 'active' : ''}
|
||||
onClick={() => setPeriod(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<small>{PERIODS.find((item) => item.value === period)?.hint}</small>
|
||||
</div>
|
||||
|
||||
<div className="analytics-toggle-group">
|
||||
<span className="analytics-toggle-label">Service</span>
|
||||
<div className="analytics-segmented analytics-segmented-wrap">
|
||||
{SERVICES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={service === item.value ? 'active' : ''}
|
||||
onClick={() => changeService(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<small>{SERVICES.find((item) => item.value === service)?.hint}</small>
|
||||
</div>
|
||||
|
||||
<label className="logs-field analytics-group-field">
|
||||
<span>Break down by</span>
|
||||
<select value={groupBy} onChange={(event) => setGroupBy(event.target.value as AnalyticsGroupBy)}>
|
||||
{groupOptions.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<small>{groupOptions.find((item) => item.value === groupBy)?.hint}</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="explorer-loading">Loading analytics…</p>
|
||||
) : data && (
|
||||
<>
|
||||
<section className="analytics-summary-grid">
|
||||
<SummaryCard label="Total SMS" value={data.totals.sms} tone="sms" />
|
||||
<SummaryCard label="Total inbound" value={data.totals.inbound} tone="inbound" />
|
||||
<SummaryCard label="Total outbound" value={data.totals.outbound} tone="outbound" />
|
||||
<SummaryCard label="Total OTPs" value={data.totals.otp} tone="otp" />
|
||||
</section>
|
||||
|
||||
<div className="analytics-grid">
|
||||
<section className="panel chart-panel analytics-chart-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>All services over time</h2>
|
||||
<p>Grouped bar chart for SMS, inbound, outbound, and OTP volumes</p>
|
||||
</div>
|
||||
<div className="legend">
|
||||
<span><i style={{ background: SERVICE_COLORS.sms }} /> SMS</span>
|
||||
<span><i style={{ background: SERVICE_COLORS.inbound }} /> Inbound</span>
|
||||
<span><i style={{ background: SERVICE_COLORS.outbound }} /> Outbound</span>
|
||||
<span><i style={{ background: SERVICE_COLORS.otp }} /> OTP</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-wrap analytics-chart-wrap">
|
||||
{hasTimeline ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.timeline} barGap={4} barCategoryGap={timelineGap}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e9edf4" />
|
||||
<XAxis dataKey="label" axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} />
|
||||
<YAxis allowDecimals={false} axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} width={38} />
|
||||
<Tooltip content={<UsageTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="sms" name="SMS" fill={SERVICE_COLORS.sms} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="inbound" name="Inbound" fill={SERVICE_COLORS.inbound} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="outbound" name="Outbound" fill={SERVICE_COLORS.outbound} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="otp" name="OTP" fill={SERVICE_COLORS.otp} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="analytics-empty">No usage recorded for this period yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel chart-panel analytics-chart-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>{breakdownTitle(service, groupBy)}</h2>
|
||||
<p>Top 10 by volume in the selected window</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`chart-wrap analytics-chart-wrap ${breakdownChartData.length <= 2 ? 'is-sparse' : ''}`}>
|
||||
{hasBreakdown ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={breakdownChartData} barGap={4} barCategoryGap={breakdownGap}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e9edf4" />
|
||||
<XAxis
|
||||
dataKey="displayLabel"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#8a96a8', fontSize: 11 }}
|
||||
interval={0}
|
||||
angle={breakdownChartData.length > 4 ? -18 : 0}
|
||||
textAnchor={breakdownChartData.length > 4 ? 'end' : 'middle'}
|
||||
height={breakdownChartData.length > 4 ? 58 : 32}
|
||||
/>
|
||||
<YAxis allowDecimals={false} axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} width={38} />
|
||||
<Tooltip content={<UsageTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
name={SERVICES.find((item) => item.value === service)?.label ?? 'Count'}
|
||||
fill={SERVICE_COLORS[service]}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="analytics-empty">No {service} data for this breakdown in the selected period.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
frontend/src/features/auth/AccountSettings.tsx
Normal file
57
frontend/src/features/auth/AccountSettings.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Check, KeyRound, ShieldCheck } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { authApi } from '../../lib/api'
|
||||
import { useAuth } from './auth-context'
|
||||
|
||||
export function AccountSettings() {
|
||||
const { user, setUser } = useAuth()
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setMessage('')
|
||||
if (password !== confirmation) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const updated = await authApi.setPassword(password, currentPassword)
|
||||
setUser(updated)
|
||||
setCurrentPassword('')
|
||||
setPassword('')
|
||||
setConfirmation('')
|
||||
setMessage(updated.has_password ? 'Your password has been saved.' : 'Password configured.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to save password')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-page">
|
||||
<div className="account-heading"><span className="eyebrow">Account</span><h1>Sign-in & security</h1><p>Manage how you access your Urmi workspace.</p></div>
|
||||
<div className="account-grid">
|
||||
<section className="panel account-profile">
|
||||
<div className="account-avatar">{user?.avatar_url ? <img src={user.avatar_url} alt="" /> : user?.name.slice(0, 2).toUpperCase()}</div>
|
||||
<div><h2>{user?.name}</h2><p>{user?.email}</p><span><ShieldCheck size={14} /> {user?.email_verified ? 'Verified email' : 'Email verification pending'}</span></div>
|
||||
</section>
|
||||
<section className="panel account-password">
|
||||
<div className="panel-heading"><div><h2>{user?.has_password ? 'Change password' : 'Add a password'}</h2><p>{user?.has_password ? 'Update your first-party Urmi credential.' : 'You signed up with Google. Add a password to use either sign-in method.'}</p></div><KeyRound size={20} /></div>
|
||||
<form onSubmit={submit}>
|
||||
{user?.has_password && <label><span>Current password</span><input required type="password" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} placeholder="Your current password" /></label>}
|
||||
<label><span>New password</span><input required minLength={10} type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="At least 10 characters" /></label>
|
||||
<label><span>Confirm password</span><input required minLength={10} type="password" autoComplete="new-password" value={confirmation} onChange={(e) => setConfirmation(e.target.value)} placeholder="Repeat password" /></label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
{message && <p className="account-success"><Check size={14} /> {message}</p>}
|
||||
<button className="primary-button" type="submit">Save password</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
frontend/src/features/auth/AuthContext.tsx
Normal file
25
frontend/src/features/auth/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { authApi, type AuthUser } from '../../lib/api'
|
||||
import { AuthContext } from './auth-context'
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.me().then(setUser).catch(() => setUser(null)).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({
|
||||
user,
|
||||
loading,
|
||||
setUser,
|
||||
logout: async () => {
|
||||
await authApi.logout()
|
||||
setUser(null)
|
||||
},
|
||||
}), [user, loading])
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
89
frontend/src/features/auth/AuthPage.tsx
Normal file
89
frontend/src/features/auth/AuthPage.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { ArrowLeft, ArrowRight, Check, Eye, EyeOff, LockKeyhole, Mail, User } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { authApi } from '../../lib/api'
|
||||
import { useAuth } from './auth-context'
|
||||
|
||||
export function AuthPage({ initialMode }: { initialMode: 'login' | 'register' }) {
|
||||
const [mode, setMode] = useState(initialMode)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const { setUser } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const user = mode === 'login'
|
||||
? await authApi.login(email, password)
|
||||
: await authApi.register(name, email, password)
|
||||
setUser(user)
|
||||
const destination = (location.state as { from?: string } | null)?.from ?? '/console'
|
||||
navigate(destination, { replace: true })
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to continue')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function switchMode(nextMode: 'login' | 'register') {
|
||||
setMode(nextMode)
|
||||
setError('')
|
||||
navigate(nextMode === 'login' ? '/login' : '/register', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-visual">
|
||||
<Link to="/" className="auth-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link>
|
||||
<div className="auth-message">
|
||||
<span>SMS FOR AI AGENTS</span>
|
||||
<h1>From agent decision to human action.</h1>
|
||||
<p>Secure messaging infrastructure with delivery, compliance, and observability built in.</p>
|
||||
<div><span><Check size={15} /> Unified agent messaging API</span><span><Check size={15} /> India-ready compliance</span><span><Check size={15} /> Complete event visibility</span></div>
|
||||
</div>
|
||||
<small>Build → Test → Deploy → Observe</small>
|
||||
</div>
|
||||
|
||||
<main className="auth-main">
|
||||
<Link to="/" className="back-home"><ArrowLeft size={15} /> Back to Urmi</Link>
|
||||
<section className="auth-card">
|
||||
<div className="auth-tabs">
|
||||
<button className={mode === 'login' ? 'active' : ''} onClick={() => switchMode('login')}>Log in</button>
|
||||
<button className={mode === 'register' ? 'active' : ''} onClick={() => switchMode('register')}>Create account</button>
|
||||
</div>
|
||||
<div className="auth-heading">
|
||||
<h2>{mode === 'login' ? 'Welcome back' : 'Start building with Urmi'}</h2>
|
||||
<p>{mode === 'login' ? 'Access your agent messaging workspace.' : 'Create your workspace and send your first test message.'}</p>
|
||||
</div>
|
||||
|
||||
<a className="google-button" href={authApi.googleUrl}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#4285F4" d="M21.6 12.23c0-.71-.06-1.39-.18-2.05H12v3.87h5.38a4.6 4.6 0 0 1-2 3.02v2.51h3.24c1.9-1.75 2.98-4.33 2.98-7.35Z"/><path fill="#34A853" d="M12 22c2.7 0 4.98-.9 6.63-2.42l-3.24-2.51c-.9.6-2.05.96-3.39.96-2.61 0-4.82-1.76-5.61-4.13H3.04v2.59A10 10 0 0 0 12 22Z"/><path fill="#FBBC05" d="M6.39 13.9A6.01 6.01 0 0 1 6.08 12c0-.66.11-1.3.31-1.9V7.51H3.04A10 10 0 0 0 2 12c0 1.61.38 3.14 1.04 4.49l3.35-2.59Z"/><path fill="#EA4335" d="M12 5.97c1.47 0 2.79.51 3.83 1.5l2.87-2.88A9.64 9.64 0 0 0 12 2a10 10 0 0 0-8.96 5.51l3.35 2.59C7.18 7.73 9.39 5.97 12 5.97Z"/></svg>
|
||||
Continue with Google
|
||||
</a>
|
||||
|
||||
<div className="auth-divider"><span>or continue with email</span></div>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
{mode === 'register' && <label><span>Name</span><div><User size={17} /><input required minLength={2} autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" /></div></label>}
|
||||
<label><span>Email address</span><div><Mail size={17} /><input required type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@company.com" /></div></label>
|
||||
<label><span>Password</span><div><LockKeyhole size={17} /><input required minLength={mode === 'register' ? 10 : 1} type={showPassword ? 'text' : 'password'} autoComplete={mode === 'login' ? 'current-password' : 'new-password'} value={password} onChange={(e) => setPassword(e.target.value)} placeholder={mode === 'register' ? 'At least 10 characters' : 'Your password'} /><button type="button" aria-label="Toggle password visibility" onClick={() => setShowPassword(!showPassword)}>{showPassword ? <EyeOff size={17} /> : <Eye size={17} />}</button></div></label>
|
||||
{mode === 'login' && <div className="forgot-row"><span>Secure, encrypted sign in</span><button type="button">Forgot password?</button></div>}
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<button className="auth-submit" disabled={submitting}>{submitting ? 'Please wait…' : mode === 'login' ? 'Log in to Urmi' : 'Create my account'} {!submitting && <ArrowRight size={17} />}</button>
|
||||
</form>
|
||||
<p className="auth-terms">By continuing, you agree to Urmi’s Terms of Service and Privacy Policy.</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
frontend/src/features/auth/ProtectedRoute.tsx
Normal file
11
frontend/src/features/auth/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from './auth-context'
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading Urmi…</div>
|
||||
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
return <Outlet />
|
||||
}
|
||||
17
frontend/src/features/auth/auth-context.ts
Normal file
17
frontend/src/features/auth/auth-context.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createContext, useContext } from 'react'
|
||||
import type { AuthUser } from '../../lib/api'
|
||||
|
||||
export type AuthContextValue = {
|
||||
user: AuthUser | null
|
||||
loading: boolean
|
||||
setUser: (user: AuthUser | null) => void
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) throw new Error('useAuth must be used inside AuthProvider')
|
||||
return context
|
||||
}
|
||||
170
frontend/src/features/campaigns/CampaignDetailPage.tsx
Normal file
170
frontend/src/features/campaigns/CampaignDetailPage.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { ArrowLeft, Play, Upload } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { campaignsApi, type Campaign, type CampaignCsvUpload } from '../../lib/api'
|
||||
|
||||
function uploadStatusLabel(status: CampaignCsvUpload['status']) {
|
||||
switch (status) {
|
||||
case 'validated': return 'Ready to run'
|
||||
case 'running': return 'Running'
|
||||
case 'completed': return 'Run completed'
|
||||
case 'failed': return 'Failed'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
export function CampaignDetailPage() {
|
||||
const { campaignId = '' } = useParams()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null)
|
||||
const [uploads, setUploads] = useState<CampaignCsvUpload[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [runningId, setRunningId] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
async function loadCampaign() {
|
||||
const [campaignData, uploadList] = await Promise.all([
|
||||
campaignsApi.get(campaignId),
|
||||
campaignsApi.listCsvUploads(campaignId),
|
||||
])
|
||||
setCampaign(campaignData)
|
||||
setUploads(uploadList)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadCampaign()
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load campaign'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [campaignId])
|
||||
|
||||
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setUploading(true)
|
||||
try {
|
||||
const upload = await campaignsApi.uploadCsv(campaignId, file)
|
||||
setUploads((current) => [upload, ...current])
|
||||
setSuccess(
|
||||
campaign?.run_mode === 'on_upload'
|
||||
? `"${upload.original_filename}" validated and automation run recorded (${upload.total_rows} rows).`
|
||||
: `"${upload.original_filename}" validated successfully (${upload.total_rows} rows).`,
|
||||
)
|
||||
await loadCampaign()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'CSV validation failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function runCsv(uploadId: string) {
|
||||
setRunningId(uploadId)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
try {
|
||||
await campaignsApi.runCsv(campaignId, uploadId)
|
||||
await loadCampaign()
|
||||
setSuccess('CSV run recorded. Send functionality will be wired with batch jobs later.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to run CSV')
|
||||
} finally {
|
||||
setRunningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading campaign…</div>
|
||||
if (!campaign) return <section className="panel"><p className="auth-error">{error || 'Campaign not found'}</p></section>
|
||||
|
||||
return (
|
||||
<div className="campaign-detail-page">
|
||||
<Link className="text-button campaign-back" to="/console/campaigns">
|
||||
<ArrowLeft size={14} /> Back to campaigns
|
||||
</Link>
|
||||
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Campaign · {campaign.name}</span>
|
||||
<h1>CSV uploads</h1>
|
||||
<p>
|
||||
{campaign.sender_id} · {campaign.template_name} · number column <code>{campaign.number_column}</code>
|
||||
{campaign.variable_columns.length > 0 && (
|
||||
<> · variables mapped to <code>{campaign.variable_columns.join(', ')}</code></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="panel campaign-upload-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Upload CSV</h2>
|
||||
<p>File is validated against <strong>{campaign.template_name}</strong> before it is stored.</p>
|
||||
</div>
|
||||
<Upload size={19} />
|
||||
</div>
|
||||
<div className="campaign-upload-actions">
|
||||
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={handleFileChange} hidden />
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={15} /> {uploading ? 'Validating…' : 'Choose CSV file'}
|
||||
</button>
|
||||
<small>
|
||||
Required headers: <code>{campaign.number_column}</code>
|
||||
{campaign.variable_columns.map((column) => (
|
||||
<span key={column}>, <code>{column}</code></span>
|
||||
))}
|
||||
</small>
|
||||
</div>
|
||||
<p className="campaign-template-preview">{campaign.template_text}</p>
|
||||
{success && <p className="campaign-success">{success}</p>}
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</section>
|
||||
|
||||
<section className="panel campaigns-table-panel">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Uploaded files</h2><p>{uploads.length} CSV file{uploads.length === 1 ? '' : 's'} in this automation</p></div>
|
||||
</div>
|
||||
|
||||
{uploads.length === 0 ? (
|
||||
<div className="campaigns-empty">
|
||||
<strong>No CSV files yet</strong>
|
||||
<span>Upload a file to validate recipients against the campaign template.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="campaigns-table campaign-csv-table">
|
||||
<div className="campaigns-table-head">
|
||||
<span>File</span>
|
||||
<span>Rows</span>
|
||||
<span>Status</span>
|
||||
<span />
|
||||
</div>
|
||||
{uploads.map((upload) => (
|
||||
<div className="campaigns-table-row" key={upload.id}>
|
||||
<strong>{upload.original_filename}</strong>
|
||||
<span>{upload.total_rows}</span>
|
||||
<span className={`doc-status ${upload.status === 'completed' ? 'approved' : upload.status}`}>
|
||||
{uploadStatusLabel(upload.status)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={runningId === upload.id || upload.status === 'running'}
|
||||
onClick={() => runCsv(upload.id)}
|
||||
>
|
||||
<Play size={14} /> {runningId === upload.id ? 'Running…' : 'Run'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
351
frontend/src/features/campaigns/CampaignsPage.tsx
Normal file
351
frontend/src/features/campaigns/CampaignsPage.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import { Play, Plus, Upload, Zap } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
campaignsApi,
|
||||
messagingApi,
|
||||
type Campaign,
|
||||
type CampaignRunMode,
|
||||
type SenderConfig,
|
||||
type TemplateConfig,
|
||||
} from '../../lib/api'
|
||||
import { templateVariableCount } from '../messaging/templateUtils'
|
||||
|
||||
const RUN_MODE_LABELS: Record<CampaignRunMode, string> = {
|
||||
manual: 'Run CSV manually',
|
||||
on_upload: 'Run immediately on CSV upload',
|
||||
scheduled: 'Schedule the run',
|
||||
}
|
||||
|
||||
function runModeSummary(campaign: Campaign) {
|
||||
if (campaign.run_mode === 'scheduled' && campaign.scheduled_at) {
|
||||
return `Scheduled · ${new Date(campaign.scheduled_at).toLocaleString()}`
|
||||
}
|
||||
return RUN_MODE_LABELS[campaign.run_mode]
|
||||
}
|
||||
|
||||
function CreateCampaignModal({
|
||||
senders,
|
||||
templates,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
senders: SenderConfig[]
|
||||
templates: TemplateConfig[]
|
||||
onClose: () => void
|
||||
onCreated: (campaign: Campaign) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [senderConfigId, setSenderConfigId] = useState('')
|
||||
const [templateId, setTemplateId] = useState('')
|
||||
const [numberColumn, setNumberColumn] = useState('phone')
|
||||
const [variableColumns, setVariableColumns] = useState<string[]>([])
|
||||
const [runMode, setRunMode] = useState<CampaignRunMode>('manual')
|
||||
const [scheduledAt, setScheduledAt] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const approvedSenders = useMemo(
|
||||
() => senders.filter((sender) => sender.status === 'approved'),
|
||||
[senders],
|
||||
)
|
||||
|
||||
const senderTemplates = useMemo(
|
||||
() => approvedSenders.length && senderConfigId
|
||||
? templates.filter(
|
||||
(template) => template.status === 'approved' && template.sender_config_id === senderConfigId,
|
||||
)
|
||||
: [],
|
||||
[approvedSenders.length, senderConfigId, templates],
|
||||
)
|
||||
|
||||
const selectedTemplate = senderTemplates.find((template) => template.id === templateId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (approvedSenders.length === 0) return
|
||||
setSenderConfigId((current) => current || approvedSenders[0].id)
|
||||
}, [approvedSenders])
|
||||
|
||||
useEffect(() => {
|
||||
if (senderTemplates.length === 0) {
|
||||
setTemplateId('')
|
||||
return
|
||||
}
|
||||
setTemplateId((current) => (senderTemplates.some((item) => item.id === current) ? current : senderTemplates[0].id))
|
||||
}, [senderTemplates])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) {
|
||||
setVariableColumns([])
|
||||
return
|
||||
}
|
||||
const count = templateVariableCount(selectedTemplate.template_text)
|
||||
setVariableColumns(Array.from({ length: count }, (_, index) => (index === 0 ? 'var1' : `var${index + 1}`)))
|
||||
}, [selectedTemplate?.id, selectedTemplate?.template_text])
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
if (!name.trim()) {
|
||||
setError('Campaign name is required')
|
||||
return
|
||||
}
|
||||
if (!numberColumn.trim()) {
|
||||
setError('Number column name is required')
|
||||
return
|
||||
}
|
||||
if (runMode === 'scheduled' && !scheduledAt) {
|
||||
setError('Pick a schedule time')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const campaign = await campaignsApi.create({
|
||||
name: name.trim(),
|
||||
sender_config_id: senderConfigId,
|
||||
template_id: templateId,
|
||||
number_column: numberColumn.trim(),
|
||||
variable_columns: variableColumns.map((column) => column.trim()),
|
||||
run_mode: runMode,
|
||||
scheduled_at: runMode === 'scheduled' ? new Date(scheduledAt).toISOString() : null,
|
||||
})
|
||||
onCreated(campaign)
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create campaign')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<form className="modal-card campaign-modal" onSubmit={submit} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Create campaign</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="campaign-form">
|
||||
<section className="campaign-form-section">
|
||||
<h3 className="campaign-form-section-title">Campaign details</h3>
|
||||
<label className="campaign-field">
|
||||
<span>Campaign name</span>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="March OTP blast" required />
|
||||
</label>
|
||||
<label className="campaign-field">
|
||||
<span>Sender ID</span>
|
||||
<select value={senderConfigId} onChange={(event) => setSenderConfigId(event.target.value)} disabled={approvedSenders.length === 0}>
|
||||
{approvedSenders.length === 0 && <option value="">No approved senders</option>}
|
||||
{approvedSenders.map((sender) => (
|
||||
<option key={sender.id} value={sender.id}>{sender.sender_id} · {sender.sender_type}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="campaign-field">
|
||||
<span>Template</span>
|
||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)} disabled={senderTemplates.length === 0}>
|
||||
{senderTemplates.length === 0 && <option value="">No approved templates</option>}
|
||||
{senderTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>{template.template_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedTemplate && (
|
||||
<p className="campaign-template-preview">{selectedTemplate.template_text}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="campaign-form-section">
|
||||
<h3 className="campaign-form-section-title">CSV column mapping</h3>
|
||||
<label className="campaign-field">
|
||||
<span>Number column</span>
|
||||
<input value={numberColumn} onChange={(event) => setNumberColumn(event.target.value)} placeholder="phone" required />
|
||||
<small>Header name in your CSV that contains recipient numbers.</small>
|
||||
</label>
|
||||
{variableColumns.length > 0 && (
|
||||
<fieldset className="campaign-variable-map">
|
||||
<legend>Template variables → CSV columns</legend>
|
||||
{variableColumns.map((column, index) => (
|
||||
<label className="campaign-field" key={`var-${index}`}>
|
||||
<span>Variable {index + 1}</span>
|
||||
<input
|
||||
value={column}
|
||||
onChange={(event) => {
|
||||
const next = [...variableColumns]
|
||||
next[index] = event.target.value
|
||||
setVariableColumns(next)
|
||||
}}
|
||||
placeholder={`column_${index + 1}`}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="campaign-form-section">
|
||||
<h3 className="campaign-form-section-title">Run settings</h3>
|
||||
<fieldset className="campaign-run-mode-group">
|
||||
<legend>When should this automation run?</legend>
|
||||
{(Object.keys(RUN_MODE_LABELS) as CampaignRunMode[]).map((mode) => (
|
||||
<label className="campaign-run-mode-option" key={mode}>
|
||||
<input type="radio" name="run_mode" checked={runMode === mode} onChange={() => setRunMode(mode)} />
|
||||
<span>{RUN_MODE_LABELS[mode]}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{runMode === 'scheduled' && (
|
||||
<label className="campaign-field campaign-schedule-field">
|
||||
<span>Scheduled time</span>
|
||||
<input type="datetime-local" value={scheduledAt} onChange={(event) => setScheduledAt(event.target.value)} required />
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="primary-button" disabled={saving || approvedSenders.length === 0}>
|
||||
{saving ? 'Creating…' : 'Create campaign'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CampaignsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([])
|
||||
const [senders, setSenders] = useState<SenderConfig[]>([])
|
||||
const [templates, setTemplates] = useState<TemplateConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [runningId, setRunningId] = useState<string | null>(null)
|
||||
|
||||
function loadData() {
|
||||
return Promise.all([
|
||||
campaignsApi.list(),
|
||||
messagingApi.listSenders(),
|
||||
messagingApi.listTemplates(),
|
||||
]).then(([campaignList, senderList, templateList]) => {
|
||||
setCampaigns(campaignList)
|
||||
setSenders(senderList)
|
||||
setTemplates(templateList)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
.catch(() => setError('Unable to load campaigns'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function runAutomation(campaignId: string) {
|
||||
setRunningId(campaignId)
|
||||
setError('')
|
||||
try {
|
||||
await campaignsApi.runAutomation(campaignId)
|
||||
await loadData()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to run automation')
|
||||
} finally {
|
||||
setRunningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading campaigns…</div>
|
||||
|
||||
return (
|
||||
<div className="campaigns-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Communicate</span>
|
||||
<h1>Campaigns</h1>
|
||||
<p>Define CSV automations linked to approved senders and templates. Upload and run when ready.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel campaigns-create-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Create automation</h2>
|
||||
<p>Map CSV columns to your template, then upload recipient files.</p>
|
||||
</div>
|
||||
<button className="primary-button" onClick={() => setModalOpen(true)}>
|
||||
<Plus size={15} /> Create campaign
|
||||
</button>
|
||||
</div>
|
||||
<p className="campaigns-note">
|
||||
Send is not enabled yet — runs are recorded as stubs. See <code>docs/BATCH_JOBS_TODO.md</code> for the batch job plan.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel campaigns-table-panel">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Your campaigns</h2><p>{campaigns.length} automation{campaigns.length === 1 ? '' : 's'}</p></div>
|
||||
<Zap size={19} />
|
||||
</div>
|
||||
|
||||
{campaigns.length === 0 ? (
|
||||
<div className="campaigns-empty">
|
||||
<strong>No campaigns yet</strong>
|
||||
<span>Create one to define CSV column mappings and run mode.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="campaigns-table">
|
||||
<div className="campaigns-table-head">
|
||||
<span>Campaign</span>
|
||||
<span>Sender · Template</span>
|
||||
<span>Run mode</span>
|
||||
<span>CSV files</span>
|
||||
<span />
|
||||
</div>
|
||||
{campaigns.map((campaign) => (
|
||||
<div className="campaigns-table-row" key={campaign.id}>
|
||||
<div>
|
||||
<strong>{campaign.name}</strong>
|
||||
<small>{campaign.number_column} · {campaign.variable_columns.join(', ') || 'no variables'}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{campaign.sender_id}</strong>
|
||||
<small>{campaign.template_name}</small>
|
||||
</div>
|
||||
<span className="campaign-run-mode-label">{runModeSummary(campaign)}</span>
|
||||
<span>{campaign.csv_upload_count} file{campaign.csv_upload_count === 1 ? '' : 's'}</span>
|
||||
<div className="campaign-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(`/console/campaigns/${campaign.id}`)}
|
||||
>
|
||||
<Upload size={14} /> Upload CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={runningId === campaign.id || campaign.csv_upload_count === 0}
|
||||
onClick={() => runAutomation(campaign.id)}
|
||||
>
|
||||
<Play size={14} /> {runningId === campaign.id ? 'Running…' : 'Run automation'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</section>
|
||||
|
||||
{modalOpen && (
|
||||
<CreateCampaignModal
|
||||
senders={senders}
|
||||
templates={templates}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onCreated={(campaign) => setCampaigns((current) => [campaign, ...current])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
frontend/src/features/compliance/ComplianceProvider.tsx
Normal file
49
frontend/src/features/compliance/ComplianceProvider.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { complianceApi } from '../../lib/api'
|
||||
import {
|
||||
ComplianceContext,
|
||||
COMPLIANCE_ONLY_PATHS,
|
||||
useCompliance,
|
||||
type ComplianceStatus,
|
||||
} from './compliance-context'
|
||||
|
||||
export { useCompliance } from './compliance-context'
|
||||
|
||||
export function ComplianceProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<ComplianceStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const data = await complianceApi.status()
|
||||
setStatus(data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setLoading(false))
|
||||
const onFocus = () => { refresh().catch(() => undefined) }
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [refresh])
|
||||
|
||||
const value = useMemo(() => ({ status, loading, refresh }), [status, loading, refresh])
|
||||
|
||||
return <ComplianceContext.Provider value={value}>{children}</ComplianceContext.Provider>
|
||||
}
|
||||
|
||||
export function ComplianceGate() {
|
||||
const { status, loading } = useCompliance()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) return <div className="auth-loading">Checking compliance status…</div>
|
||||
|
||||
const unlocked = status?.console_unlocked ?? false
|
||||
const allowed = COMPLIANCE_ONLY_PATHS.includes(location.pathname) || location.pathname === '/console/account'
|
||||
|
||||
if (!unlocked && !allowed) {
|
||||
return <Navigate to="/console/dlt" replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
204
frontend/src/features/compliance/DltPage.tsx
Normal file
204
frontend/src/features/compliance/DltPage.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { CheckCircle2, Copy, Download, FileCheck2, FileUp, Upload } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { dltApi, type DltApplication, type DltDocument } from '../../lib/api'
|
||||
import { useCompliance } from './compliance-context'
|
||||
|
||||
const DOC_LABELS: Record<string, string> = {
|
||||
proof_of_directorship: 'Proof of directorship',
|
||||
pe_tm_chain_binding: 'PE TM chain binding status',
|
||||
}
|
||||
|
||||
function statusLabel(status: string, documentType?: string) {
|
||||
if (status === 'approved') return 'Approved'
|
||||
if (status === 'pending') return 'Pending review'
|
||||
if (status === 'rejected') return 'Rejected'
|
||||
if (documentType === 'pe_tm_chain_binding') return 'Not submitted'
|
||||
return 'Not uploaded'
|
||||
}
|
||||
|
||||
function ChainBindingRow({
|
||||
doc,
|
||||
chainId,
|
||||
locked,
|
||||
onConfirm,
|
||||
onDownloadLoa,
|
||||
}: {
|
||||
doc: DltDocument
|
||||
chainId: string
|
||||
locked: boolean
|
||||
onConfirm: () => void
|
||||
onDownloadLoa: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copyId() {
|
||||
await navigator.clipboard.writeText(chainId)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kyc-doc-row chain-binding-row">
|
||||
<div>
|
||||
<strong>{DOC_LABELS.pe_tm_chain_binding}</strong>
|
||||
<span>Use this Telemarketer chain ID on the DLT portal after downloading the LOA.</span>
|
||||
<code className="chain-binding-id">{chainId}</code>
|
||||
{doc.review_note && <small>{doc.review_note}</small>}
|
||||
</div>
|
||||
<span className={`doc-status ${doc.status}`}>{statusLabel(doc.status, doc.document_type)}</span>
|
||||
<div className="chain-binding-actions">
|
||||
<button type="button" className="secondary-button" onClick={onDownloadLoa}>
|
||||
<Download size={15} /> Download LOA
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={copyId}>
|
||||
<Copy size={15} /> {copied ? 'Copied' : 'Copy ID'}
|
||||
</button>
|
||||
{!locked && doc.status !== 'approved' && doc.status !== 'pending' && (
|
||||
<button type="button" className="primary-button" onClick={onConfirm}>Confirm binding</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DltPage() {
|
||||
const { refresh: refreshCompliance } = useCompliance()
|
||||
const [dlt, setDlt] = useState<DltApplication | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
async function refresh() {
|
||||
const data = await dltApi.me()
|
||||
setDlt(data)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => setError('Unable to load DLT details')).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function uploadDocument(documentType: string, file: File | undefined) {
|
||||
if (!file) return
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
const data = await dltApi.upload(documentType, file)
|
||||
setDlt(data)
|
||||
await refreshCompliance()
|
||||
setMessage(`${DOC_LABELS[documentType]} uploaded.`)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Upload failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmChainBinding() {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
const data = await dltApi.confirmChainBinding()
|
||||
setDlt(data)
|
||||
await refreshCompliance()
|
||||
setMessage('PE TM chain binding submitted for admin review.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to confirm chain binding')
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadLoa() {
|
||||
const response = await fetch(dltApi.loaUrl, { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
setError('Unable to download LOA document')
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'Updated LOA.docx'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function submitForReview() {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
const data = await dltApi.submit()
|
||||
setDlt(data)
|
||||
await refreshCompliance()
|
||||
setMessage('DLT application submitted for admin review.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to submit DLT application')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading DLT…</div>
|
||||
|
||||
const locked = dlt?.status === 'approved'
|
||||
|
||||
return (
|
||||
<div className="kyc-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Compliance</span>
|
||||
<h1>DLT registration</h1>
|
||||
<p>Complete DLT compliance steps for telecom registry verification.</p>
|
||||
</div>
|
||||
|
||||
{dlt?.completed && (
|
||||
<div className="kyc-complete-banner">
|
||||
<CheckCircle2 size={22} />
|
||||
<div><strong>DLT completed</strong><span>All documents have been approved by Urmi compliance.</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dlt?.completed && (
|
||||
<div className="compliance-lock-banner">
|
||||
<FileCheck2 size={20} />
|
||||
<div>
|
||||
<strong>Complete DLT to unlock the workspace</strong>
|
||||
<span>Until DLT is approved, only KYC and DLT are available in the console.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="panel kyc-documents">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Required DLT documents</h2><p>Upload directorship proof and confirm PE TM chain binding.</p></div>
|
||||
<Upload size={20} />
|
||||
</div>
|
||||
{dlt?.documents.map((doc) => (
|
||||
doc.document_type === 'pe_tm_chain_binding' ? (
|
||||
<ChainBindingRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
chainId={dlt.pe_tm_chain_binding_id}
|
||||
locked={locked}
|
||||
onConfirm={confirmChainBinding}
|
||||
onDownloadLoa={downloadLoa}
|
||||
/>
|
||||
) : (
|
||||
<div className="kyc-doc-row" key={doc.id}>
|
||||
<div>
|
||||
<strong>{DOC_LABELS[doc.document_type] ?? doc.document_type}</strong>
|
||||
<span>{doc.original_filename ?? 'No file uploaded'}</span>
|
||||
{doc.review_note && <small>{doc.review_note}</small>}
|
||||
</div>
|
||||
<span className={`doc-status ${doc.status}`}>{statusLabel(doc.status, doc.document_type)}</span>
|
||||
{!locked && (
|
||||
<label className="upload-button">
|
||||
<FileUp size={15} /> Upload
|
||||
<input type="file" accept=".pdf,.png,.jpg,.jpeg,.doc,.docx" onChange={(e) => uploadDocument(doc.document_type, e.target.files?.[0])} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
{message && <p className="account-success"><CheckCircle2 size={14} /> {message}</p>}
|
||||
{!locked && (
|
||||
<button className="primary-button" onClick={submitForReview}>Submit for compliance review</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
frontend/src/features/compliance/KycPage.tsx
Normal file
170
frontend/src/features/compliance/KycPage.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
CheckCircle2, Download, FileUp, ShieldCheck, Upload,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { kycApi, type KycApplication } from '../../lib/api'
|
||||
|
||||
const DOC_LABELS: Record<string, string> = {
|
||||
business_pan_card: 'Business PAN card',
|
||||
business_documents: 'Business documents (incorporation / shop act)',
|
||||
gst_certificate: 'GST certificate (3-page with tax invoice)',
|
||||
kyc_form: 'Signed KYC form (Pvt Ltd)',
|
||||
}
|
||||
|
||||
const PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/
|
||||
const GST_RE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'approved') return 'Approved'
|
||||
if (status === 'pending') return 'Pending review'
|
||||
if (status === 'rejected') return 'Rejected'
|
||||
return 'Not uploaded'
|
||||
}
|
||||
|
||||
export function KycPage() {
|
||||
const [kyc, setKyc] = useState<KycApplication | null>(null)
|
||||
const [pan, setPan] = useState('')
|
||||
const [gst, setGst] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function refresh() {
|
||||
const data = await kycApi.me()
|
||||
setKyc(data)
|
||||
setPan(data.pan_number ?? '')
|
||||
setGst(data.gst_number ?? '')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => setError('Unable to load KYC details')).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function saveDetails(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setMessage('')
|
||||
const normalizedPan = pan.trim().toUpperCase()
|
||||
const normalizedGst = gst.trim().toUpperCase()
|
||||
if (!PAN_RE.test(normalizedPan)) {
|
||||
setError('PAN must match format ABCDE1234F')
|
||||
return
|
||||
}
|
||||
if (!GST_RE.test(normalizedGst)) {
|
||||
setError('GST must be a valid 15-character Indian GSTIN')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await kycApi.update(normalizedPan, normalizedGst)
|
||||
setKyc(data)
|
||||
setMessage('Business details saved.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to save details')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadDocument(documentType: string, file: File | undefined) {
|
||||
if (!file) return
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
const data = await kycApi.upload(documentType, file)
|
||||
setKyc(data)
|
||||
setMessage(`${DOC_LABELS[documentType]} uploaded.`)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Upload failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
const response = await fetch(kycApi.templateUrl, { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
setError('Unable to download KYC template')
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'KYC Form - Pvt Ltd Client.docx'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function submitForReview() {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
const data = await kycApi.submit()
|
||||
setKyc(data)
|
||||
setMessage('KYC submitted for admin review.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to submit KYC')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading KYC…</div>
|
||||
|
||||
return (
|
||||
<div className="kyc-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Compliance</span>
|
||||
<h1>Business KYC</h1>
|
||||
<p>Upload your business documents for telecom compliance verification.</p>
|
||||
</div>
|
||||
|
||||
{kyc?.completed && (
|
||||
<div className="kyc-complete-banner">
|
||||
<CheckCircle2 size={22} />
|
||||
<div><strong>KYC completed</strong><span>All documents have been approved by Urmi compliance.</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kyc-grid">
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><h2>Business details</h2><p>PAN and GST are required before uploading documents.</p></div><ShieldCheck size={20} /></div>
|
||||
<form className="kyc-form" onSubmit={saveDetails}>
|
||||
<label><span>Business PAN number</span><input value={pan} onChange={(e) => setPan(e.target.value.toUpperCase())} placeholder="ABCDE1234F" maxLength={10} disabled={kyc?.status === 'approved'} /></label>
|
||||
<label><span>Business GST number</span><input value={gst} onChange={(e) => setGst(e.target.value.toUpperCase())} placeholder="27ABCDE1234F1Z5" maxLength={15} disabled={kyc?.status === 'approved'} /></label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
{message && <p className="account-success"><CheckCircle2 size={14} /> {message}</p>}
|
||||
<button className="primary-button" type="submit" disabled={saving || kyc?.status === 'approved'}>{saving ? 'Saving…' : 'Save details'}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><h2>KYC form template</h2><p>Download, complete, sign, and upload the Pvt Ltd KYC form.</p></div><Download size={20} /></div>
|
||||
<button type="button" className="secondary-button full-width" onClick={downloadTemplate}><Download size={15} /> Download KYC Form - Pvt Ltd Client.docx</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="panel kyc-documents">
|
||||
<div className="panel-heading"><div><h2>Required documents</h2><p>Upload PDF, PNG, JPG, DOC, or DOCX up to 10 MB.</p></div><Upload size={20} /></div>
|
||||
{kyc?.documents.map((doc) => (
|
||||
<div className="kyc-doc-row" key={doc.id}>
|
||||
<div>
|
||||
<strong>{DOC_LABELS[doc.document_type] ?? doc.document_type}</strong>
|
||||
<span>{doc.original_filename ?? 'No file uploaded'}</span>
|
||||
{doc.review_note && <small>{doc.review_note}</small>}
|
||||
</div>
|
||||
<span className={`doc-status ${doc.status}`}>{statusLabel(doc.status)}</span>
|
||||
{kyc.status !== 'approved' && (
|
||||
<label className="upload-button">
|
||||
<FileUp size={15} /> Upload
|
||||
<input type="file" accept=".pdf,.png,.jpg,.jpeg,.doc,.docx" onChange={(e) => uploadDocument(doc.document_type, e.target.files?.[0])} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{kyc?.status !== 'approved' && (
|
||||
<button className="primary-button" onClick={submitForReview}>Submit for compliance review</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
frontend/src/features/compliance/compliance-context.ts
Normal file
23
frontend/src/features/compliance/compliance-context.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type ComplianceStatus = {
|
||||
kyc_completed: boolean
|
||||
dlt_completed: boolean
|
||||
console_unlocked: boolean
|
||||
}
|
||||
|
||||
export type ComplianceContextValue = {
|
||||
status: ComplianceStatus | null
|
||||
loading: boolean
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export const ComplianceContext = createContext<ComplianceContextValue | null>(null)
|
||||
|
||||
export function useCompliance() {
|
||||
const context = useContext(ComplianceContext)
|
||||
if (!context) throw new Error('useCompliance must be used inside ComplianceProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export const COMPLIANCE_ONLY_PATHS = ['/console/kyc', '/console/dlt']
|
||||
185
frontend/src/features/dashboard/Dashboard.tsx
Normal file
185
frontend/src/features/dashboard/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import {
|
||||
ArrowRight, Check, ChevronRight, Clock3, Code2, MessageSquareText,
|
||||
Phone, Plus, Send, ShieldCheck, Sparkles, TrendingUp, TriangleAlert,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
import { getDashboard } from '../../lib/api'
|
||||
|
||||
const chartData = [
|
||||
{ day: '14 Aug', calls: 860, messages: 2100 },
|
||||
{ day: '15 Aug', calls: 1040, messages: 2600 },
|
||||
{ day: '16 Aug', calls: 920, messages: 2380 },
|
||||
{ day: '17 Aug', calls: 1180, messages: 3100 },
|
||||
{ day: '18 Aug', calls: 1080, messages: 2820 },
|
||||
{ day: '19 Aug', calls: 1340, messages: 3460 },
|
||||
{ day: '20 Aug', calls: 1243, messages: 3250 },
|
||||
]
|
||||
|
||||
const activityMeta: Record<string, { icon: typeof Phone; tone: string }> = {
|
||||
'call.completed': { icon: Phone, tone: 'blue' },
|
||||
'message.delivered': { icon: MessageSquareText, tone: 'cyan' },
|
||||
'dlt.template.approved': { icon: ShieldCheck, tone: 'violet' },
|
||||
'webhook.retried': { icon: TriangleAlert, tone: 'amber' },
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
return value.toLocaleString('en-IN')
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string) {
|
||||
const diffMs = Date.now() - new Date(value).getTime()
|
||||
const minutes = Math.max(1, Math.round(diffMs / 60_000))
|
||||
if (minutes < 60) return `${minutes} min ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) return `${hours} hr ago`
|
||||
return new Date(value).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
const [metrics, setMetrics] = useState({
|
||||
voice_calls: 1243,
|
||||
messages_sent: 45231,
|
||||
delivery_rate: 98.7,
|
||||
api_requests: 1_200_000,
|
||||
balance: 24820,
|
||||
})
|
||||
const [activities, setActivities] = useState<Array<{
|
||||
icon: typeof Phone
|
||||
title: string
|
||||
detail: string
|
||||
time: string
|
||||
tone: string
|
||||
}>>([
|
||||
{ icon: Phone, title: 'Outbound call completed', detail: '+91 98765 43210 · 02:14', time: '2 min ago', tone: 'blue' },
|
||||
{ icon: MessageSquareText, title: 'Message delivered', detail: 'msg_8f7g2h1 · +91 99876 54321', time: '6 min ago', tone: 'cyan' },
|
||||
{ icon: ShieldCheck, title: 'DLT template approved', detail: 'OTP_LOGIN · URMIOT', time: '18 min ago', tone: 'violet' },
|
||||
{ icon: TriangleAlert, title: 'Webhook delivery retried', detail: 'endpoint_checkout · attempt 2', time: '31 min ago', tone: 'amber' },
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
getDashboard(controller.signal)
|
||||
.then((data) => {
|
||||
setMetrics(data.metrics)
|
||||
setActivities(
|
||||
data.activity.map((event) => {
|
||||
const meta = activityMeta[event.type] ?? { icon: MessageSquareText, tone: 'cyan' }
|
||||
return {
|
||||
icon: meta.icon,
|
||||
title: event.title,
|
||||
detail: event.detail,
|
||||
time: formatRelativeTime(event.occurred_at),
|
||||
tone: meta.tone,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
const stats = useMemo(() => [
|
||||
{ label: 'Voice calls', value: formatCount(metrics.voice_calls), change: '+8.4%', hint: 'vs. last week', icon: Phone, tone: 'blue' },
|
||||
{ label: 'SMS sent', value: formatCount(metrics.messages_sent), change: '+12.2%', hint: 'vs. last week', icon: MessageSquareText, tone: 'cyan' },
|
||||
{ label: 'Delivery rate', value: `${metrics.delivery_rate}%`, change: '+2.1%', hint: 'vs. last week', icon: Send, tone: 'violet' },
|
||||
{ label: 'API requests', value: formatCount(metrics.api_requests), change: '+5.8%', hint: 'vs. last week', icon: Code2, tone: 'amber' },
|
||||
], [metrics])
|
||||
|
||||
const firstName = user?.name.split(' ')[0] ?? 'there'
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="welcome-row">
|
||||
<div>
|
||||
<span className="eyebrow">{new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long' })}</span>
|
||||
<h1>Good morning, {firstName} <span>👋</span></h1>
|
||||
<p>Here’s what’s happening across your communications today.</p>
|
||||
</div>
|
||||
<div className="welcome-actions">
|
||||
<button className="secondary-button"><Clock3 size={16} /> Last 7 days</button>
|
||||
<button className="primary-button"><Plus size={17} /> New message</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="stats-grid">
|
||||
{stats.map(({ label, value, change, hint, icon: Icon, tone }) => (
|
||||
<article className="stat-card" key={label}>
|
||||
<div className={`stat-icon ${tone}`}><Icon size={19} /></div>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<p><TrendingUp size={14} /> {change} <em>{hint}</em></p>
|
||||
</article>
|
||||
))}
|
||||
<article className="balance-card">
|
||||
<span>Available balance</span>
|
||||
<strong>₹{metrics.balance.toLocaleString('en-IN')}</strong>
|
||||
<small>Estimated {Math.round(metrics.balance / 0.52).toLocaleString('en-IN')} messages</small>
|
||||
<button>Add funds <ArrowRight size={15} /></button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel chart-panel">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Communication overview</h2><p>Traffic across your active products</p></div>
|
||||
<div className="legend"><span><i className="calls-dot" /> Voice calls</span><span><i className="messages-dot" /> SMS sent</span></div>
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="callsFill" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#1475ff" stopOpacity={0.18}/><stop offset="95%" stopColor="#1475ff" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="messagesFill" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#11b7c9" stopOpacity={0.14}/><stop offset="95%" stopColor="#11b7c9" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e9edf4" />
|
||||
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} width={38} />
|
||||
<Tooltip contentStyle={{ borderRadius: 10, border: '1px solid #e4e9f1', fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="messages" stroke="#11b7c9" strokeWidth={2.2} fill="url(#messagesFill)" />
|
||||
<Area type="monotone" dataKey="calls" stroke="#1475ff" strokeWidth={2.2} fill="url(#callsFill)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel insights-panel">
|
||||
<div className="panel-heading"><div><h2>Top insights</h2><p>What needs your attention</p></div><Sparkles size={18} /></div>
|
||||
<div className="insight success"><span><Check size={16} /></span><div><strong>Delivery rate improved 2.1%</strong><p>Compared to the previous 7 days</p></div></div>
|
||||
<div className="insight info"><span><TrendingUp size={16} /></span><div><strong>Peak calling hour: 10–11 AM</strong><p>Average wait time is trending down</p></div></div>
|
||||
<div className="insight warning"><span><TriangleAlert size={16} /></span><div><strong>3 DLT templates need attention</strong><p>Review and update before expiry</p></div></div>
|
||||
<button className="text-button">View all insights <ChevronRight size={15} /></button>
|
||||
</section>
|
||||
|
||||
<section className="panel activity-panel">
|
||||
<div className="panel-heading"><div><h2>Recent activity</h2><p>Live events from all products</p></div><button className="text-button">View all <ChevronRight size={15} /></button></div>
|
||||
<div className="activity-list">
|
||||
{activities.map(({ icon: Icon, title, detail, time, tone }) => (
|
||||
<div className="activity-item" key={`${title}-${detail}`}>
|
||||
<span className={`activity-icon ${tone}`}><Icon size={16} /></span>
|
||||
<div><strong>{title}</strong><p>{detail}</p></div>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel compliance-panel">
|
||||
<div className="panel-heading"><div><h2>Compliance status</h2><p>Your India readiness</p></div><span className="ready-badge">Ready</span></div>
|
||||
{[
|
||||
['Business KYC', 'Approved'],
|
||||
['DLT principal entity', 'Approved'],
|
||||
['Headers', '3 approved'],
|
||||
['Content templates', '12 approved'],
|
||||
].map(([name, status]) => (
|
||||
<div className="compliance-row" key={name}><span><Check size={14} /> {name}</span><strong>{status}</strong></div>
|
||||
))}
|
||||
<button className="secondary-button full-width">Open compliance center</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
505
frontend/src/features/develop/ApiExplorerPage.tsx
Normal file
505
frontend/src/features/develop/ApiExplorerPage.tsx
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
import { CheckCircle2, Copy, MessageSquareText, Phone, Play, Radio, ShieldCheck } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
integrationsApi,
|
||||
messagesApi,
|
||||
numbersApi,
|
||||
voiceApi,
|
||||
type IntegrationsDocs,
|
||||
type MessageApiDoc,
|
||||
type PhoneNumber,
|
||||
type SendMessageResponse,
|
||||
type VoiceApiDocs,
|
||||
type VoiceCall,
|
||||
} from '../../lib/api'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
|
||||
|
||||
function FieldRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="explorer-field-row">
|
||||
<span>{label}</span>
|
||||
<code>{value}</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExplorerGroup({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="explorer-group">
|
||||
<h2 className="explorer-group-title">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayCallout({ principle, latency }: { principle: string; latency: string }) {
|
||||
return (
|
||||
<div className="relay-callout">
|
||||
<strong>Transparent relay — minimum latency</strong>
|
||||
<p>{principle}</p>
|
||||
<p>{latency}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_RELAY = {
|
||||
principle: 'Urmi is a transparent relay and authentication gateway. We forward raw frames without decoding or modifying them.',
|
||||
latency: 'Frames are forwarded as they arrive to keep latency as low as possible.',
|
||||
}
|
||||
|
||||
function normalizeVoiceDocs(raw: Partial<VoiceApiDocs> | null): VoiceApiDocs | null {
|
||||
if (!raw) return null
|
||||
return {
|
||||
title: raw.title ?? 'Initiate outbound call',
|
||||
summary: raw.summary ?? 'Place an outbound call from your Urmi number.',
|
||||
method: raw.method ?? 'POST',
|
||||
path: raw.path ?? '/api/v1/voice/calls',
|
||||
authentication: {
|
||||
header: raw.authentication?.header ?? 'Authorization',
|
||||
format: raw.authentication?.format ?? 'Bearer urmi_live_<key>',
|
||||
alternate_header: raw.authentication?.alternate_header,
|
||||
},
|
||||
request_body: raw.request_body ?? {
|
||||
to: { type: 'string', required: true, example: '9149154337', description: 'Destination number' },
|
||||
from: { type: 'string', required: true, example: '7946350706', description: 'Your Urmi number' },
|
||||
},
|
||||
response_example: raw.response_example ?? { id: '…', status: 'initiated' },
|
||||
relay: raw.relay ?? DEFAULT_RELAY,
|
||||
outbound_flow: raw.outbound_flow ?? [
|
||||
'Link your number to a voice socket under Webhooks & Sockets.',
|
||||
'POST /api/v1/voice/calls with from, to, and bridge_mode upstream.',
|
||||
],
|
||||
endpoints: raw.endpoints ?? [
|
||||
{ method: 'POST', path: '/api/v1/voice/calls', description: 'Place an outbound call' },
|
||||
{ method: 'GET', path: '/api/v1/voice/calls/{call_id}', description: 'Get call status' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIntegrationDocs(raw: Partial<IntegrationsDocs> | null): IntegrationsDocs | null {
|
||||
if (!raw) return null
|
||||
return {
|
||||
title: raw.title ?? 'Webhooks & Sockets',
|
||||
summary: raw.summary ?? 'Connect each Urmi number to your application.',
|
||||
relay: raw.relay ?? DEFAULT_RELAY,
|
||||
socket: raw.socket ?? {
|
||||
title: 'Voice sockets',
|
||||
description: 'Link a WebSocket endpoint to a number for live call audio.',
|
||||
events: ['connected', 'start', 'media', 'end'],
|
||||
authentication: {
|
||||
headers: ['X-Urmi-Endpoint-Id', 'X-Urmi-Timestamp', 'X-Urmi-Signature'],
|
||||
signature: 'HMAC-SHA256 of endpoint_id.timestamp using your signing secret.',
|
||||
replay_protection: 'Reject timestamps older than five minutes.',
|
||||
},
|
||||
},
|
||||
webhook: raw.webhook ?? {
|
||||
title: 'SMS delivery webhooks',
|
||||
description: 'Receive SMS delivery status updates at your HTTPS endpoint.',
|
||||
event: 'message.delivery.updated',
|
||||
statuses: ['queued', 'sent', 'delivered', 'undelivered', 'failed'],
|
||||
authentication: {
|
||||
headers: ['X-Urmi-Event', 'X-Urmi-Endpoint-Id', 'X-Urmi-Signature'],
|
||||
signature: 'HMAC-SHA256 of the raw request body using your signing secret.',
|
||||
},
|
||||
example: {},
|
||||
},
|
||||
api_authentication: raw.api_authentication ?? {
|
||||
header: 'Authorization',
|
||||
format: 'Bearer urmi_live_<your_api_key>',
|
||||
note: 'Use API keys from your server only.',
|
||||
},
|
||||
endpoints: raw.endpoints ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function ApiExplorerPage() {
|
||||
const [smsDoc, setSmsDoc] = useState<MessageApiDoc | null>(null)
|
||||
const [voiceDoc, setVoiceDoc] = useState<VoiceApiDocs | null>(null)
|
||||
const [integrationDocs, setIntegrationDocs] = useState<IntegrationsDocs | null>(null)
|
||||
const [numbers, setNumbers] = useState<PhoneNumber[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [senderId, setSenderId] = useState('')
|
||||
const [templateId, setTemplateId] = useState('')
|
||||
const [smsTo, setSmsTo] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [smsResponse, setSmsResponse] = useState<SendMessageResponse | null>(null)
|
||||
const [smsError, setSmsError] = useState('')
|
||||
const [smsRunning, setSmsRunning] = useState(false)
|
||||
|
||||
const [fromDid, setFromDid] = useState('')
|
||||
const [callTo, setCallTo] = useState('')
|
||||
const [voiceResponse, setVoiceResponse] = useState<VoiceCall | null>(null)
|
||||
const [voiceError, setVoiceError] = useState('')
|
||||
const [voiceRunning, setVoiceRunning] = useState(false)
|
||||
|
||||
const [copied, setCopied] = useState<'sms' | 'voice' | null>(null)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
messagesApi.docs(),
|
||||
voiceApi.docs(),
|
||||
integrationsApi.docs(),
|
||||
numbersApi.mine(),
|
||||
])
|
||||
.then((results) => {
|
||||
const [smsResult, voiceResult, integrationResult, numbersResult] = results
|
||||
if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value)
|
||||
if (voiceResult.status === 'fulfilled') setVoiceDoc(normalizeVoiceDocs(voiceResult.value))
|
||||
if (integrationResult.status === 'fulfilled') {
|
||||
setIntegrationDocs(normalizeIntegrationDocs(integrationResult.value))
|
||||
}
|
||||
if (numbersResult.status === 'fulfilled') {
|
||||
setNumbers(numbersResult.value)
|
||||
setFromDid(numbersResult.value[0]?.phone_number ?? '')
|
||||
}
|
||||
const failures = results.filter((result) => result.status === 'rejected')
|
||||
if (failures.length === results.length) {
|
||||
setLoadError('Unable to load API documentation. Check that the backend is running.')
|
||||
} else if (failures.length > 0) {
|
||||
setLoadError('Some documentation sections could not be loaded.')
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const smsCurl = useMemo(() => {
|
||||
if (!smsDoc) return ''
|
||||
const body = JSON.stringify({
|
||||
sender_id: senderId || smsDoc.request_body.sender_id.example,
|
||||
template_id: templateId || '00000000-0000-0000-0000-000000000001',
|
||||
to: smsTo || smsDoc.request_body.to.example,
|
||||
message: message || smsDoc.request_body.message.example,
|
||||
}, null, 2)
|
||||
return [
|
||||
`curl -X POST '${API_BASE_URL}${smsDoc.path}' \\`,
|
||||
` -H 'Authorization: Bearer ${apiKey || 'urmi_live_<your_api_key>'}' \\`,
|
||||
` -H 'Content-Type: application/json' \\`,
|
||||
` -d '${body.replace(/'/g, `'\\''`)}'`,
|
||||
].join('\n')
|
||||
}, [apiKey, message, senderId, smsDoc, smsTo, templateId])
|
||||
|
||||
const voiceCurl = useMemo(() => {
|
||||
if (!voiceDoc) return ''
|
||||
const body = JSON.stringify({
|
||||
to: callTo || voiceDoc.request_body.to.example,
|
||||
from: fromDid || voiceDoc.request_body.from.example,
|
||||
bridge_mode: 'upstream',
|
||||
}, null, 2)
|
||||
return [
|
||||
`curl -X POST '${API_BASE_URL}${voiceDoc.path}' \\`,
|
||||
` -H 'Authorization: Bearer ${apiKey || 'urmi_live_<your_api_key>'}' \\`,
|
||||
` -H 'Content-Type: application/json' \\`,
|
||||
` -d '${body.replace(/'/g, `'\\''`)}'`,
|
||||
].join('\n')
|
||||
}, [apiKey, callTo, fromDid, voiceDoc])
|
||||
|
||||
async function runSms() {
|
||||
setSmsError('')
|
||||
setSmsResponse(null)
|
||||
setSmsRunning(true)
|
||||
try {
|
||||
const result = await messagesApi.send(apiKey.trim(), {
|
||||
sender_id: senderId.trim().toUpperCase(),
|
||||
template_id: templateId.trim(),
|
||||
to: smsTo.trim(),
|
||||
message: message.trim(),
|
||||
})
|
||||
setSmsResponse(result)
|
||||
} catch (reason) {
|
||||
setSmsError(reason instanceof Error ? reason.message : 'Request failed')
|
||||
} finally {
|
||||
setSmsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runVoiceCall() {
|
||||
setVoiceError('')
|
||||
setVoiceResponse(null)
|
||||
setVoiceRunning(true)
|
||||
try {
|
||||
const result = await voiceApi.createCallWithApiKey(apiKey.trim(), {
|
||||
to: callTo.trim(),
|
||||
from: fromDid.trim(),
|
||||
bridge_mode: 'upstream',
|
||||
})
|
||||
setVoiceResponse(result)
|
||||
} catch (reason) {
|
||||
setVoiceError(reason instanceof Error ? reason.message : 'Request failed')
|
||||
} finally {
|
||||
setVoiceRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCurl(kind: 'sms' | 'voice') {
|
||||
await navigator.clipboard.writeText(kind === 'sms' ? smsCurl : voiceCurl)
|
||||
setCopied(kind)
|
||||
window.setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="api-explorer-page">
|
||||
<p className="explorer-loading">Loading API explorer…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!smsDoc && !voiceDoc && !integrationDocs) {
|
||||
return (
|
||||
<div className="api-explorer-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Develop</span>
|
||||
<h1>API Explorer</h1>
|
||||
</div>
|
||||
<p className="auth-error">{loadError || 'Unable to load API docs.'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="api-explorer-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Develop</span>
|
||||
<h1>API Explorer</h1>
|
||||
<p>Documentation and live examples for Urmi APIs. Urmi authenticates and relays traffic — we do not process payloads on our side.</p>
|
||||
</div>
|
||||
|
||||
{loadError && <p className="relay-success integration-feedback">{loadError}</p>}
|
||||
|
||||
{smsDoc && (
|
||||
<ExplorerGroup title="Messaging">
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>{smsDoc.title}</h3>
|
||||
<p>{smsDoc.summary}</p>
|
||||
</div>
|
||||
<span className="explorer-method">{smsDoc.method}</span>
|
||||
</div>
|
||||
|
||||
<FieldRow label="Endpoint" value={`${API_BASE_URL}${smsDoc.path}`} />
|
||||
<FieldRow label="Auth" value={`${smsDoc.authentication.header}: ${smsDoc.authentication.format}`} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Request body</h4>
|
||||
<div className="explorer-request-grid">
|
||||
{Object.entries(smsDoc.request_body).map(([key, field]) => (
|
||||
<div className="explorer-input" key={key}>
|
||||
<label>
|
||||
<strong>{key}</strong>
|
||||
<span>{field.description}</span>
|
||||
{key === 'message' ? (
|
||||
<textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder={field.example} rows={4} />
|
||||
) : (
|
||||
<input
|
||||
value={key === 'sender_id' ? senderId : key === 'template_id' ? templateId : key === 'to' ? smsTo : ''}
|
||||
onChange={(e) => {
|
||||
if (key === 'sender_id') setSenderId(e.target.value)
|
||||
if (key === 'template_id') setTemplateId(e.target.value)
|
||||
if (key === 'to') setSmsTo(e.target.value)
|
||||
}}
|
||||
placeholder={field.example}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>API key</h4>
|
||||
<label className="explorer-input">
|
||||
<strong>Bearer token</strong>
|
||||
<span>Paste a key from API Keys. It is sent only for this test request.</span>
|
||||
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="explorer-actions">
|
||||
<button className="primary-button" onClick={runSms} disabled={smsRunning || !apiKey.trim()}>
|
||||
<Play size={15} /> {smsRunning ? 'Sending…' : 'Try request'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => copyCurl('sms')}>
|
||||
<Copy size={15} /> {copied === 'sms' ? 'Copied cURL' : 'Copy cURL'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{smsError && <p className="auth-error">{smsError}</p>}
|
||||
{smsResponse && (
|
||||
<div className="explorer-response success">
|
||||
<strong><CheckCircle2 size={15} /> 200 OK</strong>
|
||||
<pre>{JSON.stringify(smsResponse, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example cURL</h4>
|
||||
<pre className="explorer-code">{smsCurl}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</ExplorerGroup>
|
||||
)}
|
||||
|
||||
{voiceDoc && integrationDocs && (
|
||||
<ExplorerGroup title="Numbers">
|
||||
<RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} />
|
||||
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>{voiceDoc.title}</h3>
|
||||
<p>{voiceDoc.summary}</p>
|
||||
</div>
|
||||
<span className="explorer-method">{voiceDoc.method}</span>
|
||||
</div>
|
||||
|
||||
<FieldRow label="Endpoint" value={`${API_BASE_URL}${voiceDoc.path}`} />
|
||||
<FieldRow label="Auth" value={`${voiceDoc.authentication.header}: ${voiceDoc.authentication.format}`} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>How outbound calls work</h4>
|
||||
<ol className="relay-steps">
|
||||
{voiceDoc.outbound_flow.map((step) => <li key={step}>{step}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Request body</h4>
|
||||
<div className="explorer-request-grid">
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>from</strong>
|
||||
<span>{voiceDoc.request_body.from.description}</span>
|
||||
<select value={fromDid} onChange={(e) => setFromDid(e.target.value)}>
|
||||
{!numbers.length && <option value="">No numbers assigned</option>}
|
||||
{numbers.map((n) => (
|
||||
<option key={n.id} value={n.phone_number}>{n.phone_number}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>to</strong>
|
||||
<span>{voiceDoc.request_body.to.description}</span>
|
||||
<input value={callTo} onChange={(e) => setCallTo(e.target.value)} placeholder={voiceDoc.request_body.to.example} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="relay-note">
|
||||
Link your number to a voice socket first under{' '}
|
||||
<Link to="/console/webhooks">Webhooks & Sockets</Link>. Urmi relays live audio frames to that endpoint with no processing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="explorer-actions">
|
||||
<button className="primary-button" onClick={runVoiceCall} disabled={voiceRunning || !apiKey.trim() || !fromDid}>
|
||||
<Phone size={15} /> {voiceRunning ? 'Dialing…' : 'Place outbound call'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => copyCurl('voice')}>
|
||||
<Copy size={15} /> {copied === 'voice' ? 'Copied cURL' : 'Copy cURL'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{voiceError && <p className="auth-error">{voiceError}</p>}
|
||||
{voiceResponse && (
|
||||
<div className="explorer-response success">
|
||||
<strong><CheckCircle2 size={15} /> Call initiated</strong>
|
||||
<pre>{JSON.stringify(voiceResponse, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example cURL</h4>
|
||||
<pre className="explorer-code">{voiceCurl}</pre>
|
||||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Related endpoints</h4>
|
||||
<ul className="relay-endpoints">
|
||||
{voiceDoc.endpoints.map((endpoint) => (
|
||||
<li key={endpoint.path}>
|
||||
<span className="explorer-method">{endpoint.method}</span>
|
||||
<div><code>{endpoint.path}</code><p>{endpoint.description}</p></div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>{integrationDocs.socket.title}</h3>
|
||||
<p>{integrationDocs.socket.description}</p>
|
||||
</div>
|
||||
<Radio size={18} />
|
||||
</div>
|
||||
|
||||
<p className="relay-note">Events relayed to your server: {integrationDocs.socket.events.join(', ')}.</p>
|
||||
<div className="explorer-section">
|
||||
<h4>Verify connections</h4>
|
||||
<p>{integrationDocs.socket.authentication.signature} {integrationDocs.socket.authentication.replay_protection}</p>
|
||||
<p>Headers: {integrationDocs.socket.authentication.headers.join(', ')}</p>
|
||||
</div>
|
||||
<ul className="relay-endpoints">
|
||||
{integrationDocs.endpoints.filter((e) => e.path.includes('sockets')).map((endpoint) => (
|
||||
<li key={endpoint.path}>
|
||||
<span className="explorer-method">{endpoint.method}</span>
|
||||
<div><code>{endpoint.path}</code><p>{endpoint.description}</p></div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="ghost-button integration-api-link" to="/console/webhooks">Configure voice sockets</Link>
|
||||
</section>
|
||||
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>{integrationDocs.webhook.title}</h3>
|
||||
<p>{integrationDocs.webhook.description}</p>
|
||||
</div>
|
||||
<MessageSquareText size={18} />
|
||||
</div>
|
||||
|
||||
<FieldRow label="Event" value={integrationDocs.webhook.event} />
|
||||
<FieldRow label="Statuses" value={integrationDocs.webhook.statuses.join(', ')} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example payload</h4>
|
||||
<pre className="explorer-code">{JSON.stringify(integrationDocs.webhook.example, null, 2)}</pre>
|
||||
<p className="relay-note">{integrationDocs.webhook.authentication.signature}</p>
|
||||
</div>
|
||||
<ul className="relay-endpoints">
|
||||
{integrationDocs.endpoints.filter((e) => e.path.includes('webhooks')).map((endpoint) => (
|
||||
<li key={endpoint.path}>
|
||||
<span className="explorer-method">{endpoint.method}</span>
|
||||
<div><code>{endpoint.path}</code><p>{endpoint.description}</p></div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="ghost-button integration-api-link" to="/console/webhooks">Configure delivery webhooks</Link>
|
||||
</section>
|
||||
</ExplorerGroup>
|
||||
)}
|
||||
|
||||
{integrationDocs && (
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="explorer-section">
|
||||
<h4>Authentication</h4>
|
||||
<FieldRow label="Header" value={`${integrationDocs.api_authentication.header}: ${integrationDocs.api_authentication.format}`} />
|
||||
<ul className="explorer-notes">
|
||||
<li><ShieldCheck size={14} /> {integrationDocs.api_authentication.note}</li>
|
||||
<li><ShieldCheck size={14} /> {integrationDocs.relay.latency}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
frontend/src/features/develop/ApiKeysPage.tsx
Normal file
198
frontend/src/features/develop/ApiKeysPage.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { CheckCircle2, Copy, KeyRound, Plus, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { apiKeysApi, type ApiKey, type ApiKeyWithToken } from '../../lib/api'
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return 'Never'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
function TokenRevealModal({
|
||||
title,
|
||||
apiKey,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
apiKey: ApiKeyWithToken
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copyToken() {
|
||||
await navigator.clipboard.writeText(apiKey.token)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card api-key-reveal-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="api-key-reveal-body">
|
||||
<p className="api-key-warning">Copy this key now. You will not be able to see it again.</p>
|
||||
<div className="api-key-token-box">
|
||||
<code>{apiKey.token}</code>
|
||||
<button type="button" className="secondary-button" onClick={copyToken}>
|
||||
<Copy size={15} /> {copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="api-key-meta">{apiKey.name} · {apiKey.key_prefix}</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="primary-button" onClick={onClose}>I have saved this key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated: (key: ApiKeyWithToken) => void }) {
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
const created = await apiKeysApi.create(name.trim())
|
||||
onCreated(created)
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create API key')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<form className="modal-card api-key-modal" onClick={(event) => event.stopPropagation()} onSubmit={submit}>
|
||||
<div className="modal-header">
|
||||
<h2>Create API Key</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
<label className="modal-field modal-field-stack">
|
||||
<span>Key name</span>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Production backend" maxLength={60} />
|
||||
</label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="submit" className="primary-button" disabled={saving || name.trim().length < 2}>
|
||||
{saving ? 'Creating…' : 'Create key'}
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [revealedKey, setRevealedKey] = useState<ApiKeyWithToken | null>(null)
|
||||
const [revealTitle, setRevealTitle] = useState('Your new API key')
|
||||
|
||||
async function refresh() {
|
||||
setKeys(await apiKeysApi.list())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => setError('Unable to load API keys')).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function regenerate(keyId: string) {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
const regenerated = await apiKeysApi.regenerate(keyId)
|
||||
const { token: _token, ...keyMeta } = regenerated
|
||||
setKeys((current) => current.map((item) => (item.id === regenerated.id ? keyMeta : item)))
|
||||
setRevealTitle('Regenerated API key')
|
||||
setRevealedKey(regenerated)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to regenerate API key')
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(keyId: string) {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
await apiKeysApi.revoke(keyId)
|
||||
setKeys((current) => current.filter((item) => item.id !== keyId))
|
||||
setMessage('API key revoked.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to revoke API key')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreated(created: ApiKeyWithToken) {
|
||||
const { token: _token, ...keyMeta } = created
|
||||
setKeys((current) => [keyMeta, ...current])
|
||||
setRevealTitle('Your new API key')
|
||||
setRevealedKey(created)
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading API keys…</div>
|
||||
|
||||
return (
|
||||
<div className="api-keys-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Develop</span>
|
||||
<h1>API Keys</h1>
|
||||
<p>Create named keys for server-side integrations. Each secret is shown only once.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Project keys</h2><p>Use keys in the Authorization header for Urmi API requests.</p></div>
|
||||
<button className="primary-button" onClick={() => setCreateOpen(true)}><Plus size={15} /> Create key</button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="empty-copy">No API keys yet. Create one to get started.</p>
|
||||
) : (
|
||||
keys.map((item) => (
|
||||
<div className="api-key-row" key={item.id}>
|
||||
<div>
|
||||
<strong><KeyRound size={14} /> {item.name}</strong>
|
||||
<span>{item.key_prefix}</span>
|
||||
<small>Created {formatDate(item.created_at)} · Last used {formatDate(item.last_used_at)}</small>
|
||||
</div>
|
||||
<span className={`doc-status ${item.status}`}>{item.status}</span>
|
||||
<div className="admin-actions">
|
||||
<button className="secondary-button" onClick={() => regenerate(item.id)}>
|
||||
<RefreshCw size={14} /> Regenerate
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => revoke(item.id)}>
|
||||
<Trash2 size={14} /> Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
{message && <p className="account-success"><CheckCircle2 size={14} /> {message}</p>}
|
||||
</section>
|
||||
|
||||
{createOpen && <CreateKeyModal onClose={() => setCreateOpen(false)} onCreated={handleCreated} />}
|
||||
{revealedKey && (
|
||||
<TokenRevealModal
|
||||
title={revealTitle}
|
||||
apiKey={revealedKey}
|
||||
onClose={() => setRevealedKey(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
315
frontend/src/features/develop/WebhooksSocketsPage.tsx
Normal file
315
frontend/src/features/develop/WebhooksSocketsPage.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import {
|
||||
Check,
|
||||
Copy,
|
||||
MessageSquareText,
|
||||
Plus,
|
||||
Radio,
|
||||
Send,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
integrationsApi,
|
||||
numbersApi,
|
||||
type PhoneNumber,
|
||||
type SmsDeliveryWebhook,
|
||||
type VoiceSocketEndpoint,
|
||||
} from '../../lib/api'
|
||||
|
||||
type Tab = 'sockets' | 'webhooks'
|
||||
|
||||
function formatPhone(value: string) {
|
||||
return value.length === 10 ? `+91 ${value.slice(0, 5)} ${value.slice(5)}` : value
|
||||
}
|
||||
|
||||
function EndpointStatus({ enabled }: { enabled: boolean }) {
|
||||
return <span className={`endpoint-status ${enabled ? 'is-active' : ''}`}><i />{enabled ? 'Active' : 'Paused'}</span>
|
||||
}
|
||||
|
||||
export function WebhooksSocketsPage() {
|
||||
const [tab, setTab] = useState<Tab>('sockets')
|
||||
const [numbers, setNumbers] = useState<PhoneNumber[]>([])
|
||||
const [sockets, setSockets] = useState<VoiceSocketEndpoint[]>([])
|
||||
const [webhooks, setWebhooks] = useState<SmsDeliveryWebhook[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [revealedSecret, setRevealedSecret] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [numberId, setNumberId] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
async function load() {
|
||||
const [numberPayload, socketPayload, webhookPayload] = await Promise.all([
|
||||
numbersApi.mine(),
|
||||
integrationsApi.listSockets(),
|
||||
integrationsApi.listWebhooks(),
|
||||
])
|
||||
setNumbers(numberPayload)
|
||||
setSockets(socketPayload)
|
||||
setWebhooks(webhookPayload)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load integrations'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const selectableNumbers = useMemo(() => {
|
||||
if (tab === 'webhooks') return numbers
|
||||
const linked = new Set(sockets.map((endpoint) => endpoint.phone_number_id))
|
||||
return numbers.filter((number) => !linked.has(number.id))
|
||||
}, [numbers, sockets, tab])
|
||||
|
||||
function resetForm(nextTab?: Tab) {
|
||||
const activeTab = nextTab ?? tab
|
||||
setName('')
|
||||
setUrl('')
|
||||
setDescription('')
|
||||
const choices = activeTab === 'sockets'
|
||||
? numbers.filter((number) => !sockets.some((endpoint) => endpoint.phone_number_id === number.id))
|
||||
: numbers
|
||||
setNumberId(choices[0]?.id ?? '')
|
||||
}
|
||||
|
||||
function switchTab(nextTab: Tab) {
|
||||
setTab(nextTab)
|
||||
setError('')
|
||||
setNotice('')
|
||||
setRevealedSecret('')
|
||||
resetForm(nextTab)
|
||||
}
|
||||
|
||||
async function createEndpoint(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setNotice('')
|
||||
setRevealedSecret('')
|
||||
if (!numberId) {
|
||||
setError('Choose a phone number first.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
phone_number_id: numberId,
|
||||
url: url.trim(),
|
||||
description: description.trim() || null,
|
||||
}
|
||||
const created = tab === 'sockets'
|
||||
? await integrationsApi.createSocket(payload)
|
||||
: await integrationsApi.createWebhook(payload)
|
||||
setRevealedSecret(created.signing_secret ?? '')
|
||||
setNotice(`${tab === 'sockets' ? 'Voice socket' : 'Delivery webhook'} created.`)
|
||||
await load()
|
||||
setName('')
|
||||
setUrl('')
|
||||
setDescription('')
|
||||
setNumberId('')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create endpoint')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSocket(endpoint: VoiceSocketEndpoint) {
|
||||
const updated = await integrationsApi.updateSocket(endpoint.id, { enabled: !endpoint.enabled })
|
||||
setSockets((items) => items.map((item) => item.id === updated.id ? updated : item))
|
||||
}
|
||||
|
||||
async function toggleWebhook(endpoint: SmsDeliveryWebhook) {
|
||||
const updated = await integrationsApi.updateWebhook(endpoint.id, { enabled: !endpoint.enabled })
|
||||
setWebhooks((items) => items.map((item) => item.id === updated.id ? updated : item))
|
||||
}
|
||||
|
||||
async function remove(kind: Tab, id: string) {
|
||||
if (!window.confirm(`Delete this ${kind === 'sockets' ? 'voice socket' : 'webhook'}?`)) return
|
||||
setError('')
|
||||
try {
|
||||
if (kind === 'sockets') {
|
||||
await integrationsApi.deleteSocket(id)
|
||||
setSockets((items) => items.filter((item) => item.id !== id))
|
||||
} else {
|
||||
await integrationsApi.deleteWebhook(id)
|
||||
setWebhooks((items) => items.filter((item) => item.id !== id))
|
||||
}
|
||||
setNotice('Endpoint deleted.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to delete endpoint')
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest(endpoint: SmsDeliveryWebhook) {
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const result = await integrationsApi.testWebhook(endpoint.id)
|
||||
setNotice(`Test delivery accepted with HTTP ${result.response_code}.`)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Test delivery failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
await navigator.clipboard.writeText(revealedSecret)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1800)
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading integrations…</div>
|
||||
|
||||
const emptyNumbers = numbers.length === 0
|
||||
const noSocketSlots = tab === 'sockets' && selectableNumbers.length === 0
|
||||
|
||||
return (
|
||||
<div className="webhooks-sockets-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Develop</span>
|
||||
<h1>Webhooks & Sockets</h1>
|
||||
<p>Connect each phone number to your application. API docs and examples live in <Link to="/console/api-explorer">API Explorer</Link>.</p>
|
||||
</div>
|
||||
|
||||
<div className="integration-tabs" role="tablist">
|
||||
<button className={tab === 'sockets' ? 'is-active' : ''} onClick={() => switchTab('sockets')}>
|
||||
<Radio size={16} /> Voice sockets
|
||||
</button>
|
||||
<button className={tab === 'webhooks' ? 'is-active' : ''} onClick={() => switchTab('webhooks')}>
|
||||
<MessageSquareText size={16} /> SMS delivery webhooks
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="auth-error integration-feedback">{error}</p>}
|
||||
{notice && <p className="relay-success integration-feedback">{notice}</p>}
|
||||
{revealedSecret && (
|
||||
<section className="integration-secret">
|
||||
<div>
|
||||
<strong>Save your signing secret now</strong>
|
||||
<p>It is shown only once. Use it to verify that requests came from Urmi.</p>
|
||||
<code>{revealedSecret}</code>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={() => void copySecret()}>
|
||||
{copied ? <Check size={15} /> : <Copy size={15} />} {copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="integration-grid">
|
||||
<section className="panel endpoint-list-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>{tab === 'sockets' ? 'Voice sockets' : 'SMS delivery webhooks'}</h2>
|
||||
<p>
|
||||
{tab === 'sockets'
|
||||
? 'One secure WebSocket per number. Urmi relays live audio with no processing.'
|
||||
: 'Named HTTPS endpoints for SMS delivery status updates.'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="endpoint-count">{tab === 'sockets' ? sockets.length : webhooks.length}</span>
|
||||
</div>
|
||||
|
||||
{tab === 'sockets' && sockets.map((endpoint) => (
|
||||
<article className="endpoint-row" key={endpoint.id}>
|
||||
<div className="endpoint-icon"><Radio size={16} /></div>
|
||||
<div className="endpoint-main">
|
||||
<div><strong>{endpoint.name}</strong><EndpointStatus enabled={endpoint.enabled} /></div>
|
||||
<span>{formatPhone(endpoint.phone_number)}</span>
|
||||
<code>{endpoint.url}</code>
|
||||
</div>
|
||||
<div className="endpoint-actions">
|
||||
<button className="ghost-button" onClick={() => void toggleSocket(endpoint)}>
|
||||
{endpoint.enabled ? 'Pause' : 'Enable'}
|
||||
</button>
|
||||
<button className="icon-button" aria-label="Delete socket" onClick={() => void remove('sockets', endpoint.id)}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{tab === 'webhooks' && webhooks.map((endpoint) => (
|
||||
<article className="endpoint-row" key={endpoint.id}>
|
||||
<div className="endpoint-icon"><MessageSquareText size={16} /></div>
|
||||
<div className="endpoint-main">
|
||||
<div><strong>{endpoint.name}</strong><EndpointStatus enabled={endpoint.enabled} /></div>
|
||||
<span>{formatPhone(endpoint.phone_number)} · message.delivery.updated</span>
|
||||
<code>{endpoint.url}</code>
|
||||
{endpoint.description && <small>{endpoint.description}</small>}
|
||||
</div>
|
||||
<div className="endpoint-actions">
|
||||
<button className="ghost-button" disabled={!endpoint.enabled} onClick={() => void sendTest(endpoint)}>
|
||||
<Send size={14} /> Test
|
||||
</button>
|
||||
<button className="ghost-button" onClick={() => void toggleWebhook(endpoint)}>
|
||||
{endpoint.enabled ? 'Pause' : 'Enable'}
|
||||
</button>
|
||||
<button className="icon-button" aria-label="Delete webhook" onClick={() => void remove('webhooks', endpoint.id)}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{((tab === 'sockets' && sockets.length === 0) || (tab === 'webhooks' && webhooks.length === 0)) && (
|
||||
<div className="endpoint-empty">
|
||||
<strong>No {tab === 'sockets' ? 'voice sockets' : 'delivery webhooks'} yet</strong>
|
||||
<span>Create one to connect a number to your application.</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<form className="panel endpoint-create-panel" onSubmit={createEndpoint}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Add {tab === 'sockets' ? 'voice socket' : 'delivery webhook'}</h2>
|
||||
<p>Name it clearly and link it to one of your numbers.</p>
|
||||
</div>
|
||||
<Plus size={18} />
|
||||
</div>
|
||||
|
||||
<label className="explorer-input">
|
||||
<strong>Name</strong>
|
||||
<span>Used only in your Urmi workspace.</span>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={tab === 'sockets' ? 'Production voice agent' : 'Production SMS delivery'} required />
|
||||
</label>
|
||||
<label className="explorer-input">
|
||||
<strong>Phone number</strong>
|
||||
<span>Events for this number are sent to the endpoint.</span>
|
||||
<select value={numberId} onChange={(event) => setNumberId(event.target.value)} required>
|
||||
<option value="">Select a number</option>
|
||||
{selectableNumbers.map((number) => <option key={number.id} value={number.id}>{formatPhone(number.phone_number)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="explorer-input">
|
||||
<strong>{tab === 'sockets' ? 'WebSocket URL' : 'Webhook URL'}</strong>
|
||||
<span>{tab === 'sockets' ? 'Use a public wss:// endpoint.' : 'Use a public https:// endpoint.'}</span>
|
||||
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder={tab === 'sockets' ? 'wss://voice.example.com/calls' : 'https://api.example.com/urmi/delivery'} required />
|
||||
</label>
|
||||
{tab === 'webhooks' && (
|
||||
<label className="explorer-input">
|
||||
<strong>Description <small>Optional</small></strong>
|
||||
<span>Help teammates understand where this endpoint is used.</span>
|
||||
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} placeholder="Updates our production message status service" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{(emptyNumbers || noSocketSlots) && (
|
||||
<p className="endpoint-form-note">
|
||||
{emptyNumbers ? 'Buy or assign a phone number before creating an endpoint.' : 'Every number already has a voice socket.'}
|
||||
</p>
|
||||
)}
|
||||
<button className="primary-button" type="submit" disabled={saving || emptyNumbers || noSocketSlots}>
|
||||
<Plus size={15} /> {saving ? 'Creating…' : 'Create endpoint'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
frontend/src/features/landing/LandingPage.tsx
Normal file
67
frontend/src/features/landing/LandingPage.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { ArrowRight, Bot, Braces, Check, MessageSquare, ShieldCheck, Zap } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function LandingPage() {
|
||||
return (
|
||||
<div className="landing">
|
||||
<header className="landing-nav">
|
||||
<Link to="/" className="landing-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link>
|
||||
<nav>
|
||||
<a href="#product">Product</a>
|
||||
<a href="#developers">Developers</a>
|
||||
<a href="#security">Security</a>
|
||||
</nav>
|
||||
<div className="landing-actions">
|
||||
<Link to="/login">Log in</Link>
|
||||
<Link to="/register" className="landing-cta">Start building <ArrowRight size={15} /></Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="hero-section">
|
||||
<div className="hero-copy">
|
||||
<span className="hero-pill"><i /> SMS infrastructure for AI agents</span>
|
||||
<h1>Give every AI agent a reliable way to <em>reach humans.</em></h1>
|
||||
<p>One API for transactional SMS, OTPs, alerts, and agent-driven conversations—built for delivery, compliance, and scale.</p>
|
||||
<div className="hero-actions">
|
||||
<Link to="/register" className="hero-primary">Start building free <ArrowRight size={17} /></Link>
|
||||
<a href="#developers" className="hero-secondary">View API <Braces size={17} /></a>
|
||||
</div>
|
||||
<div className="hero-proof"><span><Check size={13} /> No credit card</span><span><Check size={13} /> Sandbox included</span><span><Check size={13} /> India-ready</span></div>
|
||||
</div>
|
||||
|
||||
<div className="agent-demo">
|
||||
<div className="demo-window">
|
||||
<div className="demo-top"><span><i /><i /><i /></span><small>agent.ts</small><b>Connected</b></div>
|
||||
<pre><code><span className="code-purple">await</span> urmi.messages.send({'{'}{'\n'} to: <span className="code-green">"+91 98765 43210"</span>,{'\n'} agent: <span className="code-green">"support_agent"</span>,{'\n'} message: response.text,{'\n'} template: <span className="code-green">"CASE_UPDATE"</span>{'\n'}{'}'})</code></pre>
|
||||
<div className="demo-event"><span><MessageSquare size={17} /></span><div><small>MESSAGE DELIVERED</small><strong>Your refund was approved. It will arrive in 3–5 days.</strong></div><b>184 ms</b></div>
|
||||
</div>
|
||||
<div className="agent-orbit orbit-one"><Bot size={19} /></div>
|
||||
<div className="agent-orbit orbit-two"><Zap size={18} /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="trust-strip">
|
||||
<span>Designed for autonomous communication</span>
|
||||
<div><b>AI support</b><b>Fintech</b><b>Commerce</b><b>Logistics</b><b>Healthcare</b></div>
|
||||
</section>
|
||||
|
||||
<section className="feature-section" id="product">
|
||||
<div className="section-heading"><span>THE AGENT MESSAGING LAYER</span><h2>Simple at the surface. Serious underneath.</h2><p>Your agents focus on the conversation. Urmi handles telecom complexity.</p></div>
|
||||
<div className="feature-grid">
|
||||
<article><span><Bot /></span><h3>Agent-native messaging</h3><p>Stable APIs and webhooks designed for tools, workflows, and autonomous agents.</p></article>
|
||||
<article><span><Zap /></span><h3>Fast, observable delivery</h3><p>Track every message from API request to carrier delivery with one event timeline.</p></article>
|
||||
<article id="security"><span><ShieldCheck /></span><h3>Compliance built in</h3><p>KYC, DLT templates, sender validation, and consent checks before messages leave.</p></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="developer-band" id="developers">
|
||||
<div><span>BUILD IN MINUTES</span><h2>One endpoint. Every agent.</h2><p>Start in the sandbox, inspect the request, and promote the same integration to production.</p></div>
|
||||
<Link to="/register">Get your API key <ArrowRight size={16} /></Link>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="landing-footer"><img src="/urmi-logo.png" alt="Urmi" /><span>SMS infrastructure for the agentic internet.</span><small>© 2026 Urmi Technologies</small></footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
360
frontend/src/features/messaging/ComposeMessage.tsx
Normal file
360
frontend/src/features/messaging/ComposeMessage.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import {
|
||||
CalendarClock, CheckCircle2, ChevronDown, MessageSquareText, Send, Settings2,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { messagingApi, type ComposeSendResponse, type SenderConfig, type TemplateConfig } from '../../lib/api'
|
||||
import { type ComposeMessageState, buildMessage, splitTemplate } from './templateUtils'
|
||||
|
||||
function countNumbers(value: string) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean).length
|
||||
}
|
||||
|
||||
function smsParts(text: string) {
|
||||
if (!text) return 0
|
||||
const length = text.length
|
||||
if (length <= 160) return 1
|
||||
return Math.ceil(length / 153)
|
||||
}
|
||||
|
||||
function TemplateMessageEditor({
|
||||
templateText,
|
||||
variables,
|
||||
onChange,
|
||||
}: {
|
||||
templateText: string
|
||||
variables: string[]
|
||||
onChange: (next: string[]) => void
|
||||
}) {
|
||||
const parts = splitTemplate(templateText)
|
||||
|
||||
return (
|
||||
<div className="template-message-editor">
|
||||
{parts.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="template-message-chunk">
|
||||
<span className="template-fixed-text">{part}</span>
|
||||
{index < parts.length - 1 && (
|
||||
<input
|
||||
className="template-variable-input"
|
||||
value={variables[index] ?? ''}
|
||||
placeholder={`Variable ${index + 1}`}
|
||||
onChange={(event) => {
|
||||
const next = [...variables]
|
||||
next[index] = event.target.value
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComposeMessage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [channel, setChannel] = useState('')
|
||||
const [senderConfigId, setSenderConfigId] = useState('')
|
||||
const [templateId, setTemplateId] = useState('')
|
||||
const [campaignName, setCampaignName] = useState('')
|
||||
const [numbers, setNumbers] = useState('')
|
||||
const [language, setLanguage] = useState('')
|
||||
const [variables, setVariables] = useState<string[]>([])
|
||||
const [saveDraft, setSaveDraft] = useState(false)
|
||||
const [senders, setSenders] = useState<SenderConfig[]>([])
|
||||
const [templates, setTemplates] = useState<TemplateConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [sendResult, setSendResult] = useState<ComposeSendResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([messagingApi.listSenders(), messagingApi.listTemplates()])
|
||||
.then(([senderList, templateList]) => {
|
||||
setSenders(senderList)
|
||||
setTemplates(templateList)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
const preload = location.state as ComposeMessageState | null
|
||||
if (!preload?.templateId) return
|
||||
|
||||
const sender = senders.find((item) => item.id === preload.senderConfigId)
|
||||
const template = templates.find((item) => item.id === preload.templateId)
|
||||
if (
|
||||
sender?.status === 'approved'
|
||||
&& template?.status === 'approved'
|
||||
&& template.sender_config_id === sender.id
|
||||
) {
|
||||
setChannel(preload.channel)
|
||||
setSenderConfigId(preload.senderConfigId)
|
||||
setTemplateId(preload.templateId)
|
||||
setError('')
|
||||
setSendResult(null)
|
||||
}
|
||||
|
||||
navigate(location.pathname, { replace: true, state: null })
|
||||
}, [loading, location.pathname, location.state, navigate, senders, templates])
|
||||
|
||||
const approvedSenders = useMemo(
|
||||
() => (channel
|
||||
? senders.filter((sender) => sender.status === 'approved' && sender.sender_type === channel)
|
||||
: []),
|
||||
[senders, channel],
|
||||
)
|
||||
|
||||
const approvedTemplates = useMemo(
|
||||
() => (senderConfigId
|
||||
? templates.filter(
|
||||
(template) => template.status === 'approved' && template.sender_config_id === senderConfigId,
|
||||
)
|
||||
: []),
|
||||
[templates, senderConfigId],
|
||||
)
|
||||
|
||||
const selectedTemplate = templateId
|
||||
? approvedTemplates.find((template) => template.id === templateId) ?? null
|
||||
: null
|
||||
|
||||
const composedMessage = selectedTemplate
|
||||
? buildMessage(selectedTemplate.template_text, variables)
|
||||
: ''
|
||||
|
||||
const characterCount = composedMessage.length
|
||||
const messageCount = smsParts(composedMessage)
|
||||
const recipientCount = countNumbers(numbers)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) {
|
||||
setVariables([])
|
||||
return
|
||||
}
|
||||
const variableCount = Math.max(0, splitTemplate(selectedTemplate.template_text).length - 1)
|
||||
setVariables(Array.from({ length: variableCount }, () => ''))
|
||||
}, [selectedTemplate?.id, selectedTemplate?.template_text])
|
||||
|
||||
function handleChannelChange(next: string) {
|
||||
setChannel(next)
|
||||
setSenderConfigId('')
|
||||
setTemplateId('')
|
||||
setVariables([])
|
||||
setError('')
|
||||
setSendResult(null)
|
||||
}
|
||||
|
||||
function validateBeforeSend() {
|
||||
if (!channel) return 'Select a message channel'
|
||||
if (!senderConfigId) return 'Select a sender ID'
|
||||
if (!templateId || !selectedTemplate) return 'Select a template'
|
||||
if (!language) return 'Select a language'
|
||||
if (!numbers.trim()) return 'Enter at least one recipient number'
|
||||
if (variables.some((value) => !value.trim())) return 'Fill all template variables'
|
||||
if (!composedMessage.trim()) return 'Message cannot be empty'
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSendNow() {
|
||||
setError('')
|
||||
setSendResult(null)
|
||||
const validationError = validateBeforeSend()
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
try {
|
||||
const result = await messagingApi.send({
|
||||
sender_config_id: senderConfigId,
|
||||
template_id: templateId,
|
||||
numbers: numbers.trim(),
|
||||
message: composedMessage,
|
||||
campaign_name: campaignName.trim() || null,
|
||||
})
|
||||
setSendResult(result)
|
||||
if (!result.success) {
|
||||
setError(`${result.failed} of ${result.sent + result.failed} messages failed to send`)
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to send message')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading messaging…</div>
|
||||
|
||||
return (
|
||||
<div className="compose-message-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Communicate</span>
|
||||
<h1>Compose New Message</h1>
|
||||
<p>Send SMS using approved sender IDs and DLT templates.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel compose-message-card">
|
||||
<div className="compose-card-header">
|
||||
<div>
|
||||
<MessageSquareText size={18} />
|
||||
<h2>Compose New Message</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compose-grid">
|
||||
<label className="compose-field">
|
||||
<span>Message Channel</span>
|
||||
<div className="select-wrap">
|
||||
<select value={channel} onChange={(event) => handleChannelChange(event.target.value)}>
|
||||
<option value="">Select a channel</option>
|
||||
<option value="transactional">Transactional</option>
|
||||
<option value="promotional">Promotional</option>
|
||||
</select>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="compose-field">
|
||||
<span>SenderID</span>
|
||||
<div className="select-wrap">
|
||||
<select
|
||||
value={senderConfigId}
|
||||
onChange={(event) => {
|
||||
setSenderConfigId(event.target.value)
|
||||
setTemplateId('')
|
||||
setVariables([])
|
||||
}}
|
||||
disabled={!channel || approvedSenders.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{!channel ? 'Select a channel first' : approvedSenders.length === 0 ? 'No approved sender IDs' : 'Select a senderid'}
|
||||
</option>
|
||||
{approvedSenders.map((sender) => (
|
||||
<option key={sender.id} value={sender.id}>{sender.sender_id}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="compose-field compose-field-wide">
|
||||
<span>Campaign Name</span>
|
||||
<input value={campaignName} onChange={(event) => setCampaignName(event.target.value)} placeholder="Enter campaign name" />
|
||||
</label>
|
||||
|
||||
<label className="compose-field compose-field-wide">
|
||||
<span>Numbers ({recipientCount})</span>
|
||||
<textarea
|
||||
value={numbers}
|
||||
onChange={(event) => setNumbers(event.target.value)}
|
||||
rows={5}
|
||||
placeholder="Enter recipient numbers in comma separated, e.g. 919999999999,918888888888,911234567890"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="compose-message-section compose-field-wide">
|
||||
<div className="compose-message-toolbar">
|
||||
<label className="compose-field compose-template-field">
|
||||
<span>Select Template</span>
|
||||
<div className="select-wrap">
|
||||
<select
|
||||
value={templateId}
|
||||
onChange={(event) => setTemplateId(event.target.value)}
|
||||
disabled={!senderConfigId || approvedTemplates.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{!senderConfigId ? 'Select a sender first' : approvedTemplates.length === 0 ? 'No approved templates' : 'Select a template'}
|
||||
</option>
|
||||
{approvedTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>{template.template_name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="compose-field compose-language-field">
|
||||
<span>Language</span>
|
||||
<div className="select-wrap">
|
||||
<select value={language} onChange={(event) => setLanguage(event.target.value)}>
|
||||
<option value="">Select language</option>
|
||||
<option value="ENGLISH">ENGLISH</option>
|
||||
</select>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedTemplate ? (
|
||||
<TemplateMessageEditor
|
||||
templateText={selectedTemplate.template_text}
|
||||
variables={variables}
|
||||
onChange={setVariables}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className="compose-free-text"
|
||||
value=""
|
||||
readOnly
|
||||
rows={8}
|
||||
placeholder="Select an approved template to compose your message."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="compose-message-meta">
|
||||
<span>{characterCount} Characters Used</span>
|
||||
<span>Count {messageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="auth-error compose-status">{error}</p>}
|
||||
{sendResult && (
|
||||
<div className={`compose-send-result ${sendResult.success ? 'success' : 'warning'}`}>
|
||||
<strong>
|
||||
<CheckCircle2 size={15} />
|
||||
{sendResult.success
|
||||
? `Sent ${sendResult.sent} message${sendResult.sent === 1 ? '' : 's'} successfully`
|
||||
: `Sent ${sendResult.sent}, failed ${sendResult.failed}`}
|
||||
</strong>
|
||||
<small>
|
||||
{sendResult.template_name} · {sendResult.sender_id}
|
||||
{sendResult.campaign_name ? ` · ${sendResult.campaign_name}` : ''}
|
||||
</small>
|
||||
{sendResult.results.some((item) => !item.success) && (
|
||||
<ul>
|
||||
{sendResult.results.filter((item) => !item.success).map((item) => (
|
||||
<li key={item.to}>{item.to}: {item.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="compose-footer">
|
||||
<button type="button" className="secondary-button compose-settings-button">
|
||||
<Settings2 size={15} /> Settings <ChevronDown size={14} />
|
||||
</button>
|
||||
|
||||
<div className="compose-footer-actions">
|
||||
<label className="compose-draft-toggle">
|
||||
<input type="checkbox" checked={saveDraft} onChange={(event) => setSaveDraft(event.target.checked)} />
|
||||
Save As Draft
|
||||
</label>
|
||||
<button type="button" className="primary-button" onClick={handleSendNow} disabled={sending}>
|
||||
<Send size={15} /> {sending ? 'Sending…' : 'Send Now'}
|
||||
</button>
|
||||
<button type="button" className="primary-button">
|
||||
<CalendarClock size={15} /> Schedule For Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
363
frontend/src/features/messaging/MessageSettings.tsx
Normal file
363
frontend/src/features/messaging/MessageSettings.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { ChevronDown, MessageSquareText, Plus, ShieldCheck, SlidersHorizontal, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { messagingApi, type SenderConfig, type TemplateConfig } from '../../lib/api'
|
||||
|
||||
const SENDER_RE = /^[A-Z0-9]{3,11}$/
|
||||
const DLT_RE = /^\d{12,25}$/
|
||||
const TEMPLATE_NAME_RE = /^[A-Z0-9_]{3,40}$/
|
||||
const VARIABLE_TOKEN = '{#var#}'
|
||||
const TEMPLATE_EXAMPLE =
|
||||
'Your URMI app verification code is {#var#}. It is valid for {#var#} minutes. Do not share it to anyone - URMI AI'
|
||||
|
||||
function AccordionSection({
|
||||
title,
|
||||
description,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className={`panel accordion-panel ${open ? 'open' : ''}`}>
|
||||
<button type="button" className="accordion-trigger" onClick={onToggle}>
|
||||
<div><h2>{title}</h2><p>{description}</p></div>
|
||||
<ChevronDown size={18} className={open ? 'rotated' : ''} />
|
||||
</button>
|
||||
{open && <div className="accordion-body">{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AddSenderModal({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
|
||||
const [senderId, setSenderId] = useState('')
|
||||
const [senderType, setSenderType] = useState<'promotional' | 'transactional'>('promotional')
|
||||
const [purpose, setPurpose] = useState('')
|
||||
const [dltEntityId, setDltEntityId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
const normalizedSender = senderId.trim().toUpperCase()
|
||||
if (!SENDER_RE.test(normalizedSender)) {
|
||||
setError('Sender ID must be 3–11 uppercase letters or numbers')
|
||||
return
|
||||
}
|
||||
if (purpose.trim().length < 10) {
|
||||
setError('Purpose must be at least 10 characters')
|
||||
return
|
||||
}
|
||||
if (!DLT_RE.test(dltEntityId.trim())) {
|
||||
setError('DLT Entity ID must be 12–25 digits')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await messagingApi.createSender({
|
||||
sender_id: normalizedSender,
|
||||
sender_type: senderType,
|
||||
purpose: purpose.trim(),
|
||||
dlt_entity_id: dltEntityId.trim(),
|
||||
})
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to save sender')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<form className="modal-card sender-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<div className="modal-header">
|
||||
<h2>Add New SenderId</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<label className="modal-field">
|
||||
<span>SenderId :</span>
|
||||
<input value={senderId} onChange={(e) => setSenderId(e.target.value.toUpperCase())} placeholder="Enter SenderId" maxLength={11} />
|
||||
</label>
|
||||
|
||||
<div className="modal-field">
|
||||
<span>Type :</span>
|
||||
<div className="radio-row">
|
||||
<label><input type="radio" name="senderType" checked={senderType === 'promotional'} onChange={() => setSenderType('promotional')} /> Promotional</label>
|
||||
<label><input type="radio" name="senderType" checked={senderType === 'transactional'} onChange={() => setSenderType('transactional')} /> Transactional</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="modal-field">
|
||||
<span>Purpose :</span>
|
||||
<textarea value={purpose} onChange={(e) => setPurpose(e.target.value)} placeholder="Enter Purpose" rows={4} />
|
||||
</label>
|
||||
|
||||
<label className="modal-field">
|
||||
<span>DLT Entity Id :</span>
|
||||
<input value={dltEntityId} onChange={(e) => setDltEntityId(e.target.value.replace(/\D/g, ''))} placeholder="Enter DLT Entity Id" maxLength={25} />
|
||||
</label>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="submit" className="primary-button" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddTemplateModal({
|
||||
approvedSenders,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
approvedSenders: SenderConfig[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [senderConfigId, setSenderConfigId] = useState('')
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const [templateText, setTemplateText] = useState('')
|
||||
const [dltTemplateId, setDltTemplateId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function addVariable() {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) {
|
||||
setTemplateText((value) => `${value}${VARIABLE_TOKEN}`)
|
||||
return
|
||||
}
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const next = `${templateText.slice(0, start)}${VARIABLE_TOKEN}${templateText.slice(end)}`
|
||||
setTemplateText(next)
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
const cursor = start + VARIABLE_TOKEN.length
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
if (!senderConfigId) {
|
||||
setError('Select an approved sender ID')
|
||||
return
|
||||
}
|
||||
const normalizedName = templateName.trim().toUpperCase().replace(/\s+/g, '_')
|
||||
if (!TEMPLATE_NAME_RE.test(normalizedName)) {
|
||||
setError('Template name must be 3–40 uppercase letters, numbers, or underscores')
|
||||
return
|
||||
}
|
||||
if (templateText.trim().length < 10) {
|
||||
setError('Template must be at least 10 characters')
|
||||
return
|
||||
}
|
||||
if (!DLT_RE.test(dltTemplateId.trim())) {
|
||||
setError('DLT Template ID must be 12–25 digits')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await messagingApi.createTemplate({
|
||||
sender_config_id: senderConfigId,
|
||||
template_name: normalizedName,
|
||||
template_text: templateText.trim(),
|
||||
dlt_template_id: dltTemplateId.trim(),
|
||||
})
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to save template')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<form className="modal-card sender-modal template-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<div className="modal-header">
|
||||
<h2>Add New Template</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<label className="modal-field">
|
||||
<span>SenderId :</span>
|
||||
<select value={senderConfigId} onChange={(e) => setSenderConfigId(e.target.value)} required>
|
||||
<option value="">Select a senderid</option>
|
||||
{approvedSenders.map((sender) => (
|
||||
<option key={sender.id} value={sender.id}>{sender.sender_id}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="modal-field">
|
||||
<span>Template Name :</span>
|
||||
<input
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value.toUpperCase().replace(/\s+/g, '_'))}
|
||||
placeholder="Enter template Name"
|
||||
maxLength={40}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="modal-field template-field">
|
||||
<span>
|
||||
Template :{' '}
|
||||
<button type="button" className="text-link" onClick={addVariable}>Add Variable</button>
|
||||
</span>
|
||||
<div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={templateText}
|
||||
onChange={(e) => setTemplateText(e.target.value)}
|
||||
placeholder="Enter template"
|
||||
rows={5}
|
||||
/>
|
||||
<p className="template-example">E.g :- {TEMPLATE_EXAMPLE}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="modal-field">
|
||||
<span>DLT Template ID :</span>
|
||||
<input
|
||||
value={dltTemplateId}
|
||||
onChange={(e) => setDltTemplateId(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="Enter dlt template id"
|
||||
maxLength={25}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="submit" className="primary-button" disabled={saving || approvedSenders.length === 0}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatType(type: SenderConfig['sender_type']) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1)
|
||||
}
|
||||
|
||||
export function MessageSettings() {
|
||||
const [senderOpen, setSenderOpen] = useState(true)
|
||||
const [templateOpen, setTemplateOpen] = useState(false)
|
||||
const [senders, setSenders] = useState<SenderConfig[]>([])
|
||||
const [templates, setTemplates] = useState<TemplateConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [senderModalOpen, setSenderModalOpen] = useState(false)
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
||||
|
||||
const approvedSenders = senders.filter((sender) => sender.status === 'approved')
|
||||
|
||||
async function loadData() {
|
||||
const [senderList, templateList] = await Promise.all([
|
||||
messagingApi.listSenders(),
|
||||
messagingApi.listTemplates(),
|
||||
])
|
||||
setSenders(senderList)
|
||||
setTemplates(templateList)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData().finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="message-settings-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Compliance</span>
|
||||
<h1>Message settings</h1>
|
||||
<p>Configure sender IDs and DLT templates for outbound SMS.</p>
|
||||
</div>
|
||||
|
||||
<AccordionSection
|
||||
title="Sender configs"
|
||||
description="Headers, numeric senders, and routing defaults."
|
||||
open={senderOpen}
|
||||
onToggle={() => setSenderOpen(!senderOpen)}
|
||||
>
|
||||
<div className="accordion-toolbar">
|
||||
<div className="accordion-heading"><SlidersHorizontal size={18} /><span>Registered senders for this project</span></div>
|
||||
<button className="primary-button" onClick={() => setSenderModalOpen(true)}><Plus size={15} /> Add</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="empty-copy">Loading sender configs…</p>
|
||||
) : senders.length === 0 ? (
|
||||
<p className="empty-copy">No sender IDs yet. Add one to request admin approval.</p>
|
||||
) : (
|
||||
senders.map((item) => (
|
||||
<div className="compliance-row" key={item.id}>
|
||||
<span><MessageSquareText size={14} /> {item.sender_id} · {formatType(item.sender_type)}</span>
|
||||
<strong className={`doc-status ${item.status}`}>{item.status}</strong>
|
||||
<small>DLT {item.dlt_entity_id} · {item.purpose}</small>
|
||||
{item.review_note && <small>{item.review_note}</small>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
title="Template configs"
|
||||
description="DLT-approved message templates linked to senders."
|
||||
open={templateOpen}
|
||||
onToggle={() => setTemplateOpen(!templateOpen)}
|
||||
>
|
||||
<div className="accordion-toolbar">
|
||||
<div className="accordion-heading"><ShieldCheck size={18} /><span>Registered content templates</span></div>
|
||||
<button className="primary-button" onClick={() => setTemplateModalOpen(true)}><Plus size={15} /> Add</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="empty-copy">Loading template configs…</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="empty-copy">No templates yet. Add one to request admin approval.</p>
|
||||
) : (
|
||||
templates.map((item) => (
|
||||
<div className="compliance-row" key={item.id}>
|
||||
<span><ShieldCheck size={14} /> {item.template_name} · {item.sender_id}</span>
|
||||
<strong className={`doc-status ${item.status}`}>{item.status}</strong>
|
||||
<small>DLT {item.dlt_template_id}</small>
|
||||
<small>{item.template_text}</small>
|
||||
{item.review_note && <small>{item.review_note}</small>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{approvedSenders.length === 0 && !loading && (
|
||||
<p className="empty-copy">Templates require at least one approved sender ID.</p>
|
||||
)}
|
||||
</AccordionSection>
|
||||
|
||||
{senderModalOpen && <AddSenderModal onClose={() => setSenderModalOpen(false)} onSaved={loadData} />}
|
||||
{templateModalOpen && (
|
||||
<AddTemplateModal
|
||||
approvedSenders={approvedSenders}
|
||||
onClose={() => setTemplateModalOpen(false)}
|
||||
onSaved={loadData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
frontend/src/features/messaging/TemplatesPage.tsx
Normal file
121
frontend/src/features/messaging/TemplatesPage.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { BookOpen, Send } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { messagingApi, type SenderConfig, type TemplateConfig } from '../../lib/api'
|
||||
import { templateVariableCount, type ComposeMessageState } from './templateUtils'
|
||||
|
||||
function channelLabel(senderType: string | undefined) {
|
||||
if (senderType === 'transactional') return 'Transactional'
|
||||
if (senderType === 'promotional') return 'Promotional'
|
||||
return '—'
|
||||
}
|
||||
|
||||
export function TemplatesPage() {
|
||||
const navigate = useNavigate()
|
||||
const [templates, setTemplates] = useState<TemplateConfig[]>([])
|
||||
const [senders, setSenders] = useState<SenderConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([messagingApi.listTemplates(), messagingApi.listSenders()])
|
||||
.then(([templateList, senderList]) => {
|
||||
setTemplates(templateList)
|
||||
setSenders(senderList)
|
||||
})
|
||||
.catch(() => setError('Unable to load templates'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const senderById = useMemo(
|
||||
() => new Map(senders.map((sender) => [sender.id, sender])),
|
||||
[senders],
|
||||
)
|
||||
|
||||
function openCompose(template: TemplateConfig) {
|
||||
const sender = senderById.get(template.sender_config_id)
|
||||
if (!sender || template.status !== 'approved' || sender.status !== 'approved') return
|
||||
|
||||
const state: ComposeMessageState = {
|
||||
templateId: template.id,
|
||||
senderConfigId: template.sender_config_id,
|
||||
channel: sender.sender_type,
|
||||
}
|
||||
navigate('/console/messaging', { state })
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading templates…</div>
|
||||
|
||||
return (
|
||||
<div className="templates-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Communicate</span>
|
||||
<h1>Templates</h1>
|
||||
<p>All registered DLT templates for your account. Send a message using any approved template.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel templates-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Your templates</h2>
|
||||
<p>{templates.length} template{templates.length === 1 ? '' : 's'} registered</p>
|
||||
</div>
|
||||
<BookOpen size={19} />
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="templates-empty">
|
||||
<strong>No templates yet</strong>
|
||||
<span>Add sender IDs and templates under Message settings, then return here to send.</span>
|
||||
<Link className="secondary-button" to="/console/message-settings">Message settings</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="templates-table">
|
||||
<div className="templates-table-head">
|
||||
<span>Template</span>
|
||||
<span>Sender ID</span>
|
||||
<span>Channel</span>
|
||||
<span>DLT template ID</span>
|
||||
<span>Variables</span>
|
||||
<span>Status</span>
|
||||
<span />
|
||||
</div>
|
||||
{templates.map((template) => {
|
||||
const sender = senderById.get(template.sender_config_id)
|
||||
const canSend = template.status === 'approved' && sender?.status === 'approved'
|
||||
const variableCount = templateVariableCount(template.template_text)
|
||||
|
||||
return (
|
||||
<div className="templates-table-row" key={template.id}>
|
||||
<div className="templates-table-template">
|
||||
<strong>{template.template_name}</strong>
|
||||
<small>{template.template_text}</small>
|
||||
</div>
|
||||
<span className="templates-table-sender">{template.sender_id}</span>
|
||||
<span className="templates-table-channel">{channelLabel(sender?.sender_type)}</span>
|
||||
<code className="templates-table-dlt">{template.dlt_template_id}</code>
|
||||
<span className="templates-table-vars">
|
||||
{variableCount === 0 ? 'None' : `${variableCount} slot${variableCount === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
<span className={`doc-status ${template.status}`}>{template.status}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button templates-send-button"
|
||||
disabled={!canSend}
|
||||
title={canSend ? 'Send message with this template' : 'Only approved templates can be sent'}
|
||||
aria-label={`Send message using ${template.template_name}`}
|
||||
onClick={() => openCompose(template)}
|
||||
>
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
frontend/src/features/messaging/templateUtils.ts
Normal file
23
frontend/src/features/messaging/templateUtils.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const PLACEHOLDER_PATTERN = /\{#(?:var|num)#\}/
|
||||
|
||||
export function splitTemplate(text: string) {
|
||||
return text.split(PLACEHOLDER_PATTERN)
|
||||
}
|
||||
|
||||
export function templateVariableCount(text: string) {
|
||||
return Math.max(0, splitTemplate(text).length - 1)
|
||||
}
|
||||
|
||||
export function buildMessage(templateText: string, variables: string[]) {
|
||||
const parts = splitTemplate(templateText)
|
||||
return parts.reduce((message, part, index) => {
|
||||
const suffix = index < variables.length ? variables[index] : ''
|
||||
return message + part + suffix
|
||||
}, '')
|
||||
}
|
||||
|
||||
export type ComposeMessageState = {
|
||||
templateId: string
|
||||
senderConfigId: string
|
||||
channel: 'transactional' | 'promotional'
|
||||
}
|
||||
195
frontend/src/features/numbers/NumbersPages.tsx
Normal file
195
frontend/src/features/numbers/NumbersPages.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { CircleDollarSign, Network, PhoneIncoming, PhoneOutgoing, ShoppingCart } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { numbersApi, type PhoneNumber } from '../../lib/api'
|
||||
|
||||
function formatMonthly(amount: number, currency: string) {
|
||||
const symbol = currency === 'INR' ? '₹' : `${currency} `
|
||||
return `${symbol}${amount.toFixed(0)}/mo`
|
||||
}
|
||||
|
||||
function formatPerMin(amount: number, currency: string) {
|
||||
const symbol = currency === 'INR' ? '₹' : `${currency} `
|
||||
return `${symbol}${amount.toFixed(2)}/min`
|
||||
}
|
||||
|
||||
function formatPhoneNumber(value: string) {
|
||||
if (value.length === 10) return `${value.slice(0, 5)} ${value.slice(5)}`
|
||||
return value
|
||||
}
|
||||
|
||||
export function BuyNumbersPage() {
|
||||
const [numbers, setNumbers] = useState<PhoneNumber[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [buyingId, setBuyingId] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
numbersApi.catalog()
|
||||
.then(setNumbers)
|
||||
.catch(() => setError('Unable to load available numbers'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function buyNumber(numberId: string, phoneNumber: string) {
|
||||
setBuyingId(numberId)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
try {
|
||||
await numbersApi.purchase(numberId)
|
||||
setNumbers((current) => current.filter((item) => item.id !== numberId))
|
||||
setSuccess(`${formatPhoneNumber(phoneNumber)} added to My Numbers`)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to purchase number')
|
||||
} finally {
|
||||
setBuyingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading numbers…</div>
|
||||
|
||||
return (
|
||||
<div className="numbers-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Numbers</span>
|
||||
<h1>Buy Numbers</h1>
|
||||
<p>Purchase voice numbers with monthly number cost plus per-minute inbound and outbound rates.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel numbers-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Available numbers</h2>
|
||||
<p>{numbers.length} number{numbers.length === 1 ? '' : 's'} ready to buy</p>
|
||||
</div>
|
||||
<ShoppingCart size={19} />
|
||||
</div>
|
||||
|
||||
{numbers.length === 0 ? (
|
||||
<div className="numbers-empty">
|
||||
<strong>No numbers available right now</strong>
|
||||
<span>Ask your admin to add inventory, or check <Link to="/console/numbers">My Numbers</Link> for numbers already assigned to you.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="numbers-table numbers-table-pricing">
|
||||
<div className="numbers-table-head">
|
||||
<span>Number</span>
|
||||
<span>Number cost</span>
|
||||
<span>Inbound</span>
|
||||
<span>Outbound</span>
|
||||
<span />
|
||||
</div>
|
||||
{numbers.map((number) => (
|
||||
<div className="numbers-table-row" key={number.id}>
|
||||
<strong className="numbers-table-phone">{formatPhoneNumber(number.phone_number)}</strong>
|
||||
<span className="numbers-price">
|
||||
<CircleDollarSign size={13} />
|
||||
{formatMonthly(number.monthly_number_price, number.currency)}
|
||||
</span>
|
||||
<span className="numbers-price">
|
||||
<PhoneIncoming size={13} />
|
||||
{formatPerMin(number.inbound_price, number.currency)}
|
||||
</span>
|
||||
<span className="numbers-price">
|
||||
<PhoneOutgoing size={13} />
|
||||
{formatPerMin(number.outbound_price, number.currency)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={buyingId === number.id}
|
||||
onClick={() => buyNumber(number.id, number.phone_number)}
|
||||
>
|
||||
<ShoppingCart size={14} /> {buyingId === number.id ? 'Buying…' : 'Buy'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && <p className="campaign-success">{success}</p>}
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MyNumbersPage() {
|
||||
const [numbers, setNumbers] = useState<PhoneNumber[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
numbersApi.mine()
|
||||
.then(setNumbers)
|
||||
.catch(() => setError('Unable to load your numbers'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading your numbers…</div>
|
||||
|
||||
return (
|
||||
<div className="numbers-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Numbers</span>
|
||||
<h1>My Numbers</h1>
|
||||
<p>Numbers assigned by admin or purchased from the catalog.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel numbers-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Your inventory</h2>
|
||||
<p>{numbers.length} active number{numbers.length === 1 ? '' : 's'}</p>
|
||||
</div>
|
||||
<Network size={19} />
|
||||
</div>
|
||||
|
||||
{numbers.length === 0 ? (
|
||||
<div className="numbers-empty">
|
||||
<strong>No numbers yet</strong>
|
||||
<span>Browse <Link to="/console/numbers/buy">Buy Numbers</Link> or ask admin to assign one to your account.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="numbers-table numbers-table-pricing">
|
||||
<div className="numbers-table-head">
|
||||
<span>Number</span>
|
||||
<span>Number cost</span>
|
||||
<span>Inbound</span>
|
||||
<span>Outbound</span>
|
||||
<span>Source</span>
|
||||
</div>
|
||||
{numbers.map((number) => (
|
||||
<div className="numbers-table-row" key={number.id}>
|
||||
<div>
|
||||
<strong className="numbers-table-phone">{formatPhoneNumber(number.phone_number)}</strong>
|
||||
{number.assigned_at && (
|
||||
<small>Since {new Date(number.assigned_at).toLocaleDateString()}</small>
|
||||
)}
|
||||
</div>
|
||||
<span className="numbers-price">
|
||||
<CircleDollarSign size={13} />
|
||||
{formatMonthly(number.monthly_number_price, number.currency)}
|
||||
</span>
|
||||
<span className="numbers-price">
|
||||
<PhoneIncoming size={13} />
|
||||
{formatPerMin(number.inbound_price, number.currency)}
|
||||
</span>
|
||||
<span className="numbers-price">
|
||||
<PhoneOutgoing size={13} />
|
||||
{formatPerMin(number.outbound_price, number.currency)}
|
||||
</span>
|
||||
<span className={`doc-status ${number.acquisition_type === 'purchased' ? 'approved' : 'pending'}`}>
|
||||
{number.acquisition_type === 'purchased' ? 'Purchased' : 'Admin assigned'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
617
frontend/src/features/testing/TestingCenterPage.tsx
Normal file
617
frontend/src/features/testing/TestingCenterPage.tsx
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
import { ChevronDown, KeyRound, MessageSquareText, Play, TestTube2 } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
apiKeysApi,
|
||||
messagesApi,
|
||||
messagingApi,
|
||||
urmiOtpApi,
|
||||
type ApiKey,
|
||||
type OtpTemplateDefinition,
|
||||
type SenderConfig,
|
||||
type TemplateConfig,
|
||||
type UrmiOtpProject,
|
||||
} from '../../lib/api'
|
||||
import { buildMessage, splitTemplate } from '../messaging/templateUtils'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
|
||||
|
||||
const OTP_TEMPLATE_TYPES = [
|
||||
{ type: 'general_verification', name: 'General Verification' },
|
||||
{ type: 'login_authentication', name: 'Login Authentication' },
|
||||
{ type: 'account_recovery', name: 'Account Recovery' },
|
||||
{ type: 'account_change', name: 'Account Change' },
|
||||
{ type: 'action_authorization', name: 'Action Authorization' },
|
||||
{ type: 'transaction_verification', name: 'Transaction Verification' },
|
||||
{ type: 'device_verification', name: 'Device Verification' },
|
||||
] as const
|
||||
|
||||
type TestingGroup = 'messaging' | 'urmi-otp'
|
||||
|
||||
function TestingAccordion({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
id: TestingGroup
|
||||
title: string
|
||||
description: string
|
||||
icon: typeof MessageSquareText
|
||||
open: boolean
|
||||
onToggle: (id: TestingGroup) => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className={`panel accordion-panel testing-accordion ${open ? 'open' : ''}`}>
|
||||
<button type="button" className="accordion-trigger" onClick={() => onToggle(id)}>
|
||||
<div className="testing-accordion-heading">
|
||||
<Icon size={18} />
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown size={18} className={open ? 'rotated' : ''} />
|
||||
</button>
|
||||
{open && <div className="accordion-body testing-accordion-body">{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateVariableEditor({
|
||||
templateText,
|
||||
variables,
|
||||
onChange,
|
||||
}: {
|
||||
templateText: string
|
||||
variables: string[]
|
||||
onChange: (next: string[]) => void
|
||||
}) {
|
||||
const parts = splitTemplate(templateText)
|
||||
|
||||
return (
|
||||
<div className="template-message-editor">
|
||||
{parts.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="template-message-chunk">
|
||||
<span className="template-fixed-text">{part}</span>
|
||||
{index < parts.length - 1 && (
|
||||
<input
|
||||
className="template-variable-input"
|
||||
value={variables[index] ?? ''}
|
||||
placeholder={`Variable ${index + 1}`}
|
||||
onChange={(event) => {
|
||||
const next = [...variables]
|
||||
next[index] = event.target.value
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiTestCard({
|
||||
title,
|
||||
summary,
|
||||
method,
|
||||
path,
|
||||
notice,
|
||||
children,
|
||||
running,
|
||||
onRun,
|
||||
response,
|
||||
error,
|
||||
runDisabled,
|
||||
}: {
|
||||
title: string
|
||||
summary: string
|
||||
method: string
|
||||
path: string
|
||||
notice?: string
|
||||
children: ReactNode
|
||||
running: boolean
|
||||
onRun: () => void
|
||||
response: unknown
|
||||
error: string
|
||||
runDisabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<article className="testing-api-card">
|
||||
<div className="testing-api-header">
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>{summary}</p>
|
||||
</div>
|
||||
<span className="explorer-method">{method}</span>
|
||||
</div>
|
||||
<div className="explorer-field-row"><span>Endpoint</span><code>{API_BASE_URL}{path}</code></div>
|
||||
{notice && <div className="testing-notice">{notice}</div>}
|
||||
<div className="testing-fields">{children}</div>
|
||||
<div className="testing-actions">
|
||||
<button type="button" className="primary-button" onClick={onRun} disabled={running || runDisabled}>
|
||||
<Play size={15} /> {running ? 'Running…' : 'Run test'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <pre className="testing-response error">{error}</pre>}
|
||||
{response !== null && !error && (
|
||||
<pre className="testing-response success">{JSON.stringify(response, null, 2)}</pre>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function TestField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<label className="testing-field">
|
||||
<strong>{label}</strong>
|
||||
{hint && <span>{hint}</span>}
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function MessagingApiKeyTest({
|
||||
senders,
|
||||
templates,
|
||||
apiKeys,
|
||||
}: {
|
||||
senders: SenderConfig[]
|
||||
templates: TemplateConfig[]
|
||||
apiKeys: ApiKey[]
|
||||
}) {
|
||||
const approvedSenders = useMemo(() => senders.filter((item) => item.status === 'approved'), [senders])
|
||||
const approvedTemplates = useMemo(() => templates.filter((item) => item.status === 'approved'), [templates])
|
||||
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [senderId, setSenderId] = useState('')
|
||||
const [templateId, setTemplateId] = useState('')
|
||||
const [to, setTo] = useState('919999999999')
|
||||
const [variables, setVariables] = useState<string[]>([])
|
||||
const [running, setRunning] = useState(false)
|
||||
const [response, setResponse] = useState<unknown>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const senderTemplates = useMemo(
|
||||
() => approvedTemplates.filter((item) => item.sender_id === senderId),
|
||||
[approvedTemplates, senderId],
|
||||
)
|
||||
|
||||
const selectedTemplate = senderTemplates.find((item) => item.id === templateId) ?? null
|
||||
const message = selectedTemplate ? buildMessage(selectedTemplate.template_text, variables) : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (approvedSenders.length === 0) return
|
||||
setSenderId((current) => current || approvedSenders[0].sender_id)
|
||||
}, [approvedSenders])
|
||||
|
||||
useEffect(() => {
|
||||
const nextTemplates = approvedTemplates.filter((item) => item.sender_id === senderId)
|
||||
if (nextTemplates.length === 0) {
|
||||
setTemplateId('')
|
||||
setVariables([])
|
||||
return
|
||||
}
|
||||
setTemplateId((current) => (nextTemplates.some((item) => item.id === current) ? current : nextTemplates[0].id))
|
||||
}, [approvedTemplates, senderId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) {
|
||||
setVariables([])
|
||||
return
|
||||
}
|
||||
const count = Math.max(0, splitTemplate(selectedTemplate.template_text).length - 1)
|
||||
setVariables(Array.from({ length: count }, (_, index) => (index === 0 ? '583921' : index === 1 ? '10' : '')))
|
||||
}, [selectedTemplate?.id, selectedTemplate?.template_text])
|
||||
|
||||
async function runTest() {
|
||||
setError('')
|
||||
setResponse(null)
|
||||
setRunning(true)
|
||||
try {
|
||||
const result = await messagesApi.send(apiKey.trim(), {
|
||||
sender_id: senderId,
|
||||
template_id: templateId,
|
||||
to: to.trim(),
|
||||
message: message.trim(),
|
||||
})
|
||||
setResponse(result)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Request failed')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const keyHint = apiKeys.find((item) => item.status === 'active')
|
||||
? `Paste a full key from API Keys. Active prefix: ${apiKeys.find((item) => item.status === 'active')?.key_prefix}…`
|
||||
: 'Create an API key under Develop → API Keys'
|
||||
|
||||
return (
|
||||
<ApiTestCard
|
||||
title="Send template SMS"
|
||||
summary="API key authentication for a single recipient."
|
||||
method="POST"
|
||||
path="/api/v1/messages/send"
|
||||
running={running}
|
||||
onRun={runTest}
|
||||
response={response}
|
||||
error={error}
|
||||
runDisabled={!apiKey.trim() || !senderId || !templateId || !message.trim() || approvedSenders.length === 0}
|
||||
>
|
||||
<TestField label="API key" hint={keyHint}>
|
||||
<input type="password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} placeholder="urmi_live_..." />
|
||||
</TestField>
|
||||
<TestField label="Sender ID" hint="Pre-filled from your approved senders">
|
||||
<select value={senderId} onChange={(event) => setSenderId(event.target.value)} disabled={approvedSenders.length === 0}>
|
||||
{approvedSenders.length === 0 && <option value="">No approved senders</option>}
|
||||
{approvedSenders.map((sender) => (
|
||||
<option key={sender.id} value={sender.sender_id}>{sender.sender_id} · {sender.sender_type}</option>
|
||||
))}
|
||||
</select>
|
||||
</TestField>
|
||||
<TestField label="Template" hint="Pre-filled from templates linked to the selected sender">
|
||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)} disabled={senderTemplates.length === 0}>
|
||||
{senderTemplates.length === 0 && <option value="">No approved templates</option>}
|
||||
{senderTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>{template.template_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</TestField>
|
||||
<TestField label="Recipient (to)" hint="Mobile number with country code">
|
||||
<input value={to} onChange={(event) => setTo(event.target.value)} placeholder="919999999999" />
|
||||
</TestField>
|
||||
{selectedTemplate && (
|
||||
<TestField label="Message variables" hint="Fill {#var#} placeholders — the final message is built automatically">
|
||||
<TemplateVariableEditor
|
||||
templateText={selectedTemplate.template_text}
|
||||
variables={variables}
|
||||
onChange={setVariables}
|
||||
/>
|
||||
<code className="testing-built-message">{message || 'Complete all variables to preview the message'}</code>
|
||||
</TestField>
|
||||
)}
|
||||
</ApiTestCard>
|
||||
)
|
||||
}
|
||||
|
||||
function MessagingConsoleTest({
|
||||
senders,
|
||||
templates,
|
||||
}: {
|
||||
senders: SenderConfig[]
|
||||
templates: TemplateConfig[]
|
||||
}) {
|
||||
const approvedSenders = useMemo(() => senders.filter((item) => item.status === 'approved'), [senders])
|
||||
const approvedTemplates = useMemo(() => templates.filter((item) => item.status === 'approved'), [templates])
|
||||
|
||||
const [senderConfigId, setSenderConfigId] = useState('')
|
||||
const [templateId, setTemplateId] = useState('')
|
||||
const [numbers, setNumbers] = useState('919999999999')
|
||||
const [campaignName, setCampaignName] = useState('testing-center')
|
||||
const [variables, setVariables] = useState<string[]>([])
|
||||
const [running, setRunning] = useState(false)
|
||||
const [response, setResponse] = useState<unknown>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const senderTemplates = useMemo(
|
||||
() => approvedTemplates.filter((item) => item.sender_config_id === senderConfigId),
|
||||
[approvedTemplates, senderConfigId],
|
||||
)
|
||||
|
||||
const selectedTemplate = senderTemplates.find((item) => item.id === templateId) ?? null
|
||||
const message = selectedTemplate ? buildMessage(selectedTemplate.template_text, variables) : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (approvedSenders.length === 0) return
|
||||
setSenderConfigId((current) => current || approvedSenders[0].id)
|
||||
}, [approvedSenders])
|
||||
|
||||
useEffect(() => {
|
||||
if (senderTemplates.length === 0) {
|
||||
setTemplateId('')
|
||||
setVariables([])
|
||||
return
|
||||
}
|
||||
setTemplateId((current) => (senderTemplates.some((item) => item.id === current) ? current : senderTemplates[0].id))
|
||||
}, [senderTemplates])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) {
|
||||
setVariables([])
|
||||
return
|
||||
}
|
||||
const count = Math.max(0, splitTemplate(selectedTemplate.template_text).length - 1)
|
||||
setVariables(Array.from({ length: count }, (_, index) => (index === 0 ? '583921' : index === 1 ? '10' : '')))
|
||||
}, [selectedTemplate?.id, selectedTemplate?.template_text])
|
||||
|
||||
async function runTest() {
|
||||
setError('')
|
||||
setResponse(null)
|
||||
setRunning(true)
|
||||
try {
|
||||
const result = await messagingApi.send({
|
||||
sender_config_id: senderConfigId,
|
||||
template_id: templateId,
|
||||
numbers: numbers.trim(),
|
||||
message: message.trim(),
|
||||
campaign_name: campaignName.trim() || null,
|
||||
})
|
||||
setResponse(result)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Request failed')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiTestCard
|
||||
title="Send SMS from console"
|
||||
summary="Session-authenticated batch send used by the compose screen."
|
||||
method="POST"
|
||||
path="/api/v1/messaging/send"
|
||||
running={running}
|
||||
onRun={runTest}
|
||||
response={response}
|
||||
error={error}
|
||||
runDisabled={!senderConfigId || !templateId || !message.trim() || approvedSenders.length === 0}
|
||||
>
|
||||
<TestField label="Sender ID" hint="Pre-filled from your approved sender configs">
|
||||
<select value={senderConfigId} onChange={(event) => setSenderConfigId(event.target.value)} disabled={approvedSenders.length === 0}>
|
||||
{approvedSenders.length === 0 && <option value="">No approved senders</option>}
|
||||
{approvedSenders.map((sender) => (
|
||||
<option key={sender.id} value={sender.id}>{sender.sender_id} · {sender.sender_type}</option>
|
||||
))}
|
||||
</select>
|
||||
</TestField>
|
||||
<TestField label="Template" hint="Templates registered for the selected sender">
|
||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)} disabled={senderTemplates.length === 0}>
|
||||
{senderTemplates.length === 0 && <option value="">No approved templates</option>}
|
||||
{senderTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>{template.template_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</TestField>
|
||||
<TestField label="Numbers" hint="Comma-separated recipients">
|
||||
<textarea value={numbers} onChange={(event) => setNumbers(event.target.value)} rows={3} />
|
||||
</TestField>
|
||||
<TestField label="Campaign name">
|
||||
<input value={campaignName} onChange={(event) => setCampaignName(event.target.value)} />
|
||||
</TestField>
|
||||
{selectedTemplate && (
|
||||
<TestField label="Message variables" hint="Fill {#var#} placeholders for the selected template">
|
||||
<TemplateVariableEditor
|
||||
templateText={selectedTemplate.template_text}
|
||||
variables={variables}
|
||||
onChange={setVariables}
|
||||
/>
|
||||
<code className="testing-built-message">{message || 'Complete all variables to preview the message'}</code>
|
||||
</TestField>
|
||||
)}
|
||||
</ApiTestCard>
|
||||
)
|
||||
}
|
||||
|
||||
function UrmiOtpGenerateTest({
|
||||
projects,
|
||||
apiKeys,
|
||||
}: {
|
||||
projects: UrmiOtpProject[]
|
||||
apiKeys: ApiKey[]
|
||||
}) {
|
||||
const approvedProjects = useMemo(() => projects.filter((item) => item.status === 'approved'), [projects])
|
||||
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [templateType, setTemplateType] = useState<string>(OTP_TEMPLATE_TYPES[0].type)
|
||||
const [otp, setOtp] = useState('583921')
|
||||
const [validityMinutes, setValidityMinutes] = useState('10')
|
||||
const [recipient, setRecipient] = useState('919999999999')
|
||||
const [previewTemplate, setPreviewTemplate] = useState<OtpTemplateDefinition | null>(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [response, setResponse] = useState<unknown>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const selectedProject = approvedProjects.find((item) => item.project_name === projectName) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (approvedProjects.length === 0) return
|
||||
setProjectName((current) => current || approvedProjects[0].project_name)
|
||||
}, [approvedProjects])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setPreviewTemplate(null)
|
||||
return
|
||||
}
|
||||
urmiOtpApi.documentation(selectedProject.id)
|
||||
.then((docs) => {
|
||||
const match = docs.templates.find((item) => item.type === templateType) ?? docs.templates[0] ?? null
|
||||
setPreviewTemplate(match)
|
||||
})
|
||||
.catch(() => setPreviewTemplate(null))
|
||||
}, [selectedProject, templateType])
|
||||
|
||||
const previewMessage = previewTemplate
|
||||
? buildMessage(previewTemplate.content, [otp, validityMinutes])
|
||||
: ''
|
||||
|
||||
async function runTest() {
|
||||
setError('')
|
||||
setResponse(null)
|
||||
setRunning(true)
|
||||
try {
|
||||
const body = {
|
||||
project_name: projectName,
|
||||
template_type: templateType,
|
||||
to: recipient.trim(),
|
||||
variables: { otp, validity_minutes: validityMinutes },
|
||||
}
|
||||
if (apiKey.trim()) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/otp/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey.trim()}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.detail === 'string' ? payload.detail : `Request failed (${response.status})`)
|
||||
}
|
||||
setResponse(payload)
|
||||
} else {
|
||||
const payload = await urmiOtpApi.generate(body)
|
||||
setResponse(payload)
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Request failed')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const keyHint = apiKeys.find((item) => item.status === 'active')
|
||||
? `Active key prefix: ${apiKeys.find((item) => item.status === 'active')?.key_prefix}…`
|
||||
: 'Create an API key under Develop → API Keys'
|
||||
|
||||
return (
|
||||
<ApiTestCard
|
||||
title="Generate verification OTP"
|
||||
summary="Unified UrmiOTP delivery API for approved projects."
|
||||
method="POST"
|
||||
path="/api/v1/otp/generate"
|
||||
notice="Uses your console session when no API key is set. Add an API key to test the external POST /api/v1/otp/generate path. Each run is logged for Logs and Analytics."
|
||||
running={running}
|
||||
onRun={runTest}
|
||||
response={response}
|
||||
error={error}
|
||||
runDisabled={!projectName || approvedProjects.length === 0}
|
||||
>
|
||||
<TestField label="API key (optional)" hint={keyHint}>
|
||||
<input type="password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} placeholder="urmi_live_..." />
|
||||
</TestField>
|
||||
<TestField label="Project name" hint="Pre-filled from your approved UrmiOTP projects">
|
||||
<select value={projectName} onChange={(event) => setProjectName(event.target.value)} disabled={approvedProjects.length === 0}>
|
||||
{approvedProjects.length === 0 && <option value="">No approved projects</option>}
|
||||
{approvedProjects.map((project) => (
|
||||
<option key={project.id} value={project.project_name}>
|
||||
{project.project_name} · label {project.message_label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</TestField>
|
||||
<TestField label="Template type" hint="Choose one of the seven generic OTP templates">
|
||||
<select value={templateType} onChange={(event) => setTemplateType(event.target.value)}>
|
||||
{OTP_TEMPLATE_TYPES.map((item) => (
|
||||
<option key={item.type} value={item.type}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</TestField>
|
||||
<TestField label="Recipient (to)" hint="Mobile number with country code">
|
||||
<input value={recipient} onChange={(event) => setRecipient(event.target.value)} placeholder="919999999999" />
|
||||
</TestField>
|
||||
<TestField label="OTP ({#var#})" hint="First template variable">
|
||||
<input value={otp} onChange={(event) => setOtp(event.target.value)} placeholder="583921" />
|
||||
</TestField>
|
||||
<TestField label="Validity minutes ({#var#})" hint="Second template variable">
|
||||
<input value={validityMinutes} onChange={(event) => setValidityMinutes(event.target.value)} placeholder="10" />
|
||||
</TestField>
|
||||
{previewTemplate && (
|
||||
<TestField label="Message preview" hint={`Product label: ${selectedProject?.message_label ?? '—'}`}>
|
||||
<code className="testing-built-message">{previewMessage}</code>
|
||||
</TestField>
|
||||
)}
|
||||
</ApiTestCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function TestingCenterPage() {
|
||||
const [expanded, setExpanded] = useState<TestingGroup | null>('messaging')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [senders, setSenders] = useState<SenderConfig[]>([])
|
||||
const [templates, setTemplates] = useState<TemplateConfig[]>([])
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||
const [projects, setProjects] = useState<UrmiOtpProject[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
messagingApi.listSenders(),
|
||||
messagingApi.listTemplates(),
|
||||
apiKeysApi.list(),
|
||||
urmiOtpApi.listProjects(),
|
||||
])
|
||||
.then(([senderList, templateList, keyList, projectList]) => {
|
||||
setSenders(senderList)
|
||||
setTemplates(templateList)
|
||||
setApiKeys(keyList)
|
||||
setProjects(projectList)
|
||||
})
|
||||
.catch(() => setError('Unable to load testing data'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
function toggleGroup(id: TestingGroup) {
|
||||
setExpanded((current) => (current === id ? null : id))
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading Testing Center…</div>
|
||||
|
||||
return (
|
||||
<div className="testing-center-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Test & observe</span>
|
||||
<h1>Testing Center</h1>
|
||||
<p>Try Urmi APIs with fields pre-filled from your approved senders, templates, and UrmiOTP projects.</p>
|
||||
</div>
|
||||
|
||||
<TestingAccordion
|
||||
id="messaging"
|
||||
title="Messaging"
|
||||
description="Send DLT-approved SMS using API keys or your signed-in console session."
|
||||
icon={MessageSquareText}
|
||||
open={expanded === 'messaging'}
|
||||
onToggle={toggleGroup}
|
||||
>
|
||||
<MessagingApiKeyTest senders={senders} templates={templates} apiKeys={apiKeys} />
|
||||
<MessagingConsoleTest senders={senders} templates={templates} />
|
||||
</TestingAccordion>
|
||||
|
||||
<TestingAccordion
|
||||
id="urmi-otp"
|
||||
title="URMI OTP"
|
||||
description="Generate and deliver verification codes from approved UrmiOTP projects."
|
||||
icon={KeyRound}
|
||||
open={expanded === 'urmi-otp'}
|
||||
onToggle={toggleGroup}
|
||||
>
|
||||
<UrmiOtpGenerateTest projects={projects} apiKeys={apiKeys} />
|
||||
</TestingAccordion>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
|
||||
{!loading && senders.length === 0 && templates.length === 0 && projects.length === 0 && (
|
||||
<section className="panel testing-empty">
|
||||
<TestTube2 size={22} />
|
||||
<strong>No test data yet</strong>
|
||||
<span>Add senders and templates under Message settings, or register an UrmiOTP project, then return here.</span>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
436
frontend/src/features/voice/VoicePage.tsx
Normal file
436
frontend/src/features/voice/VoicePage.tsx
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { Mic, MicOff, Phone, PhoneOutgoing, Radio, ShieldCheck } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { numbersApi, voiceApi, type PhoneNumber, type VoiceApiDocs, type VoiceCall } from '../../lib/api'
|
||||
import { UrmiConsoleAudio, extractStreamId, type AudioPermissionState } from './voiceAudio'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
|
||||
|
||||
function wsBaseUrl() {
|
||||
const url = new URL(API_BASE_URL)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return url.origin
|
||||
}
|
||||
|
||||
function formatPhone(value: string) {
|
||||
if (value.length === 10) return `${value.slice(0, 5)} ${value.slice(5)}`
|
||||
return value
|
||||
}
|
||||
|
||||
type CallEvent = {
|
||||
id: string
|
||||
at: string
|
||||
label: string
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
export function VoicePage() {
|
||||
const [numbers, setNumbers] = useState<PhoneNumber[]>([])
|
||||
const [docs, setDocs] = useState<VoiceApiDocs | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fromDid, setFromDid] = useState('')
|
||||
const [toNumber, setToNumber] = useState('9149154337')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [activeCall, setActiveCall] = useState<VoiceCall | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [consoleConnected, setConsoleConnected] = useState(false)
|
||||
const [events, setEvents] = useState<CallEvent[]>([])
|
||||
const [audioState, setAudioState] = useState<AudioPermissionState>('idle')
|
||||
const [callActive, setCallActive] = useState(false)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const activeCallIdRef = useRef<string | null>(null)
|
||||
const pollCountRef = useRef(0)
|
||||
const audioRef = useRef<UrmiConsoleAudio | null>(null)
|
||||
const streamIdRef = useRef<string | null>(null)
|
||||
|
||||
const sendConsoleMedia = useCallback((payload: string) => {
|
||||
const socket = socketRef.current
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function beginCallStream(streamId: string | number | null) {
|
||||
streamIdRef.current = streamId != null ? String(streamId) : null
|
||||
setCallActive(true)
|
||||
audioRef.current?.setStreamId(streamId)
|
||||
}
|
||||
|
||||
function endCallStream() {
|
||||
streamIdRef.current = null
|
||||
setCallActive(false)
|
||||
audioRef.current?.setStreamId(null)
|
||||
}
|
||||
|
||||
async function enableAudio() {
|
||||
setAudioState('requesting')
|
||||
setError('')
|
||||
try {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new UrmiConsoleAudio(sendConsoleMedia)
|
||||
}
|
||||
await audioRef.current.enable()
|
||||
if (streamIdRef.current) {
|
||||
audioRef.current.setStreamId(streamIdRef.current)
|
||||
}
|
||||
setAudioState('granted')
|
||||
pushEvent('Microphone and speaker enabled')
|
||||
} catch (reason) {
|
||||
setAudioState('denied')
|
||||
setError(reason instanceof Error ? reason.message : 'Microphone access denied')
|
||||
}
|
||||
}
|
||||
|
||||
function pushEvent(label: string, payload?: unknown) {
|
||||
setEvents((current) => [
|
||||
{
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
at: new Date().toLocaleTimeString(),
|
||||
label,
|
||||
payload,
|
||||
},
|
||||
...current,
|
||||
].slice(0, 40))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([numbersApi.mine(), voiceApi.docs()])
|
||||
.then(([mine, voiceDocs]) => {
|
||||
setNumbers(mine)
|
||||
setDocs(voiceDocs)
|
||||
if (mine[0]) setFromDid(mine[0].phone_number)
|
||||
})
|
||||
.catch(() => setError('Unable to load voice settings'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket(`${wsBaseUrl()}/api/v1/voice/console`)
|
||||
socketRef.current = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
setConsoleConnected(true)
|
||||
pushEvent('Console audio bridge connected')
|
||||
}
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
try {
|
||||
const payload = JSON.parse(message.data)
|
||||
if (payload.event === 'console.ready') {
|
||||
pushEvent('Ready for outbound calls', payload)
|
||||
return
|
||||
}
|
||||
if (payload.event === 'call.outbound') {
|
||||
pushEvent(`Outbound call connected to ${payload.to}`, payload)
|
||||
beginCallStream(payload.stream_id ?? extractStreamId(payload))
|
||||
return
|
||||
}
|
||||
if (payload.event === 'call.ended') {
|
||||
pushEvent(`Call ended (${payload.direction})`, payload)
|
||||
endCallStream()
|
||||
setActiveCall((current) =>
|
||||
current && payload.call_id === current.id
|
||||
? { ...current, status: 'ended', ended_at: new Date().toISOString() }
|
||||
: current,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (payload.event === 'end') {
|
||||
pushEvent('Provider sent call end', payload)
|
||||
endCallStream()
|
||||
setActiveCall((current) =>
|
||||
current ? { ...current, status: 'ended', ended_at: new Date().toISOString() } : current,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (payload.event === 'start') {
|
||||
const streamId = extractStreamId(payload)
|
||||
pushEvent('Media stream started', payload)
|
||||
beginCallStream(streamId)
|
||||
return
|
||||
}
|
||||
if (payload.event === 'media') {
|
||||
audioRef.current?.playMediaEvent(payload)
|
||||
return
|
||||
}
|
||||
pushEvent(`Event: ${payload.event ?? 'message'}`, payload)
|
||||
} catch {
|
||||
pushEvent('Raw media frame received')
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
setConsoleConnected(false)
|
||||
pushEvent('Console audio bridge disconnected')
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
setConsoleConnected(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.close()
|
||||
socketRef.current = null
|
||||
audioRef.current?.stop()
|
||||
audioRef.current = null
|
||||
}
|
||||
}, [sendConsoleMedia])
|
||||
|
||||
useEffect(() => {
|
||||
activeCallIdRef.current = activeCall?.id ?? null
|
||||
}, [activeCall?.id])
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = ['ended', 'failed', 'no_answer']
|
||||
if (!activeCall?.id || terminal.includes(activeCall.status)) {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pollCountRef.current = 0
|
||||
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
const callId = activeCallIdRef.current
|
||||
if (!callId) return
|
||||
pollCountRef.current += 1
|
||||
if (pollCountRef.current > 30) {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
setActiveCall((current) =>
|
||||
current && current.id === callId && !terminal.includes(current.status)
|
||||
? { ...current, status: 'no_answer' }
|
||||
: current,
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const latest = await voiceApi.getCall(callId)
|
||||
setActiveCall(latest)
|
||||
if (terminal.includes(latest.status)) {
|
||||
pushEvent(`Call status: ${latest.status}`, latest)
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
}, [activeCall?.id, activeCall?.status])
|
||||
|
||||
async function startOutboundCall(useApiKey: boolean) {
|
||||
setError('')
|
||||
if (!consoleConnected) {
|
||||
setError('Console audio bridge is not connected — refresh this page')
|
||||
return
|
||||
}
|
||||
if (audioState !== 'granted') {
|
||||
setError('Enable microphone & speaker before placing a call')
|
||||
return
|
||||
}
|
||||
setRunning(true)
|
||||
try {
|
||||
const payload = {
|
||||
to: toNumber.trim(),
|
||||
from: fromDid.trim(),
|
||||
bridge_mode: 'console' as const,
|
||||
}
|
||||
const result = useApiKey && apiKey.trim()
|
||||
? await voiceApi.createCallWithApiKey(apiKey.trim(), payload)
|
||||
: await voiceApi.createCall(payload)
|
||||
setActiveCall(result)
|
||||
pushEvent(`Outbound call initiated (${result.id})`, result)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Call failed')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading voice console…</div>
|
||||
|
||||
return (
|
||||
<div className="voice-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Numbers</span>
|
||||
<h1>Voice</h1>
|
||||
<p>
|
||||
Place outbound test calls through the production API. With <code>bridge_mode: console</code>,
|
||||
audio from the call is played through your speakers and your microphone is sent to the callee.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="voice-layout">
|
||||
<div className="voice-column">
|
||||
<section className="panel voice-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Console audio</h2>
|
||||
<p>Required for console-bridged outbound calls on this page.</p>
|
||||
</div>
|
||||
<Radio size={18} className={consoleConnected ? 'voice-live-icon' : ''} />
|
||||
</div>
|
||||
<div className={`voice-presence ${consoleConnected ? 'is-live' : ''}`}>
|
||||
<span>{consoleConnected ? 'Audio bridge connected' : 'Connecting…'}</span>
|
||||
</div>
|
||||
<div className="voice-audio-panel">
|
||||
<p className="voice-events-empty">
|
||||
Click below to allow microphone access. Speakers do not require a separate permission.
|
||||
</p>
|
||||
<div className="voice-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={audioState === 'granted' ? 'ghost-button' : 'primary-button'}
|
||||
onClick={() => void enableAudio()}
|
||||
disabled={audioState === 'requesting'}
|
||||
>
|
||||
{audioState === 'granted' ? <Mic size={16} /> : <MicOff size={16} />}
|
||||
{audioState === 'requesting'
|
||||
? 'Requesting access…'
|
||||
: audioState === 'granted'
|
||||
? 'Microphone enabled'
|
||||
: 'Enable microphone & speaker'}
|
||||
</button>
|
||||
</div>
|
||||
{callActive && audioState !== 'granted' && (
|
||||
<p className="auth-error voice-audio-hint">
|
||||
Call is active — enable microphone to hear and speak.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!numbers.length && (
|
||||
<div className="numbers-empty">
|
||||
<strong>No purchased numbers</strong>
|
||||
<span>
|
||||
Buy a number from <Link to="/console/numbers/buy">Buy Numbers</Link> or check{' '}
|
||||
<Link to="/console/numbers">My Numbers</Link>. The <code>from</code> DID must match a purchased number.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel voice-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Live events</h2>
|
||||
<p>Outbound media events appear here while you stay on this page.</p>
|
||||
</div>
|
||||
<Radio size={18} />
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="voice-events-empty">No call events yet.</p>
|
||||
) : (
|
||||
<ul className="voice-events">
|
||||
{events.map((event) => (
|
||||
<li key={event.id}>
|
||||
<span>{event.at}</span>
|
||||
<strong>{event.label}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="voice-column">
|
||||
<section className="panel voice-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Outbound test</h2>
|
||||
<p>Place a call through <code>POST /api/v1/voice/calls</code> with console bridging.</p>
|
||||
</div>
|
||||
<PhoneOutgoing size={18} />
|
||||
</div>
|
||||
<div className="explorer-request-grid voice-form-grid">
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>From (purchased DID)</strong>
|
||||
<span>Must be a number assigned to your account.</span>
|
||||
<select value={fromDid} onChange={(event) => setFromDid(event.target.value)} disabled={!numbers.length}>
|
||||
{!numbers.length ? <option value="">No numbers</option> : null}
|
||||
{numbers.map((number) => (
|
||||
<option key={number.id} value={number.phone_number}>
|
||||
{formatPhone(number.phone_number)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>To</strong>
|
||||
<span>Destination number with country code, e.g. 9149154337</span>
|
||||
<input value={toNumber} onChange={(event) => setToNumber(event.target.value)} placeholder="9149154337" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>API key (optional)</strong>
|
||||
<span>Leave empty to use your console session cookie.</span>
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="urmi_live_…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<div className="voice-actions">
|
||||
<button type="button" className="primary-button" disabled={running || !fromDid} onClick={() => startOutboundCall(Boolean(apiKey.trim()))}>
|
||||
{running ? 'Dialing…' : 'Start outbound call'}
|
||||
</button>
|
||||
</div>
|
||||
{activeCall && (
|
||||
<div className="voice-call-status">
|
||||
<Phone size={16} />
|
||||
<div>
|
||||
<strong>{activeCall.status}</strong>
|
||||
<span>{formatPhone(activeCall.from)} → {activeCall.to}</span>
|
||||
{activeCall.media_url && <code>{activeCall.media_url}</code>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel explorer-endpoint-card voice-api-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>API for your projects</h2>
|
||||
<p>{docs?.summary}</p>
|
||||
</div>
|
||||
<ShieldCheck size={18} />
|
||||
</div>
|
||||
<div className="explorer-field-row">
|
||||
<span>Outbound</span>
|
||||
<code>POST {API_BASE_URL}/api/v1/voice/calls</code>
|
||||
</div>
|
||||
<div className="explorer-field-row">
|
||||
<span>Status</span>
|
||||
<code>GET {API_BASE_URL}/api/v1/voice/calls/{'{call_id}'}</code>
|
||||
</div>
|
||||
{docs?.relay && (
|
||||
<p className="voice-events-empty">{docs.relay.latency}</p>
|
||||
)}
|
||||
<Link className="ghost-button" to="/console/api-explorer">Full API docs in Explorer</Link>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
frontend/src/features/voice/voiceAudio.ts
Normal file
210
frontend/src/features/voice/voiceAudio.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/** Browser audio for Urmi console testing — PCM 16-bit 8 kHz mono, 320-byte chunks. */
|
||||
|
||||
const BONVOICE_RATE = 8000
|
||||
const CHUNK_BYTES = 320
|
||||
const CHUNK_SAMPLES = CHUNK_BYTES / 2
|
||||
const MS_PER_CHUNK = 20
|
||||
const MAX_PLAYBACK_BUFFER_SEC = 0.06
|
||||
|
||||
function base64FromBytes(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string): Uint8Array {
|
||||
const binary = atob(value)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function downsampleToPcm16(input: Float32Array, inputRate: number, outputRate: number): Int16Array {
|
||||
if (inputRate === outputRate) {
|
||||
const pcm = new Int16Array(input.length)
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, input[i]))
|
||||
pcm[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff
|
||||
}
|
||||
return pcm
|
||||
}
|
||||
const ratio = inputRate / outputRate
|
||||
const outputLength = Math.floor(input.length / ratio)
|
||||
const pcm = new Int16Array(outputLength)
|
||||
for (let i = 0; i < outputLength; i += 1) {
|
||||
const start = Math.floor(i * ratio)
|
||||
const end = Math.floor((i + 1) * ratio)
|
||||
let sum = 0
|
||||
let count = 0
|
||||
for (let j = start; j < end && j < input.length; j += 1) {
|
||||
sum += input[j]
|
||||
count += 1
|
||||
}
|
||||
const sample = count ? sum / count : 0
|
||||
const clamped = Math.max(-1, Math.min(1, sample))
|
||||
pcm[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff
|
||||
}
|
||||
return pcm
|
||||
}
|
||||
|
||||
export type AudioPermissionState = 'idle' | 'requesting' | 'granted' | 'denied'
|
||||
|
||||
export class UrmiConsoleAudio {
|
||||
private audioContext: AudioContext | null = null
|
||||
private micStream: MediaStream | null = null
|
||||
private processor: ScriptProcessorNode | null = null
|
||||
private micSource: MediaStreamAudioSourceNode | null = null
|
||||
private streamId: string | number | null = null
|
||||
private packetId = 0
|
||||
private pcmBuffer = new Int16Array(0)
|
||||
private nextPlayTime = 0
|
||||
private readonly onSendMedia: (payload: string) => void
|
||||
|
||||
constructor(onSendMedia: (payload: string) => void) {
|
||||
this.onSendMedia = onSendMedia
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return Boolean(this.micStream && this.audioContext)
|
||||
}
|
||||
|
||||
setStreamId(streamId: string | number | null) {
|
||||
this.streamId = streamId
|
||||
this.packetId = 0
|
||||
this.nextPlayTime = 0
|
||||
this.pcmBuffer = new Int16Array(0)
|
||||
}
|
||||
|
||||
async enable(): Promise<void> {
|
||||
if (this.isActive) return
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('Microphone access is not supported in this browser')
|
||||
}
|
||||
|
||||
this.audioContext = new AudioContext({ latencyHint: 'interactive' })
|
||||
await this.audioContext.resume()
|
||||
|
||||
this.micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 1,
|
||||
},
|
||||
})
|
||||
|
||||
this.micSource = this.audioContext.createMediaStreamSource(this.micStream)
|
||||
this.processor = this.audioContext.createScriptProcessor(512, 1, 1)
|
||||
this.processor.onaudioprocess = (event) => {
|
||||
if (!this.streamId) return
|
||||
const input = event.inputBuffer.getChannelData(0)
|
||||
const pcm = downsampleToPcm16(input, this.audioContext!.sampleRate, BONVOICE_RATE)
|
||||
this.appendMicPcm(pcm)
|
||||
this.flushMicChunks()
|
||||
}
|
||||
|
||||
const silentGain = this.audioContext.createGain()
|
||||
silentGain.gain.value = 0
|
||||
this.micSource.connect(this.processor)
|
||||
this.processor.connect(silentGain)
|
||||
silentGain.connect(this.audioContext.destination)
|
||||
}
|
||||
|
||||
private appendMicPcm(pcm: Int16Array) {
|
||||
const merged = new Int16Array(this.pcmBuffer.length + pcm.length)
|
||||
merged.set(this.pcmBuffer)
|
||||
merged.set(pcm, this.pcmBuffer.length)
|
||||
this.pcmBuffer = merged
|
||||
}
|
||||
|
||||
private flushMicChunks() {
|
||||
while (this.pcmBuffer.length >= CHUNK_SAMPLES) {
|
||||
const chunk = this.pcmBuffer.slice(0, CHUNK_SAMPLES)
|
||||
this.pcmBuffer = this.pcmBuffer.slice(CHUNK_SAMPLES)
|
||||
this.sendMicChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private sendMicChunk(chunk: Int16Array) {
|
||||
if (!this.streamId) return
|
||||
|
||||
this.packetId += 1
|
||||
const bytes = new Uint8Array(CHUNK_BYTES)
|
||||
for (let i = 0; i < CHUNK_SAMPLES; i += 1) {
|
||||
bytes[i * 2] = chunk[i] & 0xff
|
||||
bytes[i * 2 + 1] = chunk[i] >> 8
|
||||
}
|
||||
const payload = {
|
||||
event: 'media',
|
||||
stream_id: this.streamId,
|
||||
media: {
|
||||
packet_id: this.packetId,
|
||||
timestamp: (this.packetId - 1) * MS_PER_CHUNK,
|
||||
payload: base64FromBytes(bytes),
|
||||
},
|
||||
}
|
||||
this.onSendMedia(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
playMediaEvent(message: { stream_id?: string | number; media?: { payload?: string } }) {
|
||||
if (!this.audioContext || !message.media?.payload) return
|
||||
const streamId = message.stream_id
|
||||
if (streamId != null && !this.streamId) {
|
||||
const numeric = Number(streamId)
|
||||
this.setStreamId(Number.isFinite(numeric) && String(numeric) === String(streamId) ? numeric : String(streamId))
|
||||
}
|
||||
|
||||
const bytes = bytesFromBase64(message.media.payload)
|
||||
if (bytes.length < 2) return
|
||||
|
||||
const sampleCount = Math.floor(bytes.length / 2)
|
||||
const buffer = this.audioContext.createBuffer(1, sampleCount, BONVOICE_RATE)
|
||||
const channel = buffer.getChannelData(0)
|
||||
for (let i = 0; i < sampleCount; i += 1) {
|
||||
const lo = bytes[i * 2]
|
||||
const hi = bytes[i * 2 + 1]
|
||||
const int16 = (hi << 8) | lo
|
||||
const signed = int16 >= 0x8000 ? int16 - 0x10000 : int16
|
||||
channel[i] = signed / 0x8000
|
||||
}
|
||||
|
||||
const source = this.audioContext.createBufferSource()
|
||||
source.buffer = buffer
|
||||
source.connect(this.audioContext.destination)
|
||||
|
||||
const now = this.audioContext.currentTime
|
||||
if (this.nextPlayTime < now) {
|
||||
this.nextPlayTime = now + 0.01
|
||||
} else if (this.nextPlayTime - now > MAX_PLAYBACK_BUFFER_SEC) {
|
||||
this.nextPlayTime = now + 0.01
|
||||
}
|
||||
source.start(this.nextPlayTime)
|
||||
this.nextPlayTime += buffer.duration
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.processor?.disconnect()
|
||||
this.micSource?.disconnect()
|
||||
this.processor = null
|
||||
this.micSource = null
|
||||
this.micStream?.getTracks().forEach((track) => track.stop())
|
||||
this.micStream = null
|
||||
void this.audioContext?.close()
|
||||
this.audioContext = null
|
||||
this.streamId = null
|
||||
this.packetId = 0
|
||||
this.pcmBuffer = new Int16Array(0)
|
||||
this.nextPlayTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
export function extractStreamId(message: Record<string, unknown>): string | null {
|
||||
const data = (message.data ?? message.start ?? {}) as Record<string, unknown>
|
||||
const streamId = message.stream_id ?? data.stream_id
|
||||
return streamId != null ? String(streamId) : null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue