first commit

This commit is contained in:
debu-2 2026-10-02 11:22:40 +05:30
commit efa5080897
127 changed files with 18610 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
}

View 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>
)
}

View 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 />
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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 />
}

View 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>
)
}

View 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>
)
}

View 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']

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}