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

7
frontend/.env.example Normal file
View file

@ -0,0 +1,7 @@
# Copy to frontend/.env when running Vite outside Docker:
# cp .env.example .env
#
# Vite only exposes variables prefixed with VITE_ to the browser.
# Backend API base URL (no trailing slash)
VITE_API_URL=http://localhost:8000

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

9
frontend/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

32
frontend/README.md Normal file
View file

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

15
frontend/index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/urmi-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#071a36" />
<meta name="description" content="Urmi communications infrastructure console" />
<title>Urmi Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1635
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
frontend/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

184
frontend/src/App.css Normal file
View file

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

77
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,77 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AppShell } from './components/AppShell'
import { AccountSettings } from './features/auth/AccountSettings'
import { AuthPage } from './features/auth/AuthPage'
import { ProtectedRoute } from './features/auth/ProtectedRoute'
import { Dashboard } from './features/dashboard/Dashboard'
import { LandingPage } from './features/landing/LandingPage'
import { MessageSettings } from './features/messaging/MessageSettings'
import { ComposeMessage } from './features/messaging/ComposeMessage'
import { TemplatesPage } from './features/messaging/TemplatesPage'
import { ApiKeysPage } from './features/develop/ApiKeysPage'
import { ApiExplorerPage } from './features/develop/ApiExplorerPage'
import { WebhooksSocketsPage } from './features/develop/WebhooksSocketsPage'
import { CampaignsPage } from './features/campaigns/CampaignsPage'
import { CampaignDetailPage } from './features/campaigns/CampaignDetailPage'
import { BuyNumbersPage, MyNumbersPage } from './features/numbers/NumbersPages'
import { VoicePage } from './features/voice/VoicePage'
import { TestingCenterPage } from './features/testing/TestingCenterPage'
import { LogsPage } from './features/logs/LogsPage'
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage'
import { KycPage } from './features/compliance/KycPage'
import { DltPage } from './features/compliance/DltPage'
import { ComplianceGate } from './features/compliance/ComplianceProvider'
import { AdminApp } from './features/admin/AdminPortal'
function Placeholder() {
return (
<section className="placeholder">
<span className="eyebrow">Urmi workspace</span>
<h1>Module workspace</h1>
<p>This product area is ready for the next iteration.</p>
</section>
)
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<AuthPage initialMode="login" />} />
<Route path="/register" element={<AuthPage initialMode="register" />} />
<Route path="/admin/*" element={<AdminApp />} />
<Route element={<ProtectedRoute />}>
<Route path="/console" element={<AppShell />}>
<Route path="kyc" element={<KycPage />} />
<Route path="dlt" element={<DltPage />} />
<Route element={<ComplianceGate />}>
<Route index element={<Dashboard />} />
<Route path="account" element={<AccountSettings />} />
<Route path="message-settings" element={<MessageSettings />} />
<Route path="templates" element={<TemplatesPage />} />
<Route path="messaging" element={<ComposeMessage />} />
<Route path="campaigns" element={<CampaignsPage />} />
<Route path="campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
<Route path="api-explorer" element={<ApiExplorerPage />} />
<Route path="webhooks" element={<WebhooksSocketsPage />} />
<Route path="testing" element={<TestingCenterPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="numbers/buy" element={<BuyNumbersPage />} />
<Route path="numbers" element={<MyNumbersPage />} />
<Route path="voice" element={<VoicePage />} />
<Route path="agent/urmiotp" element={<UrmiOtpPage />} />
<Route path="agent/urmiotp/projects/:projectId/docs" element={<UrmiOtpDocsPage />} />
<Route path=":section/*" element={<Placeholder />} />
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,146 @@
import { useState } from 'react'
import {
Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
Code2, CreditCard, Cpu, FileCheck2, Gauge, KeyRound, LayoutDashboard,
LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck,
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
} from 'lucide-react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useAuth } from '../features/auth/auth-context'
import { ComplianceProvider } from '../features/compliance/ComplianceProvider'
import { useCompliance } from '../features/compliance/compliance-context'
import { COMPLIANCE_ONLY_PATHS } from '../features/compliance/compliance-context'
const groups = [
{ label: 'Compliance', links: [
{ label: 'KYC', path: '/console/kyc', icon: ShieldCheck },
{ label: 'DLT', path: '/console/dlt', icon: FileCheck2 },
{ label: 'Message settings', path: '/console/message-settings', icon: SlidersHorizontal },
]},
{ label: 'Workspace', links: [
{ label: 'Overview', path: '/console', icon: LayoutDashboard },
{ label: 'Activity', path: '/console/activity', icon: Activity },
]},
{ label: 'Agent', links: [
{ label: 'UrmiAI', path: '/console/agent/urmiai', icon: Bot },
{ label: 'UrmIOT', path: '/console/agent/urmiot', icon: Cpu },
{ label: 'UrmiOTP', path: '/console/agent/urmiotp', icon: KeyRound },
]},
{ label: 'Communicate', links: [
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap },
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
]},
{ label: 'Numbers', links: [
{ label: 'Voice', path: '/console/voice', icon: Phone },
{ label: 'Buy Numbers', path: '/console/numbers/buy', icon: Search },
{ label: 'My Numbers', path: '/console/numbers', icon: Network, end: true },
]},
{ label: 'Develop', links: [
{ label: 'Webhooks & Sockets', path: '/console/webhooks', icon: Webhook },
{ label: 'API Keys', path: '/console/api-keys', icon: KeyRound },
{ label: 'API Explorer', path: '/console/api-explorer', icon: Code2 },
]},
{ label: 'Test & observe', links: [
{ label: 'Testing Center', path: '/console/testing', icon: TestTube2 },
{ label: 'Logs', path: '/console/logs', icon: Boxes },
{ label: 'Analytics', path: '/console/analytics', icon: Gauge },
]},
{ label: 'Manage', links: [
{ label: 'Billing', path: '/console/billing', icon: CreditCard },
{ label: 'Team', path: '/console/team', icon: Users },
{ label: 'Settings', path: '/console/settings', icon: Settings },
]},
]
function AppShellContent() {
const [open, setOpen] = useState(false)
const [accountOpen, setAccountOpen] = useState(false)
const { user, logout } = useAuth()
const { status } = useCompliance()
const navigate = useNavigate()
const unlocked = status?.console_unlocked ?? false
const visibleGroups = unlocked
? groups
: [{
label: 'Compliance',
links: groups[0].links.filter((link) => COMPLIANCE_ONLY_PATHS.includes(link.path)),
}]
async function signOut() {
await logout()
navigate('/')
}
return (
<div className="app-shell">
<aside className={`sidebar ${open ? 'is-open' : ''}`}>
<div className="brand">
<NavLink to={unlocked ? '/console' : '/console/dlt'} className="brand-mark" onClick={() => setOpen(false)}>
<img src="/urmi-logo.png" alt="Urmi" />
</NavLink>
<button className="icon-button sidebar-close" onClick={() => setOpen(false)}><X size={18} /></button>
</div>
<nav>
{visibleGroups.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</p>
{group.links.map(({ label, path, icon: Icon, end }) => (
<NavLink key={path} to={path} end={end ?? path === '/console'} onClick={() => setOpen(false)}>
<Icon size={17} strokeWidth={1.8} />
<span>{label}</span>
</NavLink>
))}
</div>
))}
</nav>
{unlocked && (
<div className="sidebar-plan">
<div><CircleDollarSign size={17} /><span>Growth plan</span></div>
<strong>₹24,820.00</strong>
<small>Available balance</small>
<button>Add funds</button>
</div>
)}
</aside>
{open && <button className="sidebar-scrim" aria-label="Close navigation" onClick={() => setOpen(false)} />}
<div className="workspace">
<header className="topbar">
<button className="icon-button menu-button" onClick={() => setOpen(true)}><Menu size={20} /></button>
<button className="selector">Urmi Technologies <ChevronDown size={14} /></button>
<button className="selector hide-mobile">Production API <ChevronDown size={14} /></button>
<span className="environment"><i /> Production</span>
<label className="global-search">
<Search size={16} />
<input aria-label="Global search" placeholder="Search numbers, calls, messages..." />
<kbd>⌘ K</kbd>
</label>
<span className="system-status"><i /> Operational</span>
<button className="icon-button notification-button"><Bell size={19} /><b>3</b></button>
<div className="account-menu">
<button className="avatar" onClick={() => setAccountOpen(!accountOpen)}>
{user?.avatar_url ? <img src={user.avatar_url} alt="" /> : user?.name.slice(0, 2).toUpperCase()}
</button>
{accountOpen && <div className="account-popover">
<div><strong>{user?.name}</strong><span>{user?.email}</span></div>
<NavLink to="/console/account" onClick={() => setAccountOpen(false)}><Settings size={15} /> Sign-in & security</NavLink>
<button onClick={signOut}><LogOut size={15} /> Log out</button>
</div>}
</div>
</header>
<main><Outlet /></main>
</div>
</div>
)
}
export function AppShell() {
return (
<ComplianceProvider>
<AppShellContent />
</ComplianceProvider>
)
}

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
}

952
frontend/src/index.css Normal file
View file

@ -0,0 +1,952 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
:root {
font-family: 'DM Sans', system-ui, sans-serif;
color: #13213a;
background: #f6f8fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
--navy: #071a36;
--blue: #1475ff;
--muted: #718096;
--border: #e5eaf1;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
.app-shell { min-height: 100vh; display: flex; }
.sidebar {
position: fixed; inset: 0 auto 0 0; z-index: 30; width: 248px; color: #aebcd0;
background: linear-gradient(180deg, #061b39 0%, #07172e 100%); display: flex; flex-direction: column;
border-right: 1px solid rgba(255,255,255,.05);
}
.brand {
min-height: 96px; display: flex; align-items: center;
padding: 16px 18px; border-bottom: 1px solid rgba(255,255,255,.08);
}
.brand-mark {
flex: 1; min-width: 0; display: flex; align-items: center; text-decoration: none;
}
.brand-mark img {
width: 100%; max-width: 208px; height: 72px; object-fit: contain; object-position: left center;
filter: drop-shadow(0 4px 14px rgba(0,156,255,.28));
}
.sidebar-close { display: none !important; margin-left: auto; color: white !important; }
.sidebar nav { padding: 12px 10px 24px; overflow-y: auto; flex: 1; scrollbar-width: thin; }
.nav-group { margin-bottom: 15px; }
.nav-group p { margin: 0 10px 6px; color: #647793; font-size: 9px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
.nav-group a { color: #aab8ca; text-decoration: none; height: 34px; padding: 0 10px; border-radius: 7px; display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 2px; transition: .18s ease; }
.nav-group a:hover { color: white; background: rgba(255,255,255,.055); }
.nav-group a.active { color: white; background: linear-gradient(90deg, #0877f9, #0b6de3); box-shadow: 0 7px 20px rgba(0,104,239,.23); }
.sidebar-plan { margin: 12px; padding: 13px; border-radius: 10px; background: rgba(255,255,255,.055); border: 1px solid rgba(255,255,255,.08); }
.sidebar-plan div { display: flex; align-items: center; gap: 7px; color: #c1ccda; font-size: 11px; }
.sidebar-plan strong { display: block; color: white; font-size: 16px; margin-top: 10px; }
.sidebar-plan small { color: #7487a3; font-size: 9px; }
.sidebar-plan button { width: 100%; border: 0; color: white; background: #0877f9; border-radius: 6px; padding: 7px; font-size: 10px; margin-top: 10px; }
.workspace { min-width: 0; width: calc(100% - 248px); margin-left: 248px; }
.topbar { height: 72px; position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 9px; padding: 0 25px; background: rgba(255,255,255,.94); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); }
.icon-button { border: 0; background: transparent; color: #627087; width: 34px; height: 34px; border-radius: 8px; display: inline-grid; place-items: center; }
.icon-button:hover { background: #f1f5fa; }
.menu-button { display: none; }
.selector { display: flex; align-items: center; gap: 8px; color: #35435a; background: white; border: 1px solid var(--border); padding: 8px 10px; border-radius: 7px; font-size: 11px; white-space: nowrap; }
.environment, .system-status { display: flex; align-items: center; gap: 6px; color: #64728a; font-size: 10px; white-space: nowrap; }
.environment i, .system-status i { display: block; width: 7px; height: 7px; border-radius: 50%; background: #15b87a; box-shadow: 0 0 0 3px #e4f8f0; }
.global-search { margin-left: auto; width: min(340px, 34vw); height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #f6f8fb; border: 1px solid #edf0f4; border-radius: 8px; color: #8b97a9; }
.global-search input { width: 100%; outline: none; border: 0; background: transparent; color: #26364f; font-size: 11px; }
.global-search kbd { color: #8e9aad; background: white; border: 1px solid #e4e8ef; border-radius: 4px; padding: 2px 5px; font-size: 9px; white-space: nowrap; }
.system-status { color: #279270; }
.notification-button { position: relative; }
.notification-button b { position: absolute; top: 3px; right: 3px; width: 14px; height: 14px; color: white; background: #f05064; border: 2px solid white; border-radius: 50%; font-size: 7px; display: grid; place-items: center; }
.avatar { width: 34px; height: 34px; border-radius: 50%; border: 0; background: #e5f0ff; color: #126de3; font-size: 10px; font-weight: 700; }
.avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.account-menu { position: relative; }
.account-popover { position: absolute; top: 44px; right: 0; width: 230px; padding: 8px; background: white; border: 1px solid var(--border); border-radius: 11px; box-shadow: 0 18px 45px rgba(18,31,54,.14); }
.account-popover > div { padding: 9px 10px 12px; border-bottom: 1px solid var(--border); margin-bottom: 5px; }
.account-popover strong, .account-popover span { display: block; overflow: hidden; text-overflow: ellipsis; }
.account-popover strong { font-size: 12px; color: #17253e; }
.account-popover span { font-size: 10px; color: #7c899d; margin-top: 3px; }
.account-popover a, .account-popover button { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 10px; color: #4d5b70; background: transparent; border: 0; border-radius: 7px; text-decoration: none; font-size: 11px; }
.account-popover a:hover, .account-popover button:hover { background: #f4f7fb; color: #1475ff; }
main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.dashboard { max-width: 1500px; margin: 0 auto; }
.eyebrow { color: #8a96a7; text-transform: uppercase; letter-spacing: .12em; font-size: 9px; font-weight: 700; }
.welcome-row { display: flex; justify-content: space-between; align-items: end; gap: 20px; }
.welcome-row h1, .placeholder h1 { color: #10203b; font-size: clamp(24px, 2.3vw, 32px); letter-spacing: -.04em; margin: 6px 0 5px; }
.welcome-row h1 span { font-size: .8em; }
.welcome-row p, .placeholder p { color: var(--muted); font-size: 12px; margin: 0; }
.welcome-actions { display: flex; gap: 9px; }
.primary-button, .secondary-button { display: inline-flex; justify-content: center; align-items: center; gap: 7px; border-radius: 8px; padding: 9px 13px; font-size: 11px; font-weight: 600; }
.primary-button { color: white; border: 1px solid #1475ff; background: #1475ff; box-shadow: 0 5px 12px rgba(20,117,255,.18); }
.secondary-button { color: #4b5a70; border: 1px solid var(--border); background: white; }
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(135px, 1fr)) 1.25fr; gap: 12px; margin: 25px 0 18px; }
.stat-card, .balance-card, .panel { background: white; border: 1px solid var(--border); border-radius: 11px; box-shadow: 0 3px 12px rgba(28,45,76,.025); }
.stat-card { position: relative; min-height: 137px; padding: 16px; }
.stat-icon { position: absolute; top: 15px; right: 15px; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; }
.stat-icon.blue, .activity-icon.blue { color: #1475ff; background: #edf5ff; }
.stat-icon.cyan, .activity-icon.cyan { color: #0aaabb; background: #e8fbfc; }
.stat-icon.violet, .activity-icon.violet { color: #805bd7; background: #f3efff; }
.stat-icon.amber, .activity-icon.amber { color: #d78219; background: #fff7e9; }
.stat-card > span, .balance-card > span { color: #718096; font-size: 10px; }
.stat-card > strong { display: block; color: #12213a; font-size: 25px; letter-spacing: -.04em; margin: 18px 0 7px; }
.stat-card p { display: flex; align-items: center; gap: 3px; color: #159768; font-size: 9px; margin: 0; font-weight: 600; }
.stat-card em { color: #96a0b0; font-style: normal; font-weight: 400; margin-left: 2px; }
.balance-card { color: white; border-color: #1475ff; background: linear-gradient(135deg, #1475ff, #075adb); padding: 17px; }
.balance-card > span, .balance-card small { color: #d7e8ff; }
.balance-card strong { display: block; font-size: 25px; letter-spacing: -.035em; margin: 11px 0 3px; }
.balance-card small { display: block; font-size: 9px; }
.balance-card button { display: flex; align-items: center; justify-content: space-between; width: 100%; color: #126be8; background: white; border: 0; padding: 8px 10px; border-radius: 6px; font-size: 10px; font-weight: 600; margin-top: 12px; }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(270px, .85fr); gap: 18px; }
.panel { padding: 19px; min-width: 0; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.panel-heading h2 { font-size: 13px; color: #17253e; margin: 0 0 4px; }
.panel-heading p { font-size: 9px; color: #8b96a7; margin: 0; }
.legend { display: flex; gap: 13px; color: #7c899d; font-size: 9px; }
.legend span { display: flex; align-items: center; gap: 5px; }
.legend i { width: 7px; height: 7px; border-radius: 50%; }
.calls-dot { background: #1475ff; }
.messages-dot { background: #11b7c9; }
.chart-wrap { height: 260px; margin-top: 18px; }
.insights-panel { display: flex; flex-direction: column; }
.insights-panel > .panel-heading svg { color: #1475ff; }
.insight { display: flex; gap: 10px; padding: 14px 0; border-bottom: 1px solid #edf0f4; }
.insight > span { flex: 0 0 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; }
.insight.success > span { color: #13946a; background: #e9f9f2; }
.insight.info > span { color: #1475ff; background: #edf5ff; }
.insight.warning > span { color: #d78219; background: #fff6e6; }
.insight strong { display: block; color: #35435a; font-size: 10px; line-height: 1.4; }
.insight p { color: #8a95a7; font-size: 9px; margin: 3px 0 0; }
.text-button { display: inline-flex; align-items: center; gap: 2px; color: #1475ff; background: transparent; border: 0; padding: 0; font-size: 10px; font-weight: 600; }
.insights-panel > .text-button { margin-top: auto; padding-top: 15px; }
.activity-panel, .compliance-panel { min-height: 260px; }
.activity-list { margin-top: 10px; }
.activity-item { display: grid; grid-template-columns: 32px 1fr auto; align-items: center; gap: 9px; padding: 11px 0; border-bottom: 1px solid #edf0f4; }
.activity-item:last-child { border: 0; }
.activity-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 8px; }
.activity-item strong { color: #35435a; display: block; font-size: 10px; }
.activity-item p, .activity-item time { color: #8c97a8; font-size: 9px; margin: 3px 0 0; }
.ready-badge { color: #13885f; background: #eaf9f2; border-radius: 20px; padding: 4px 8px; font-size: 9px; font-weight: 600; }
.compliance-row { display: flex; align-items: center; justify-content: space-between; padding: 13px 0; border-bottom: 1px solid #edf0f4; font-size: 10px; }
.compliance-row span { display: flex; align-items: center; gap: 7px; color: #4a5870; }
.compliance-row span svg { color: #18a275; background: #e7f8f1; border-radius: 50%; padding: 2px; }
.compliance-row strong { color: #159369; font-size: 9px; }
.compliance-row small { display: block; width: 100%; margin-top: 4px; color: #8a95a7; font-size: 8px; font-weight: 400; }
.message-settings-page .compliance-row { flex-wrap: wrap; }
.message-settings-page .compliance-row strong { margin-left: auto; }
.full-width { width: 100%; margin-top: 14px; }
.placeholder { background: white; border: 1px solid var(--border); border-radius: 12px; padding: 50px; min-height: 300px; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }
.auth-loading { min-height: 100vh; display: grid; place-items: center; color: #1475ff; font-size: 13px; font-weight: 600; }
/* Public landing page */
.landing { min-height: 100vh; background: #fbfcfe; color: #0b1730; }
.landing-nav { height: 76px; display: flex; align-items: center; gap: 42px; padding: 0 max(5vw, 24px); background: #07172f; border-bottom: 1px solid rgba(255,255,255,.08); }
.landing-brand { display: flex; width: 126px; height: 56px; }
.landing-brand img { width: 100%; height: 100%; object-fit: contain; }
.landing-nav nav { display: flex; gap: 30px; }
.landing-nav nav a, .landing-actions > a:first-child { color: #9cacbf; text-decoration: none; font-size: 12px; transition: color .2s; }
.landing-nav nav a:hover, .landing-actions > a:first-child:hover { color: white; }
.landing-actions { margin-left: auto; display: flex; align-items: center; gap: 20px; }
.landing-cta { display: inline-flex; align-items: center; gap: 8px; color: #07172f; background: white; border-radius: 7px; padding: 10px 15px; text-decoration: none; font-size: 11px; font-weight: 700; }
.hero-section { min-height: 620px; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(400px, .95fr); align-items: center; gap: 7vw; padding: 80px max(7vw, 28px) 90px; overflow: hidden; color: white; background: radial-gradient(circle at 75% 30%, rgba(19,116,255,.18), transparent 28%), linear-gradient(135deg, #07172f 0%, #071b36 55%, #061329 100%); }
.hero-copy { max-width: 680px; }
.hero-pill { display: inline-flex; align-items: center; gap: 8px; padding: 7px 11px; color: #aad7ff; border: 1px solid rgba(113,186,255,.22); background: rgba(17,117,255,.08); border-radius: 30px; font-size: 10px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }
.hero-pill i { width: 6px; height: 6px; border-radius: 50%; background: #37c9b6; box-shadow: 0 0 10px #37c9b6; }
.hero-copy h1 { margin: 25px 0 20px; font-size: clamp(42px, 5vw, 74px); line-height: 1.02; letter-spacing: -.06em; font-weight: 600; }
.hero-copy h1 em { font-style: normal; color: #5eb6ff; }
.hero-copy > p { max-width: 610px; color: #9aabc1; font-size: clamp(15px, 1.4vw, 18px); line-height: 1.7; }
.hero-actions { display: flex; gap: 11px; margin-top: 31px; }
.hero-actions a { display: inline-flex; align-items: center; justify-content: center; gap: 9px; border-radius: 8px; padding: 13px 18px; text-decoration: none; font-size: 12px; font-weight: 700; }
.hero-primary { color: white; background: #1475ff; box-shadow: 0 10px 30px rgba(20,117,255,.25); }
.hero-secondary { color: #dbeaff; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.04); }
.hero-proof { display: flex; gap: 20px; margin-top: 24px; color: #778ca8; font-size: 10px; }
.hero-proof span { display: flex; align-items: center; gap: 5px; }
.hero-proof svg { color: #37c9b6; }
.agent-demo { position: relative; max-width: 610px; width: 100%; justify-self: center; }
.demo-window { position: relative; z-index: 2; overflow: hidden; background: rgba(9,28,55,.92); border: 1px solid rgba(116,169,229,.2); border-radius: 16px; box-shadow: 0 35px 90px rgba(0,0,0,.38); backdrop-filter: blur(15px); }
.demo-top { height: 48px; display: flex; align-items: center; gap: 15px; padding: 0 16px; border-bottom: 1px solid rgba(255,255,255,.08); color: #7d91ac; }
.demo-top > span { display: flex; gap: 5px; }
.demo-top i { width: 7px; height: 7px; background: #31445e; border-radius: 50%; }
.demo-top small { font-family: ui-monospace, monospace; font-size: 10px; }
.demo-top b { margin-left: auto; color: #49cdae; background: rgba(73,205,174,.08); padding: 4px 7px; border-radius: 12px; font-size: 8px; letter-spacing: .05em; text-transform: uppercase; }
.demo-window pre { margin: 0; padding: 34px 28px 42px; color: #c3d1e4; font: 12px/2 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-x: auto; }
.code-purple { color: #b89cff; }.code-green { color: #72d6b9; }
.demo-event { display: grid; grid-template-columns: 38px 1fr auto; align-items: center; gap: 11px; margin: 0 15px 15px; padding: 13px; background: rgba(255,255,255,.045); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; }
.demo-event > span { width: 36px; height: 36px; display: grid; place-items: center; color: #59b6ff; background: rgba(20,117,255,.13); border-radius: 8px; }
.demo-event small, .demo-event strong { display: block; }
.demo-event small { color: #4fc9a7; font-size: 7px; font-weight: 700; letter-spacing: .1em; }
.demo-event strong { color: #c5d2e3; font-size: 9px; margin-top: 4px; font-weight: 500; }
.demo-event > b { color: #6f829c; font-size: 8px; }
.agent-orbit { position: absolute; z-index: 3; display: grid; place-items: center; width: 48px; height: 48px; color: #79c4ff; background: #0d2a50; border: 1px solid rgba(78,161,239,.3); border-radius: 13px; box-shadow: 0 15px 40px rgba(0,0,0,.3); }
.orbit-one { top: -25px; right: 36px; transform: rotate(7deg); }.orbit-two { bottom: 52px; left: -27px; transform: rotate(-8deg); }
.trust-strip { padding: 30px max(7vw, 28px); border-bottom: 1px solid #e9edf3; text-align: center; }
.trust-strip > span { color: #9aa4b2; font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.trust-strip div { display: flex; justify-content: center; flex-wrap: wrap; gap: clamp(30px, 6vw, 85px); margin-top: 18px; color: #647085; }
.trust-strip b { font-size: 13px; font-weight: 600; }
.feature-section { padding: 100px max(7vw, 28px) 120px; }
.section-heading { max-width: 650px; margin: 0 auto 50px; text-align: center; }
.section-heading > span, .developer-band > div > span { color: #1475ff; font-size: 9px; font-weight: 700; letter-spacing: .14em; }
.section-heading h2, .developer-band h2 { margin: 12px 0; color: #10203b; font-size: clamp(28px, 3vw, 42px); letter-spacing: -.045em; }
.section-heading p, .developer-band p { color: #7b8799; font-size: 13px; line-height: 1.7; }
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; max-width: 1160px; margin: auto; }
.feature-grid article { padding: 28px; background: white; border: 1px solid #e7ebf1; border-radius: 13px; box-shadow: 0 8px 30px rgba(26,42,70,.035); }
.feature-grid article > span { width: 42px; height: 42px; display: grid; place-items: center; color: #1475ff; background: #edf5ff; border-radius: 10px; }
.feature-grid article svg { width: 19px; }
.feature-grid h3 { margin: 23px 0 9px; color: #152440; font-size: 15px; }
.feature-grid p { color: #7f8a9b; font-size: 11px; line-height: 1.7; margin: 0; }
.developer-band { margin: 0 max(5vw, 20px) 70px; padding: 55px max(5vw, 30px); display: flex; align-items: center; justify-content: space-between; gap: 30px; color: white; background: #081a35; border-radius: 18px; }
.developer-band h2 { color: white; margin-bottom: 7px; }.developer-band p { margin: 0; color: #8fa0b6; }
.developer-band > a { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; color: white; background: #1475ff; border-radius: 8px; padding: 12px 16px; text-decoration: none; font-size: 11px; font-weight: 700; }
.landing-footer { min-height: 100px; display: flex; align-items: center; gap: 22px; padding: 20px max(5vw, 24px); color: #788ba5; background: #07172f; }
.landing-footer img { width: 90px; height: 48px; object-fit: contain; }.landing-footer span { font-size: 10px; }.landing-footer small { margin-left: auto; font-size: 9px; }
/* Unified authentication */
.auth-page { min-height: 100vh; display: grid; grid-template-columns: minmax(380px, .8fr) minmax(520px, 1.2fr); background: white; }
.auth-visual { min-height: 100vh; display: flex; flex-direction: column; padding: 32px clamp(38px, 5vw, 76px); color: white; background: radial-gradient(circle at 10% 80%, rgba(20,117,255,.2), transparent 32%), #07172f; }
.auth-brand { width: 126px; height: 58px; display: flex; }.auth-brand img { width: 100%; object-fit: contain; }
.auth-message { margin: auto 0; max-width: 500px; }
.auth-message > span { color: #57b5ff; font-size: 9px; font-weight: 700; letter-spacing: .15em; }
.auth-message h1 { margin: 17px 0; font-size: clamp(34px, 4vw, 58px); line-height: 1.05; letter-spacing: -.055em; }
.auth-message > p { color: #94a6bd; font-size: 14px; line-height: 1.7; }
.auth-message > div { display: flex; flex-direction: column; gap: 12px; margin-top: 30px; color: #bac6d5; font-size: 11px; }
.auth-message > div span { display: flex; align-items: center; gap: 9px; }.auth-message svg { color: #42c9aa; }
.auth-visual > small { color: #607691; font-size: 9px; letter-spacing: .12em; text-transform: uppercase; }
.auth-main { position: relative; display: grid; place-items: center; padding: 70px 30px; }
.back-home { position: absolute; top: 30px; right: 40px; display: flex; align-items: center; gap: 6px; color: #7c899b; text-decoration: none; font-size: 10px; }
.auth-card { width: min(420px, 100%); }
.auth-tabs { display: grid; grid-template-columns: 1fr 1fr; padding: 4px; background: #f3f6fa; border-radius: 9px; }
.auth-tabs button { border: 0; padding: 9px; color: #8792a2; background: transparent; border-radius: 7px; font-size: 11px; font-weight: 600; }
.auth-tabs button.active { color: #13213a; background: white; box-shadow: 0 2px 8px rgba(20,34,58,.08); }
.auth-heading { margin: 29px 0 24px; text-align: center; }
.auth-heading h2 { margin: 0 0 8px; color: #14233d; font-size: 25px; letter-spacing: -.035em; }
.auth-heading p { margin: 0; color: #7f8b9d; font-size: 11px; }
.google-button { height: 44px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #344157; background: white; border: 1px solid #dfe5ed; border-radius: 8px; text-decoration: none; font-size: 11px; font-weight: 600; }
.google-button:hover { border-color: #c7d0dc; background: #fafbfd; }.google-button svg { width: 17px; }
.auth-divider { display: flex; align-items: center; gap: 13px; margin: 22px 0; color: #a0a9b5; font-size: 9px; }
.auth-divider::before, .auth-divider::after { content: ''; height: 1px; flex: 1; background: #e9edf2; }
.auth-card form { display: flex; flex-direction: column; gap: 15px; }
.auth-card label > span, .account-password label > span { display: block; margin-bottom: 6px; color: #46546a; font-size: 10px; font-weight: 600; }
.auth-card label > div { height: 43px; display: flex; align-items: center; gap: 9px; padding: 0 12px; border: 1px solid #dfe5ed; border-radius: 8px; color: #9aa5b4; transition: border-color .2s, box-shadow .2s; }
.auth-card label > div:focus-within { border-color: #1475ff; box-shadow: 0 0 0 3px rgba(20,117,255,.09); color: #1475ff; }
.auth-card input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; color: #26364d; font-size: 11px; }
.auth-card input::placeholder { color: #a6afbc; }.auth-card label button { display: grid; place-items: center; padding: 3px; color: #8c98a9; background: transparent; border: 0; }
.forgot-row { display: flex; justify-content: space-between; align-items: center; margin-top: -3px; color: #a0a9b6; font-size: 9px; }
.forgot-row button { padding: 0; color: #1475ff; background: transparent; border: 0; font-size: 9px; font-weight: 600; }
.auth-error { margin: 0; padding: 9px 11px; color: #b93f51; background: #fff1f3; border-radius: 7px; font-size: 10px; }
.auth-submit { height: 44px; display: flex; align-items: center; justify-content: center; gap: 8px; color: white; background: #1475ff; border: 0; border-radius: 8px; font-size: 11px; font-weight: 700; box-shadow: 0 8px 20px rgba(20,117,255,.18); }
.auth-submit:disabled { opacity: .65; cursor: wait; }
.auth-terms { max-width: 300px; margin: 19px auto 0; color: #9aa4b2; text-align: center; font-size: 8px; line-height: 1.6; }
/* Account security */
.account-heading { margin-bottom: 24px; }.account-heading h1 { margin: 6px 0; font-size: 28px; letter-spacing: -.04em; }.account-heading p { margin: 0; color: #7c899b; font-size: 11px; }
.account-grid { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 18px; }
.account-profile { display: flex; align-items: center; gap: 15px; align-self: start; }
.account-avatar { width: 48px; height: 48px; flex: 0 0 48px; display: grid; place-items: center; overflow: hidden; color: #1475ff; background: #e7f1ff; border-radius: 50%; font-size: 13px; font-weight: 700; }
.account-avatar img { width: 100%; height: 100%; object-fit: cover; }.account-profile h2 { margin: 0 0 4px; font-size: 14px; }.account-profile p { margin: 0 0 8px; color: #7d899a; font-size: 10px; }.account-profile span { display: flex; align-items: center; gap: 5px; color: #15916a; font-size: 9px; }
.account-password > .panel-heading svg { color: #1475ff; }.account-password form { display: grid; gap: 14px; margin-top: 22px; }
.account-password input { width: 100%; height: 40px; padding: 0 11px; border: 1px solid #dfe5ed; border-radius: 8px; outline: 0; font-size: 11px; }.account-password input:focus { border-color: #1475ff; box-shadow: 0 0 0 3px rgba(20,117,255,.08); }
.account-password .primary-button { justify-self: start; }.account-success { display: flex; align-items: center; gap: 6px; margin: 0; color: #128660; font-size: 10px; }
/* KYC */
.kyc-page, .admin-content { max-width: 1100px; }
.kyc-grid { display: grid; grid-template-columns: 1.2fr .8fr; gap: 18px; margin-bottom: 18px; }
.kyc-form { display: grid; gap: 14px; margin-top: 18px; }
.kyc-form label > span { display: block; margin-bottom: 6px; color: #46546a; font-size: 10px; font-weight: 600; }
.kyc-form input { width: 100%; height: 40px; padding: 0 11px; border: 1px solid #dfe5ed; border-radius: 8px; font-size: 11px; }
.kyc-form input:focus { outline: none; border-color: #1475ff; box-shadow: 0 0 0 3px rgba(20,117,255,.08); }
.kyc-documents { margin-top: 0; }
.kyc-doc-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: center; padding: 14px 0; border-bottom: 1px solid #edf0f4; }
.kyc-doc-row:last-child { border-bottom: 0; }
.kyc-doc-row strong { display: block; color: #35435a; font-size: 11px; }
.kyc-doc-row span, .kyc-doc-row small { display: block; color: #8a95a7; font-size: 9px; margin-top: 3px; }
.doc-status { padding: 4px 8px; border-radius: 20px; font-size: 9px; font-weight: 700; text-transform: capitalize; }
.doc-status.approved { color: #128660; background: #e9f9f2; }
.doc-status.pending, .doc-status.submitted, .doc-status.in_progress { color: #a56d12; background: #fff6e6; }
.doc-status.rejected, .doc-status.missing { color: #b54858; background: #fff1f3; }
.upload-button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; color: #1475ff; background: #edf5ff; border-radius: 7px; font-size: 10px; font-weight: 600; cursor: pointer; }
.upload-button input { display: none; }
.kyc-complete-banner { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; padding: 14px 16px; color: #128660; background: #e9f9f2; border: 1px solid #bfe8d5; border-radius: 11px; }
.kyc-complete-banner strong { display: block; font-size: 13px; }
.kyc-complete-banner span { color: #3f8f6d; font-size: 10px; }
.compliance-lock-banner { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; padding: 14px 16px; color: #46546a; background: #f5f8fc; border: 1px solid #dfe5ed; border-radius: 11px; }
.compliance-lock-banner strong { display: block; font-size: 13px; color: #35435a; }
.compliance-lock-banner span { color: #607089; font-size: 10px; }
.chain-binding-row { align-items: start; }
.chain-binding-id { display: inline-block; margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: #f5f8fc; border: 1px solid #dfe5ed; color: #35435a; font-size: 12px; letter-spacing: .02em; }
.chain-binding-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
/* Compose message */
.compose-message-page { max-width: 980px; }
.templates-page { max-width: 1100px; }
.templates-panel { margin-bottom: 20px; }
.templates-empty { display: grid; justify-items: start; gap: 8px; padding: 28px 4px 8px; color: #8a95a7; }
.templates-empty strong { color: #46546a; font-size: 12px; }
.templates-empty span { font-size: 10px; max-width: 420px; line-height: 1.45; }
.templates-table { margin-top: 18px; }
.templates-table-head, .templates-table-row {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) minmax(90px, .7fr) minmax(100px, .7fr) minmax(130px, .9fr) minmax(70px, .5fr) minmax(80px, .6fr) auto;
gap: 14px;
align-items: center;
}
.templates-table-head { padding: 0 10px 9px; color: #8a95a7; font-size: 9px; font-weight: 700; text-transform: uppercase; }
.templates-table-row { padding: 14px 10px; border-top: 1px solid #edf0f4; }
.templates-table-template strong { display: block; color: #35435a; font-size: 12px; }
.templates-table-template small {
display: block;
margin-top: 4px;
max-width: 420px;
color: #8a95a7;
font-size: 9px;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.templates-table-sender, .templates-table-channel, .templates-table-vars { color: #607089; font-size: 10px; }
.templates-table-channel { text-transform: capitalize; }
.templates-table-dlt { color: #607089; font-size: 9px; word-break: break-all; }
.templates-send-button { display: inline-flex; align-items: center; justify-content: center; min-width: 38px; padding: 8px 10px; }
.templates-send-button:disabled { opacity: .45; cursor: not-allowed; }
.testing-center-page { max-width: 980px; }
.testing-accordion-heading { display: flex; align-items: flex-start; gap: 12px; }
.testing-accordion-heading svg { margin-top: 2px; color: #1475ff; }
.testing-accordion-body { display: grid; gap: 14px; }
.testing-api-card { padding: 16px; border: 1px solid #edf0f4; border-radius: 8px; background: #fcfdff; }
.testing-api-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.testing-api-header h3 { margin: 0 0 4px; color: #17253e; font-size: 12px; }
.testing-api-header p { margin: 0; color: #8a95a7; font-size: 9px; line-height: 1.45; }
.testing-fields { display: grid; gap: 12px; margin-top: 14px; }
.testing-field { display: grid; gap: 5px; }
.testing-field strong { color: #35435a; font-size: 10px; }
.testing-field span { color: #8a95a7; font-size: 9px; line-height: 1.4; }
.testing-field input, .testing-field select, .testing-field textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #dfe5ed;
border-radius: 5px;
color: #26364d;
font-size: 12px;
}
.testing-built-message {
display: block;
margin-top: 8px;
padding: 10px 12px;
border-radius: 6px;
background: #f5f8fc;
color: #46546a;
font-size: 10px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.testing-actions { margin-top: 14px; }
.testing-notice {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #f3ddb0;
border-radius: 7px;
background: #fffaf0;
color: #a56d12;
font-size: 10px;
line-height: 1.45;
}
.testing-response {
margin-top: 14px;
padding: 12px;
border-radius: 7px;
background: #f5fffa;
border: 1px solid #d4eee2;
color: #2f5f49;
font-size: 10px;
line-height: 1.5;
overflow-x: auto;
}
.testing-response.error {
background: #fff5f5;
border-color: #f0d0d0;
color: #9b3b3b;
}
.testing-empty {
display: grid;
justify-items: start;
gap: 8px;
margin-top: 14px;
padding: 24px;
color: #8a95a7;
}
.testing-empty strong { color: #46546a; font-size: 12px; }
.testing-empty span { font-size: 10px; max-width: 520px; line-height: 1.45; }
.campaigns-page, .campaign-detail-page { max-width: 1100px; }
.campaigns-create-panel, .campaigns-table-panel { margin-bottom: 16px; }
.campaigns-note { margin: 0; color: #8a95a7; font-size: 10px; line-height: 1.45; }
.campaigns-note code { color: #607089; font-size: 9px; }
.campaigns-empty { display: grid; gap: 6px; padding: 24px 4px 8px; color: #8a95a7; }
.campaigns-empty strong { color: #46546a; font-size: 12px; }
.campaigns-empty span { font-size: 10px; }
.campaigns-table { margin-top: 16px; }
.campaigns-table-head, .campaigns-table-row {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) minmax(160px, 1fr) minmax(80px, .5fr) auto;
gap: 14px;
align-items: center;
}
.campaign-csv-table .campaigns-table-head,
.campaign-csv-table .campaigns-table-row {
grid-template-columns: minmax(220px, 1.4fr) minmax(80px, .5fr) minmax(120px, .7fr) auto;
}
.campaigns-table-head { padding: 0 10px 9px; color: #8a95a7; font-size: 9px; font-weight: 700; text-transform: uppercase; }
.campaigns-table-row { padding: 14px 10px; border-top: 1px solid #edf0f4; }
.campaigns-table-row strong { display: block; color: #35435a; font-size: 12px; }
.campaigns-table-row small { display: block; margin-top: 4px; color: #8a95a7; font-size: 9px; }
.campaign-run-mode-label { color: #607089; font-size: 10px; line-height: 1.4; }
.campaign-row-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
.campaign-modal { max-width: 560px; max-height: calc(100vh - 40px); overflow-y: auto; }
.campaign-form { display: grid; gap: 20px; padding: 18px 20px 6px; }
.campaign-form-section { display: grid; gap: 12px; }
.campaign-form-section-title {
margin: 0;
color: #8a95a7;
font-size: 9px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.campaign-field { display: grid; gap: 6px; }
.campaign-field > span, .campaign-variable-map legend, .campaign-run-mode-group legend {
color: #46546a;
font-size: 11px;
font-weight: 600;
}
.campaign-form input:not([type="radio"]):not([type="checkbox"]),
.campaign-form select {
width: 100%;
padding: 10px 12px;
border: 1px solid #dfe5ed;
border-radius: 5px;
font-size: 12px;
color: #26364d;
}
.campaign-form small { color: #8a95a7; font-size: 9px; line-height: 1.4; }
.campaign-template-preview {
margin: 0;
padding: 10px 12px;
border-radius: 6px;
background: #f5f8fc;
color: #607089;
font-size: 10px;
line-height: 1.5;
}
.campaign-variable-map,
.campaign-run-mode-group {
display: grid;
gap: 10px;
margin: 0;
padding: 12px;
border: 1px solid #edf0f4;
border-radius: 6px;
background: #fafbfd;
}
.campaign-variable-map legend,
.campaign-run-mode-group legend {
padding: 0;
margin-bottom: 2px;
}
.campaign-run-mode-option {
display: flex;
align-items: center;
gap: 8px;
color: #607089;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.campaign-run-mode-option input[type="radio"] {
width: auto;
margin: 0;
flex-shrink: 0;
accent-color: #1475ff;
}
.campaign-run-mode-option span { flex: 1; }
.campaign-schedule-field { margin-top: -4px; padding-left: 4px; }
.campaign-upload-panel { margin-bottom: 16px; }
.campaign-upload-actions { display: grid; gap: 8px; }
.campaign-upload-actions small { color: #8a95a7; font-size: 9px; }
.campaign-upload-actions code { color: #1475ff; font-size: 9px; }
.campaign-success { margin: 10px 0 0; color: #2f7a4f; font-size: 10px; }
.campaign-back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 14px; }
.numbers-page { max-width: 1100px; }
.voice-page { max-width: 1180px; }
.voice-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
.voice-column { display: grid; gap: 16px; align-content: start; min-width: 0; }
.voice-panel { display: grid; gap: 14px; align-content: start; }
.voice-form-grid { margin-top: 2px; }
.voice-form-grid .explorer-input label { display: grid; gap: 6px; }
.voice-form-grid .explorer-input select {
width: 100%;
border: 1px solid #dfe5ed;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
color: #26364d;
background: #fff;
}
.voice-presence { display: grid; gap: 8px; padding: 14px; border-radius: 12px; background: #f7f9fc; border: 1px solid #e6ebf2; }
.voice-presence.is-live { border-color: #b9e6c8; background: #f3fbf6; }
.voice-presence span { color: #2f7a4f; font-size: 11px; font-weight: 600; }
.voice-audio-panel { display: grid; gap: 10px; margin-top: 4px; }
.voice-audio-hint { margin: 0; }
.voice-actions { margin-top: 2px; }
.voice-call-status { display: flex; gap: 10px; align-items: flex-start; margin-top: 4px; padding: 12px; border-radius: 10px; background: #f7f9fc; }
.voice-call-status strong { display: block; color: #35435a; font-size: 12px; text-transform: capitalize; }
.voice-call-status span { display: block; margin-top: 4px; color: #607089; font-size: 10px; }
.voice-call-status code { display: block; margin-top: 8px; font-size: 9px; color: #1475ff; word-break: break-all; }
.voice-events-empty { color: #8a95a7; font-size: 10px; margin: 0; }
.voice-events { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; max-height: 320px; overflow: auto; }
.voice-events li { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; padding: 8px 0; border-top: 1px solid #edf0f4; font-size: 10px; }
.voice-events li span { color: #8a95a7; }
.voice-events li strong { color: #46546a; font-weight: 600; line-height: 1.45; }
.voice-api-panel { min-width: 0; }
.voice-live-icon { color: #2f7a4f; }
@media (max-width: 960px) {
.voice-layout { grid-template-columns: 1fr; }
}
.numbers-panel { margin-bottom: 16px; }
.numbers-empty { display: grid; gap: 6px; padding: 24px 4px 8px; color: #8a95a7; }
.numbers-empty strong { color: #46546a; font-size: 12px; }
.numbers-empty span { font-size: 10px; line-height: 1.45; }
.numbers-empty a { color: #1475ff; }
.numbers-table { margin-top: 16px; }
.numbers-table-head, .numbers-table-row {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) minmax(130px, .9fr) minmax(130px, .9fr) minmax(120px, .8fr);
gap: 14px;
align-items: center;
}
.numbers-table-pricing .numbers-table-head,
.numbers-table-pricing .numbers-table-row {
grid-template-columns: minmax(140px, 1fr) minmax(110px, .8fr) minmax(110px, .8fr) minmax(110px, .8fr) auto;
}
.numbers-table-head { padding: 0 10px 9px; color: #8a95a7; font-size: 9px; font-weight: 700; text-transform: uppercase; }
.numbers-table-row { padding: 14px 10px; border-top: 1px solid #edf0f4; }
.numbers-table-row strong { display: block; color: #35435a; font-size: 12px; }
.numbers-table-row small { display: block; margin-top: 4px; color: #8a95a7; font-size: 9px; }
.numbers-table-phone { font-family: ui-monospace, monospace; }
.logs-page { max-width: 1080px; }
.logs-toolbar { margin-bottom: 16px; }
.logs-toolbar-row { display: grid; grid-template-columns: minmax(160px, 220px) repeat(2, minmax(140px, 180px)); gap: 14px; align-items: end; }
.logs-field { display: flex; flex-direction: column; gap: 6px; }
.logs-field span { color: #8a95a7; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.logs-field select, .logs-field input { width: 100%; padding: 10px 12px; border: 1px solid #e5eaf1; border-radius: 10px; background: #fff; color: #35435a; font-size: 13px; }
.logs-toolbar-meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 14px; color: #8a95a7; font-size: 12px; }
.logs-table-panel { margin-bottom: 16px; }
.logs-empty, .logs-table-panel .explorer-loading { padding: 24px 10px; color: #8a95a7; font-size: 13px; }
.logs-table { margin-top: 4px; }
.logs-table-head, .logs-table-row { display: grid; gap: 12px; align-items: center; padding: 0 10px; }
.logs-cols-4 { grid-template-columns: 1.1fr 1.1fr 0.8fr 1.2fr; }
.logs-cols-5 { grid-template-columns: 0.9fr 1fr 1.1fr 0.8fr 1.2fr; }
.logs-table-head { padding-bottom: 9px; color: #8a95a7; font-size: 9px; font-weight: 700; text-transform: uppercase; }
.logs-table-row { padding: 14px 10px; border-top: 1px solid #edf0f4; color: #35435a; font-size: 12px; }
.logs-table-row strong { color: #35435a; font-size: 12px; }
.logs-status { display: inline-flex; width: fit-content; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 700; text-transform: uppercase; }
.logs-status.success { color: #0f7a49; background: #e8f8ef; }
.logs-status.failed { color: #b42318; background: #fdecec; }
.logs-status.pending { color: #8a5b00; background: #fff7df; }
.logs-pagination { display: flex; justify-content: center; align-items: center; gap: 14px; padding: 16px 10px 6px; color: #607089; font-size: 12px; }
.analytics-page { max-width: 1180px; }
.analytics-toolbar { margin-bottom: 16px; }
.analytics-toolbar-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; align-items: end; }
.analytics-segmented-wrap { flex-wrap: wrap; }
.analytics-chart-wrap.is-sparse { display: flex; justify-content: center; }
.analytics-chart-wrap.is-sparse .recharts-responsive-container { max-width: 420px; }
.analytics-tooltip p { margin: 0 0 8px; color: #8a95a7; font-size: 11px; }
.analytics-toggle-group { display: flex; flex-direction: column; gap: 8px; }
.analytics-toggle-label, .analytics-group-field > span { color: #8a95a7; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.analytics-toggle-group small, .analytics-group-field small { color: #8a95a7; font-size: 11px; }
.analytics-segmented { display: inline-flex; gap: 6px; padding: 4px; border: 1px solid #e5eaf1; border-radius: 12px; background: #f8fafc; }
.analytics-segmented button { border: 0; background: transparent; color: #607089; padding: 8px 14px; border-radius: 8px; font-size: 12px; font-weight: 600; }
.analytics-segmented button.active { color: #1475ff; background: #fff; box-shadow: 0 1px 2px #0f172a14; }
.analytics-group-field small { display: block; margin-top: 6px; }
.analytics-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; }
.analytics-stat { position: relative; overflow: hidden; padding: 16px 18px; border: 1px solid #e5eaf1; border-radius: 12px; background: #fff; }
.analytics-stat span { color: #8a95a7; font-size: 10px; font-weight: 700; text-transform: uppercase; }
.analytics-stat strong { display: block; margin-top: 10px; color: #12213a; font-size: 24px; letter-spacing: -.03em; }
.analytics-stat i { position: absolute; top: 14px; right: 14px; width: 8px; height: 8px; border-radius: 50%; }
.analytics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.analytics-chart-panel { min-height: 360px; }
.analytics-chart-wrap { height: 280px; }
.analytics-empty { display: grid; place-items: center; height: 100%; color: #8a95a7; font-size: 13px; }
.analytics-tooltip { padding: 10px 12px; border: 1px solid #e4e9f1; border-radius: 10px; background: #fff; font-size: 12px; box-shadow: 0 8px 24px #0f172a14; }
.analytics-tooltip strong { display: block; margin-bottom: 8px; color: #35435a; }
.analytics-tooltip div { display: flex; align-items: center; gap: 8px; color: #607089; margin-top: 4px; }
.analytics-tooltip span { width: 8px; height: 8px; border-radius: 50%; }
@media (max-width: 980px) {
.analytics-toolbar-row, .analytics-grid, .analytics-summary-grid { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
.logs-toolbar-row { grid-template-columns: 1fr; }
.logs-table-head { display: none; }
.logs-table-row { grid-template-columns: 1fr; gap: 6px; padding: 16px 10px; }
}
.numbers-price { display: inline-flex; align-items: center; gap: 6px; color: #607089; font-size: 10px; }
.admin-phone-form { margin-bottom: 16px; }
.admin-phone-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.admin-phone-form-grid label { display: grid; gap: 6px; }
.admin-phone-form-grid label > span { color: #46546a; font-size: 11px; font-weight: 600; }
.admin-phone-form-grid input {
width: 100%;
padding: 10px 12px;
border: 1px solid #dfe5ed;
border-radius: 5px;
font-size: 12px;
}
.admin-phone-form-grid small { color: #8a95a7; font-size: 9px; line-height: 1.4; }
.admin-phone-number { grid-column: 1 / -1; }
.admin-phone-email { grid-column: 1 / -1; }
.admin-phone-form-grid .primary-button { grid-column: 1 / -1; justify-self: start; }
.compose-message-card { padding: 0; overflow: hidden; }
.compose-card-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid #edf0f4; background: #fafbfd; }
.compose-card-header div { display: flex; align-items: center; gap: 10px; color: #46546a; }
.compose-card-header h2 { margin: 0; font-size: 16px; font-weight: 600; color: #35435a; }
.compose-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px; padding: 22px; }
.compose-field { display: grid; gap: 8px; min-width: 0; }
.compose-field-wide { grid-column: 1 / -1; }
.compose-field > span { color: #607089; font-size: 11px; font-weight: 600; }
.compose-field input, .compose-field textarea, .select-wrap select { width: 100%; border: 1px solid #dfe5ed; border-radius: 4px; padding: 11px 12px; font-size: 12px; color: #26364d; background: white; }
.compose-field textarea { resize: vertical; min-height: 120px; line-height: 1.5; }
.compose-field input:focus, .compose-field textarea:focus, .select-wrap select:focus { outline: none; border-color: #1475ff; box-shadow: 0 0 0 3px rgba(20,117,255,.08); }
.select-wrap { position: relative; }
.select-wrap select { appearance: none; padding-right: 34px; }
.select-wrap svg { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); color: #8a95a7; pointer-events: none; }
.compose-message-section { display: grid; gap: 12px; padding-top: 4px; border-top: 1px solid #edf0f4; }
.compose-message-toolbar { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(180px, .6fr); gap: 16px; align-items: end; }
.compose-template-field, .compose-language-field { margin: 0; }
.template-message-editor, .compose-free-text { min-height: 180px; padding: 14px; border: 1px solid #dfe5ed; border-radius: 4px; background: #fcfdff; line-height: 1.7; font-size: 12px; color: #26364d; }
.compose-free-text { width: 100%; resize: vertical; }
.template-message-chunk { display: inline; white-space: pre-wrap; }
.template-fixed-text { color: #46546a; }
.template-variable-input { display: inline-block; min-width: 120px; max-width: 220px; margin: 0 4px 6px; border: 1px solid #1475ff; border-radius: 4px; padding: 6px 8px; font-size: 12px; color: #17253e; background: #f3f8ff; vertical-align: baseline; }
.template-variable-input:focus { outline: none; box-shadow: 0 0 0 3px rgba(20,117,255,.12); }
.compose-message-meta { display: flex; justify-content: space-between; gap: 12px; color: #8a95a7; font-size: 10px; font-weight: 600; }
.compose-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 22px 22px; border-top: 1px solid #edf0f4; background: #fafbfd; }
.compose-settings-button { background: white; }
.compose-footer-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
.compose-draft-toggle { display: inline-flex; align-items: center; gap: 8px; color: #607089; font-size: 11px; font-weight: 600; }
.compose-draft-toggle input { accent-color: #1475ff; }
.compose-status { margin: 0 22px; }
.compose-send-result { margin: 0 22px 18px; padding: 14px 16px; border-radius: 8px; border: 1px solid #dfe5ed; background: #f5f8fc; }
.compose-send-result.success { border-color: #bfe8d5; background: #f5fffa; color: #128660; }
.compose-send-result.warning { border-color: #f3ddb0; background: #fffaf0; color: #a56d12; }
.compose-send-result strong { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.compose-send-result small { display: block; margin-top: 6px; color: inherit; opacity: .85; font-size: 10px; }
.compose-send-result ul { margin: 10px 0 0; padding-left: 18px; font-size: 10px; line-height: 1.5; }
/* API explorer */
.api-explorer-page { max-width: 980px; }
.explorer-loading { padding: 24px 0; color: #607089; font-size: 13px; }
.explorer-group { display: grid; gap: 18px; margin-bottom: 28px; }
.explorer-group-title { margin: 0 0 4px; color: #607089; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.explorer-endpoint-card h3, .explorer-endpoint-card h4 { margin: 0; color: #35435a; font-size: 13px; }
.explorer-endpoint-card h4 { font-size: 12px; margin-bottom: 10px; }
.explorer-endpoint-card .explorer-input select { width: 100%; border: 1px solid #dfe5ed; border-radius: 6px; padding: 10px 12px; background: white; color: #26364d; font-size: 12px; }
.explorer-endpoint-card { display: grid; gap: 18px; }
.explorer-method { padding: 6px 10px; border-radius: 999px; background: #eef5ff; color: #1475ff; font-size: 10px; font-weight: 700; }
.explorer-field-row { display: grid; gap: 6px; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.explorer-field-row span { color: #607089; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.explorer-field-row code { color: #26364d; font-size: 12px; word-break: break-all; }
.explorer-section h3 { margin: 0 0 10px; color: #35435a; font-size: 13px; }
.explorer-request-grid { display: grid; gap: 14px; }
.explorer-input { display: grid; gap: 6px; }
.explorer-input strong { color: #35435a; font-size: 12px; }
.explorer-input span { color: #8a95a7; font-size: 10px; line-height: 1.4; }
.explorer-input input, .explorer-input textarea { width: 100%; border: 1px solid #dfe5ed; border-radius: 6px; padding: 10px 12px; font-size: 12px; color: #26364d; }
.explorer-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.explorer-code, .explorer-response pre { margin: 0; padding: 14px; border-radius: 8px; background: #0f1728; color: #dbe7ff; font-size: 11px; line-height: 1.5; overflow: auto; }
.explorer-response { display: grid; gap: 10px; padding: 14px; border: 1px solid #bfe8d5; border-radius: 8px; background: #f5fffa; }
.explorer-response strong { display: flex; align-items: center; gap: 8px; color: #128660; font-size: 12px; }
.explorer-notes { margin: 0; padding-left: 18px; color: #607089; font-size: 11px; line-height: 1.6; }
.explorer-notes li { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; }
/* Webhooks & Sockets */
.webhooks-sockets-page { max-width: 1180px; }
.webhooks-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
.webhooks-column { display: grid; gap: 18px; }
.relay-callout { padding: 14px; border-radius: 8px; background: #eef5ff; border: 1px solid #d6e6ff; }
.relay-callout strong { display: block; color: #1475ff; font-size: 12px; margin-bottom: 6px; }
.relay-callout p { margin: 0; color: #46546a; font-size: 11px; line-height: 1.55; }
.relay-steps, .relay-flow { margin: 0; padding-left: 18px; color: #46546a; font-size: 11px; line-height: 1.6; }
.relay-flow-step { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; list-style: none; padding: 0; }
.relay-flow-step span { width: 22px; height: 22px; border-radius: 999px; background: #1475ff; color: white; font-size: 10px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.relay-flow-step p { margin: 2px 0 0; }
.relay-note { margin: 0 0 10px; color: #607089; font-size: 11px; line-height: 1.5; }
.relay-endpoints { margin: 0; padding: 0; list-style: none; display: grid; gap: 12px; }
.relay-endpoints li { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: start; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.relay-endpoints li:last-child { border-bottom: none; padding-bottom: 0; }
.relay-endpoints p { margin: 4px 0 0; color: #8a95a7; font-size: 10px; }
.relay-number-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 12px; }
.relay-number-list li { display: grid; grid-template-columns: 120px minmax(0, 1fr) auto; gap: 10px; align-items: center; }
.relay-number-list strong { font-size: 12px; color: #35435a; }
.relay-number-list input { border: 1px solid #dfe5ed; border-radius: 6px; padding: 8px 10px; font-size: 11px; }
.relay-toggle { display: flex; align-items: center; gap: 8px; color: #46546a; font-size: 11px; }
.relay-success { margin: 0; padding: 10px 12px; border-radius: 6px; background: #f5fffa; border: 1px solid #bfe8d5; color: #128660; font-size: 11px; }
.integration-tabs { display: inline-flex; gap: 4px; margin-bottom: 18px; padding: 4px; border: 1px solid #dfe5ed; border-radius: 9px; background: #f5f7fa; }
.integration-tabs button { display: flex; align-items: center; gap: 7px; border: 0; border-radius: 6px; padding: 9px 13px; background: transparent; color: #607089; font-size: 11px; font-weight: 650; cursor: pointer; }
.integration-tabs button.is-active { background: white; color: #1475ff; box-shadow: 0 1px 3px rgba(23,37,61,.1); }
.integration-feedback { margin: 0 0 14px; }
.integration-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .85fr); gap: 18px; align-items: start; }
.endpoint-list-panel, .endpoint-create-panel, .integration-docs { display: grid; gap: 16px; }
.endpoint-create-panel { position: sticky; top: 88px; }
.endpoint-count { min-width: 25px; height: 25px; display: inline-flex; align-items: center; justify-content: center; border-radius: 99px; background: #eef5ff; color: #1475ff; font-size: 11px; font-weight: 700; }
.endpoint-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: start; padding: 14px 0; border-top: 1px solid #edf0f4; }
.endpoint-icon { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 8px; background: #eef5ff; color: #1475ff; }
.endpoint-main { min-width: 0; display: grid; gap: 5px; }
.endpoint-main > div { display: flex; align-items: center; gap: 8px; }
.endpoint-main strong { color: #26364d; font-size: 12px; }
.endpoint-main span, .endpoint-main small { color: #7a8799; font-size: 10px; }
.endpoint-main code { overflow: hidden; color: #46546a; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.endpoint-status { display: inline-flex !important; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 99px; background: #f1f3f6; color: #7a8799 !important; font-size: 9px !important; font-weight: 650; }
.endpoint-status i { width: 5px; height: 5px; border-radius: 50%; background: #9aa4b2; }
.endpoint-status.is-active { background: #ebfaf3; color: #128660 !important; }
.endpoint-status.is-active i { background: #18a979; }
.endpoint-actions { display: flex; align-items: center; gap: 6px; }
.endpoint-actions .ghost-button { padding: 7px 9px; }
.endpoint-empty { display: grid; gap: 5px; padding: 24px; border: 1px dashed #d9e0e9; border-radius: 8px; text-align: center; }
.endpoint-empty strong { color: #35435a; font-size: 12px; }
.endpoint-empty span { color: #8a95a7; font-size: 10px; }
.endpoint-create-panel .explorer-input select { width: 100%; border: 1px solid #dfe5ed; border-radius: 6px; padding: 10px 12px; background: white; color: #26364d; font-size: 12px; }
.endpoint-create-panel .primary-button { justify-content: center; }
.endpoint-form-note { margin: 0; padding: 10px; border-radius: 6px; background: #fff8e8; color: #986a16; font-size: 10px; }
.integration-secret { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: start; margin: 0 0 16px; padding: 14px; border: 1px solid #bfe8d5; border-radius: 8px; background: #f5fffa; color: #128660; }
.integration-secret strong { display: block; font-size: 12px; }
.integration-secret p { margin: 3px 0 8px; color: #607089; font-size: 10px; }
.integration-secret code { display: block; overflow-wrap: anywhere; color: #26364d; font-size: 10px; }
.integration-docs > p, .integration-docs ol { margin: 0; color: #607089; font-size: 11px; line-height: 1.6; }
.integration-docs ol { padding-left: 18px; }
.integration-docs h3 { margin: 2px 0 0; color: #35435a; font-size: 12px; }
.integration-api-summary { margin-top: 18px; }
.integration-api-link { justify-self: start; text-decoration: none; }
@media (max-width: 960px) {
.webhooks-layout { grid-template-columns: 1fr; }
.relay-number-list li { grid-template-columns: 1fr; }
.integration-grid { grid-template-columns: 1fr; }
.endpoint-create-panel { position: static; }
}
@media (max-width: 640px) {
.endpoint-row { grid-template-columns: auto minmax(0, 1fr); }
.endpoint-actions { grid-column: 2; flex-wrap: wrap; }
.integration-tabs { display: flex; }
.integration-tabs button { flex: 1; justify-content: center; }
.integration-secret { grid-template-columns: auto 1fr; }
.integration-secret button { grid-column: 2; justify-self: start; }
}
/* API keys */
.api-keys-page { max-width: 920px; }
.api-key-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 12px; align-items: center; padding: 14px 0; border-bottom: 1px solid #edf0f4; }
.api-key-row:last-of-type { border-bottom: none; }
.api-key-row strong { display: flex; align-items: center; gap: 8px; color: #35435a; font-size: 12px; }
.api-key-row span { display: block; margin-top: 4px; color: #607089; font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.api-key-row small { display: block; margin-top: 4px; color: #8a95a7; font-size: 10px; }
.api-key-modal, .api-key-reveal-modal { padding-bottom: 0; }
.modal-field-stack { display: grid; gap: 8px; padding: 16px 20px; border-bottom: 1px solid #edf0f4; }
.modal-field-stack > span { color: #46546a; font-size: 12px; font-weight: 600; }
.api-key-reveal-body { padding: 18px 20px 0; }
.api-key-warning { margin: 0 0 12px; color: #a56d12; font-size: 11px; font-weight: 600; }
.api-key-token-box { display: flex; gap: 10px; align-items: flex-start; padding: 12px; border: 1px solid #dfe5ed; border-radius: 6px; background: #f5f8fc; }
.api-key-token-box code { flex: 1; overflow-wrap: anywhere; color: #17253e; font-size: 11px; line-height: 1.5; }
.api-key-meta { margin: 10px 0 0; color: #8a95a7; font-size: 10px; }
/* Admin portal */
.admin-shell { min-height: 100vh; background: #f6f8fb; }
.admin-topbar { height: 72px; display: flex; align-items: center; justify-content: space-between; padding: 0 28px; background: #07172f; color: white; border-bottom: 1px solid rgba(255,255,255,.08); }
.admin-topbar > div { display: flex; align-items: center; gap: 14px; }
.admin-topbar img { width: 92px; height: 42px; object-fit: contain; }
.admin-topbar strong { font-size: 12px; color: #c7d6ea; }
.admin-content, .admin-login-page { padding: 28px; }
.admin-login-page { min-height: calc(100vh - 72px); display: grid; place-items: center; }
.admin-login-card { width: min(420px, 100%); }
.admin-kyc-card { margin-bottom: 18px; }
.admin-actions { display: flex; gap: 8px; }
/* Message settings accordion */
.accordion-panel { margin-bottom: 14px; padding: 0; overflow: hidden; }
.accordion-trigger { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 19px; background: transparent; border: 0; text-align: left; }
.accordion-trigger h2 { margin: 0 0 4px; font-size: 13px; color: #17253e; }
.accordion-trigger p { margin: 0; font-size: 9px; color: #8b96a7; }
.accordion-trigger svg { color: #7c899d; transition: transform .2s ease; }
.accordion-trigger svg.rotated { transform: rotate(180deg); }
.accordion-body { padding: 0 19px 19px; }
.accordion-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: #607089; font-size: 10px; font-weight: 600; }
.accordion-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.accordion-toolbar .accordion-heading { margin-bottom: 0; }
.empty-copy { margin: 8px 0 0; color: #8a95a7; font-size: 11px; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 20px; background: rgba(8, 18, 36, .45); }
.modal-card { width: min(560px, 100%); background: white; border: 1px solid #e4e9f1; border-radius: 4px; box-shadow: 0 24px 60px rgba(15, 28, 50, .18); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid #edf0f4; }
.modal-header h2 { margin: 0; color: #35435a; font-size: 18px; font-weight: 500; }
.sender-modal { padding-bottom: 0; }
.modal-field { display: grid; grid-template-columns: 130px 1fr; gap: 14px; align-items: start; padding: 16px 20px; border-bottom: 1px solid #edf0f4; }
.modal-field > span { padding-top: 10px; color: #46546a; font-size: 12px; font-weight: 600; }
.modal-field input, .modal-field textarea, .modal-field select { width: 100%; border: 1px solid #dfe5ed; border-radius: 4px; padding: 10px 12px; font-size: 12px; color: #26364d; resize: vertical; background: white; }
.modal-field input:focus, .modal-field textarea:focus, .modal-field select:focus { outline: none; border-color: #1475ff; box-shadow: 0 0 0 3px rgba(20,117,255,.08); }
.template-field > span { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.text-link { border: none; background: none; padding: 0; color: #1475ff; font-size: 12px; font-weight: 600; cursor: pointer; }
.text-link:hover { text-decoration: underline; }
.template-example { margin: 8px 0 0; color: #8a95a7; font-size: 11px; line-height: 1.5; }
.radio-row { display: flex; gap: 22px; padding-top: 8px; color: #46546a; font-size: 12px; }
.radio-row label { display: flex; align-items: center; gap: 7px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 20px 20px; }
.admin-tabs { display: flex; gap: 8px; margin-bottom: 18px; }
.admin-tabs button { border: 1px solid #dfe5ed; background: white; color: #607089; border-radius: 8px; padding: 9px 14px; font-size: 11px; font-weight: 600; }
.admin-tabs button.active { color: white; background: #1475ff; border-color: #1475ff; }
.admin-otp-project { display: grid; gap: 9px; margin-top: 14px; color: #607089; font-size: 11px; }
.admin-otp-project a { color: #1475ff; word-break: break-all; }
.admin-otp-project p { margin: 0; line-height: 1.55; }
.admin-otp-project .admin-actions { margin-top: 5px; }
/* UrmiOTP */
.urmiotp-page, .urmiotp-docs-page { max-width: 1080px; }
.otp-page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
.otp-map { padding: 24px; margin-bottom: 18px; overflow: hidden; background: linear-gradient(135deg, #07172f, #102c55); border-color: #183862; color: white; }
.otp-map-root { width: max-content; display: flex; align-items: center; gap: 9px; margin: 0 auto 28px; padding: 10px 16px; border: 1px solid rgba(255,255,255,.2); border-radius: 999px; background: rgba(20,117,255,.2); font-size: 13px; }
.otp-map-branches { position: relative; display: grid; grid-template-columns: repeat(7, minmax(105px, 1fr)); gap: 10px; }
.otp-map-branches::before { content: ""; position: absolute; top: -15px; left: 7%; right: 7%; height: 1px; background: rgba(121,170,230,.45); }
.otp-map-branches > div { position: relative; min-height: 82px; padding: 12px; border: 1px solid rgba(255,255,255,.1); border-radius: 8px; background: rgba(255,255,255,.055); text-align: center; }
.otp-map-branches > div::before { content: ""; position: absolute; top: -15px; left: 50%; height: 15px; width: 1px; background: rgba(121,170,230,.45); }
.otp-map-branches strong, .otp-map-branches span { display: block; }
.otp-map-branches strong { margin-bottom: 7px; color: #eef6ff; font-size: 10px; }
.otp-map-branches span { color: #9fb5d2; font-size: 8px; line-height: 1.45; }
.otp-projects-panel { margin-bottom: 20px; }
.otp-empty { display: grid; justify-items: center; gap: 7px; padding: 42px 20px 25px; color: #8a95a7; text-align: center; }
.otp-empty strong { color: #46546a; font-size: 12px; }
.otp-empty span { font-size: 10px; }
.otp-project-table { margin-top: 18px; }
.otp-project-table-head, .otp-project-row { display: grid; grid-template-columns: minmax(180px, 1.3fr) minmax(140px, .8fr) minmax(120px, .7fr) auto; gap: 14px; align-items: center; }
.otp-project-table-head { padding: 0 10px 9px; color: #8a95a7; font-size: 9px; font-weight: 700; text-transform: uppercase; }
.otp-project-row { padding: 14px 10px; border-top: 1px solid #edf0f4; }
.otp-project-row strong, .otp-project-row small { display: block; }
.otp-project-row strong { color: #35435a; font-size: 12px; }
.otp-project-row small { margin-top: 4px; max-width: 360px; color: #8a95a7; font-size: 9px; line-height: 1.4; }
.otp-project-row a { display: flex; align-items: center; gap: 5px; color: #1475ff; font-size: 10px; text-transform: capitalize; }
.otp-awaiting { display: flex; align-items: center; gap: 6px; color: #8a95a7; font-size: 9px; }
.otp-project-modal { max-height: calc(100vh - 40px); overflow-y: auto; }
.otp-project-form { display: grid; gap: 16px; padding: 18px 20px 5px; }
.otp-project-form > label { display: grid; gap: 7px; }
.otp-project-form label > span, .otp-project-form legend { color: #46546a; font-size: 11px; font-weight: 600; }
.otp-project-form input[type="text"], .otp-project-form input[type="url"], .otp-project-form input:not([type]), .otp-project-form textarea { width: 100%; padding: 10px 12px; border: 1px solid #dfe5ed; border-radius: 5px; color: #26364d; font-size: 12px; }
.otp-project-form small { color: #8a95a7; font-size: 9px; }
.otp-project-form fieldset { display: flex; gap: 20px; padding: 0; border: 0; }
.otp-project-form fieldset legend { margin-bottom: 8px; }
.otp-project-form fieldset label { display: flex; align-items: center; gap: 7px; color: #607089; font-size: 10px; }
.otp-terms { grid-template-columns: auto 1fr; align-items: start; }
.otp-terms input { margin-top: 2px; accent-color: #1475ff; }
.otp-terms span { line-height: 1.45; }
.otp-back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 14px; }
.otp-api-overview { display: grid; gap: 16px; margin-bottom: 18px; }
.otp-api-notice { display: flex; gap: 8px; padding: 10px 12px; border: 1px solid #f3ddb0; border-radius: 7px; background: #fffaf0; color: #a56d12; font-size: 10px; }
.otp-parameter-list { display: grid; gap: 8px; }
.otp-parameter-list > div { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 10px; border-radius: 6px; background: #f7f9fc; font-size: 10px; }
.otp-parameter-list code { color: #1475ff; font-weight: 700; }
.otp-parameter-list span { color: #607089; }
.otp-code-heading { display: flex; justify-content: space-between; align-items: center; color: #35435a; font-size: 11px; }
.otp-template-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.otp-template-card { position: relative; display: grid; gap: 14px; overflow: hidden; }
.otp-template-number { position: absolute; top: 12px; right: 16px; color: #e7edf5; font-size: 40px; font-weight: 800; }
.otp-template-card .panel-heading { position: relative; padding-right: 42px; }
.otp-template-card .panel-heading code { color: #1475ff; }
.otp-template-copy { padding: 11px; border: 1px solid #dfe5ed; border-radius: 7px; background: #f9fbfd; }
.otp-template-copy.sample { background: #f5fffa; border-color: #d4eee2; }
.otp-template-copy span { color: #8a95a7; font-size: 8px; font-weight: 700; text-transform: uppercase; }
.otp-template-copy p { margin: 6px 0 0; color: #46546a; font-size: 10px; line-height: 1.55; }
.otp-template-columns { display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px; }
.otp-template-columns strong { color: #35435a; font-size: 10px; }
.otp-template-columns ul, .otp-template-columns ol { display: grid; gap: 5px; margin: 8px 0 0; padding: 0; color: #607089; font-size: 9px; }
.otp-template-columns ul { list-style: none; }
.otp-template-columns ol { padding-left: 16px; }
.otp-template-columns li { line-height: 1.35; }
.otp-template-columns ul li { display: flex; gap: 5px; }
@media (max-width: 1180px) {
.stats-grid { grid-template-columns: repeat(4, 1fr); }
.balance-card { grid-column: 1 / -1; }
.balance-card button { max-width: 180px; }
.system-status { display: none; }
}
@media (max-width: 900px) {
.sidebar { transform: translateX(-100%); transition: transform .25s ease; }
.sidebar.is-open { transform: translateX(0); }
.sidebar-close, .menu-button { display: inline-grid !important; }
.sidebar-scrim { position: fixed; inset: 0; z-index: 25; border: 0; background: rgba(4,15,33,.48); }
.workspace { width: 100%; margin-left: 0; }
.topbar { padding: 0 16px; }
.dashboard-grid { grid-template-columns: 1fr; }
.landing-nav nav { display: none; }
.hero-section { grid-template-columns: 1fr; padding-top: 65px; }
.hero-copy { text-align: center; margin: auto; }
.hero-copy > p { margin-inline: auto; }
.hero-actions, .hero-proof { justify-content: center; }
.agent-demo { margin-top: 30px; }
.feature-grid { grid-template-columns: 1fr; max-width: 560px; }
.auth-page { grid-template-columns: 1fr; }
.auth-visual { display: none; }
.account-grid { grid-template-columns: 1fr; }
.kyc-grid { grid-template-columns: 1fr; }
.kyc-doc-row { grid-template-columns: 1fr; }
.modal-field { grid-template-columns: 1fr; }
.compose-grid { grid-template-columns: 1fr; }
.compose-message-toolbar { grid-template-columns: 1fr; }
.compose-footer { flex-direction: column; align-items: stretch; }
.compose-footer-actions { justify-content: stretch; }
.compose-footer-actions .primary-button { flex: 1; }
.api-key-row { grid-template-columns: 1fr; }
.otp-map-branches { grid-template-columns: repeat(4, 1fr); }
.otp-map-branches::before, .otp-map-branches > div::before { display: none; }
.otp-project-table-head { display: none; }
.otp-project-row { grid-template-columns: 1fr; align-items: start; }
.templates-table-head { display: none; }
.templates-table-row { grid-template-columns: 1fr; align-items: start; gap: 8px; }
.templates-send-button { justify-self: start; }
.otp-template-grid { grid-template-columns: 1fr; }
}
@media (max-width: 680px) {
main { padding: 22px 14px 40px; }
.hide-mobile, .environment, .global-search kbd { display: none; }
.global-search { width: auto; flex: 1; }
.global-search input::placeholder { color: transparent; }
.welcome-row { align-items: flex-start; }
.welcome-actions .secondary-button { display: none; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.balance-card { grid-column: 1 / -1; }
.legend { display: none; }
.landing-nav { padding-inline: 18px; }
.landing-brand { width: 100px; }
.landing-actions { gap: 12px; }
.landing-cta { padding: 9px 11px; }
.hero-section { grid-template-columns: minmax(0, 1fr); padding: 55px 20px 65px; }
.hero-copy h1 { font-size: 42px; }
.hero-proof { flex-wrap: wrap; gap: 10px; }
.demo-window pre { padding: 25px 17px 30px; font-size: 10px; }
.orbit-one { right: 16px; }
.orbit-two { left: -8px; }
.developer-band { align-items: flex-start; flex-direction: column; }
.landing-footer { flex-wrap: wrap; }
.landing-footer small { margin-left: 0; width: 100%; }
.auth-main { padding: 78px 20px 40px; }
.back-home { top: 25px; right: 20px; }
.otp-page-heading { align-items: flex-start; flex-direction: column; }
.otp-map-branches { grid-template-columns: repeat(2, 1fr); }
.otp-parameter-list > div { grid-template-columns: 1fr; }
}

790
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,790 @@
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
export type DashboardResponse = {
metrics: {
voice_calls: number
messages_sent: number
delivery_rate: number
api_requests: number
balance: number
}
activity: Array<{
id: string
type: string
title: string
detail: string
status: string
occurred_at: string
}>
}
export async function getDashboard(signal?: AbortSignal): Promise<DashboardResponse> {
const response = await fetch(`${API_BASE_URL}/api/v1/dashboard`, {
signal,
credentials: 'include',
})
if (!response.ok) throw new Error(`Dashboard request failed: ${response.status}`)
return response.json() as Promise<DashboardResponse>
}
export type AuthUser = {
id: string
email: string
name: string
avatar_url: string | null
email_verified: boolean
has_password: boolean
providers: string[]
}
async function authRequest(path: string, init?: RequestInit): Promise<AuthUser> {
const response = await fetch(`${API_BASE_URL}/api/v1/auth${path}`, {
...init,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...init?.headers },
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail ?? 'Authentication request failed')
}
return response.json() as Promise<AuthUser>
}
export const authApi = {
me: () => authRequest('/me'),
login: (email: string, password: string) =>
authRequest('/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
register: (name: string, email: string, password: string) =>
authRequest('/register', { method: 'POST', body: JSON.stringify({ name, email, password }) }),
setPassword: (password: string, currentPassword?: string) =>
authRequest('/password', {
method: 'POST',
body: JSON.stringify({ password, current_password: currentPassword || null }),
}),
logout: () =>
fetch(`${API_BASE_URL}/api/v1/auth/logout`, { method: 'POST', credentials: 'include' }),
googleUrl: `${API_BASE_URL}/api/v1/auth/google/start`,
}
export type KycDocument = {
id: string
document_type: string
original_filename: string | null
status: 'missing' | 'pending' | 'approved' | 'rejected'
review_note: string | null
uploaded_at: string | null
reviewed_at: string | null
}
export type KycApplication = {
id: string
pan_number: string | null
gst_number: string | null
status: string
completed: boolean
submitted_at: string | null
approved_at: string | null
documents: KycDocument[]
}
export type DltDocument = {
id: string
document_type: string
original_filename: string | null
status: 'missing' | 'pending' | 'approved' | 'rejected'
review_note: string | null
uploaded_at: string | null
reviewed_at: string | null
}
export type DltApplication = {
id: string
status: string
completed: boolean
submitted_at: string | null
approved_at: string | null
pe_tm_chain_binding_id: string
documents: DltDocument[]
}
export type ComplianceStatus = {
kyc_completed: boolean
dlt_completed: boolean
console_unlocked: boolean
}
async function jsonRequest<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
credentials: 'include',
headers: init?.body instanceof FormData ? init.headers : { 'Content-Type': 'application/json', ...init?.headers },
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Request failed')
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
export const kycApi = {
me: () => jsonRequest<KycApplication>('/api/v1/kyc/me'),
update: (pan_number: string, gst_number: string) =>
jsonRequest<KycApplication>('/api/v1/kyc/me', { method: 'PUT', body: JSON.stringify({ pan_number, gst_number }) }),
upload: (documentType: string, file: File) => {
const body = new FormData()
body.append('file', file)
return jsonRequest<KycApplication>(`/api/v1/kyc/documents/${documentType}`, { method: 'POST', body })
},
submit: () => jsonRequest<KycApplication>('/api/v1/kyc/submit', { method: 'POST' }),
templateUrl: `${API_BASE_URL}/api/v1/kyc/template`,
}
export const dltApi = {
me: () => jsonRequest<DltApplication>('/api/v1/dlt/me'),
loaUrl: `${API_BASE_URL}/api/v1/dlt/loa`,
upload: (documentType: string, file: File) => {
const body = new FormData()
body.append('file', file)
return jsonRequest<DltApplication>(`/api/v1/dlt/documents/${documentType}`, { method: 'POST', body })
},
confirmChainBinding: () =>
jsonRequest<DltApplication>('/api/v1/dlt/chain-binding/confirm', { method: 'POST' }),
submit: () => jsonRequest<DltApplication>('/api/v1/dlt/submit', { method: 'POST' }),
}
export const complianceApi = {
status: () => jsonRequest<ComplianceStatus>('/api/v1/compliance/status'),
}
export type AdminKycApplication = KycApplication & {
user: { id: string; name: string; email: string }
}
export type AdminDltApplication = DltApplication & {
user: { id: string; name: string; email: string }
}
export type SenderConfig = {
id: string
sender_id: string
sender_type: 'promotional' | 'transactional'
purpose: string
dlt_entity_id: string
status: 'pending' | 'approved' | 'rejected'
review_note: string | null
created_at: string
reviewed_at: string | null
}
export type AdminSenderConfig = SenderConfig & {
user: { id: string; name: string; email: string }
}
export type TemplateConfig = {
id: string
sender_config_id: string
sender_id: string
template_name: string
template_text: string
dlt_template_id: string
status: 'pending' | 'approved' | 'rejected'
review_note: string | null
created_at: string
reviewed_at: string | null
}
export const messagingApi = {
listSenders: () => jsonRequest<SenderConfig[]>('/api/v1/messaging/senders'),
createSender: (payload: {
sender_id: string
sender_type: string
purpose: string
dlt_entity_id: string
}) => jsonRequest<SenderConfig>('/api/v1/messaging/senders', { method: 'POST', body: JSON.stringify(payload) }),
listTemplates: () => jsonRequest<TemplateConfig[]>('/api/v1/messaging/templates'),
createTemplate: (payload: {
sender_config_id: string
template_name: string
template_text: string
dlt_template_id: string
}) => jsonRequest<TemplateConfig>('/api/v1/messaging/templates', { method: 'POST', body: JSON.stringify(payload) }),
send: (payload: {
sender_config_id: string
template_id: string
numbers: string
message: string
campaign_name?: string | null
}) => jsonRequest<ComposeSendResponse>('/api/v1/messaging/send', { method: 'POST', body: JSON.stringify(payload) }),
}
export type CampaignRunMode = 'manual' | 'on_upload' | 'scheduled'
export type CampaignCsvUpload = {
id: string
original_filename: string
total_rows: number
status: 'validated' | 'running' | 'completed' | 'failed'
run_started_at: string | null
run_completed_at: string | null
created_at: string
}
export type Campaign = {
id: string
name: string
sender_config_id: string
sender_id: string
sender_type: 'promotional' | 'transactional'
template_id: string
template_name: string
template_text: string
number_column: string
variable_columns: string[]
run_mode: CampaignRunMode
scheduled_at: string | null
status: string
created_at: string
updated_at: string
csv_upload_count: number
csv_uploads: CampaignCsvUpload[]
}
export const campaignsApi = {
list: () => jsonRequest<Campaign[]>('/api/v1/campaigns'),
get: (campaignId: string) => jsonRequest<Campaign>(`/api/v1/campaigns/${campaignId}`),
create: (payload: {
name: string
sender_config_id: string
template_id: string
number_column: string
variable_columns: string[]
run_mode: CampaignRunMode
scheduled_at?: string | null
}) => jsonRequest<Campaign>('/api/v1/campaigns', { method: 'POST', body: JSON.stringify(payload) }),
listCsvUploads: (campaignId: string) =>
jsonRequest<CampaignCsvUpload[]>(`/api/v1/campaigns/${campaignId}/csv`),
uploadCsv: (campaignId: string, file: File) => {
const body = new FormData()
body.append('file', file)
return jsonRequest<CampaignCsvUpload>(`/api/v1/campaigns/${campaignId}/csv`, { method: 'POST', body })
},
runAutomation: (campaignId: string) =>
jsonRequest<{ processed_uploads: number; note: string }>(`/api/v1/campaigns/${campaignId}/run`, { method: 'POST' }),
runCsv: (campaignId: string, uploadId: string) =>
jsonRequest<CampaignCsvUpload & { note?: string }>(
`/api/v1/campaigns/${campaignId}/csv/${uploadId}/run`,
{ method: 'POST' },
),
}
export type ComposeSendResultItem = {
to: string
success: boolean
job_id: string | null
message_id: string | null
error: string | null
}
export type ComposeSendResponse = {
success: boolean
campaign_name: string | null
sent: number
failed: number
sender_id: string
template_id: string
template_name: string
dlt_template_id: string
results: ComposeSendResultItem[]
}
export type ApiKey = {
id: string
name: string
key_prefix: string
created_at: string
last_used_at: string | null
revoked_at: string | null
status: 'active' | 'revoked'
}
export type ApiKeyWithToken = ApiKey & {
token: string
}
export const apiKeysApi = {
list: () => jsonRequest<ApiKey[]>('/api/v1/api-keys'),
create: (name: string) =>
jsonRequest<ApiKeyWithToken>('/api/v1/api-keys', { method: 'POST', body: JSON.stringify({ name }) }),
regenerate: (keyId: string) =>
jsonRequest<ApiKeyWithToken>(`/api/v1/api-keys/${keyId}/regenerate`, { method: 'POST' }),
revoke: (keyId: string) =>
fetch(`${API_BASE_URL}/api/v1/api-keys/${keyId}`, { method: 'DELETE', credentials: 'include' }).then((response) => {
if (!response.ok) throw new Error('Unable to revoke API key')
}),
}
export type MessageApiDoc = {
id: string
method: string
path: string
title: string
summary: string
authentication: {
type: string
header: string
format: string
alternate_header: string
}
request_body: Record<string, {
type: string
required: boolean
description: string
example?: string
}>
responses: Record<string, { description: string; example?: Record<string, unknown> }>
}
export type SendMessageResponse = {
success: boolean
job_id: string | null
message_id: string | null
to: string | null
sender_id: string
template_id: string
template_name: string
dlt_template_id: string
}
export const messagesApi = {
docs: () => jsonRequest<MessageApiDoc>('/api/v1/messages/docs/send'),
send: (apiKey: string, payload: {
sender_id: string
template_id: string
to: string
message: string
}) =>
fetch(`${API_BASE_URL}/api/v1/messages/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
}).then(async (response) => {
const body = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(typeof body?.detail === 'string' ? body.detail : 'SMS send request failed')
}
return body as SendMessageResponse
}),
}
export type UrmiOtpProject = {
id: string
project_name: string
message_label: string
app_type: 'web' | 'mobile'
app_url: string
purpose: string
status: 'pending' | 'approved' | 'rejected'
review_note: string | null
terms_accepted_at: string
created_at: string
reviewed_at: string | null
}
export type OtpTemplateDefinition = {
type: string
name: string
content: string
sample: string
uses: string[]
variables: string[]
}
export type UrmiOtpDocumentation = {
project: UrmiOtpProject
templates: OtpTemplateDefinition[]
api: {
method: string
path: string
implemented: boolean
summary: string
parameters: Record<string, string>
example: Record<string, unknown>
}
}
export type AdminUrmiOtpProject = UrmiOtpProject & {
user: { id: string; name: string; email: string }
}
export const urmiOtpApi = {
listProjects: () => jsonRequest<UrmiOtpProject[]>('/api/v1/urmiotp/projects'),
createProject: (payload: {
project_name: string
message_label: string
app_type: 'web' | 'mobile'
app_url: string
purpose: string
terms_accepted: boolean
}) => jsonRequest<UrmiOtpProject>('/api/v1/urmiotp/projects', {
method: 'POST',
body: JSON.stringify(payload),
}),
documentation: (projectId: string) =>
jsonRequest<UrmiOtpDocumentation>(`/api/v1/urmiotp/projects/${projectId}/documentation`),
generate: (payload: {
project_name: string
template_type: string
to: string
variables: { otp: string; validity_minutes: string }
}) => jsonRequest<{
success: boolean
log_id: string
project_name: string
template_type: string
to: string
message_preview: string
status: string
}>('/api/v1/urmiotp/generate', {
method: 'POST',
body: JSON.stringify(payload),
}),
}
export type PhoneNumber = {
id: string
phone_number: string
monthly_number_price: number
inbound_price: number
outbound_price: number
currency: string
status: 'available' | 'assigned'
acquisition_type: 'admin_assigned' | 'purchased' | null
assigned_at: string | null
created_at: string
}
export type IntegrationEndpoint = {
id: string
name: string
phone_number_id: string
phone_number: string
url: string
enabled: boolean
created_at: string
updated_at: string
signing_secret?: string
}
export type VoiceSocketEndpoint = IntegrationEndpoint
export type SmsDeliveryWebhook = IntegrationEndpoint & {
description: string | null
event: 'message.delivery.updated'
}
export type IntegrationsDocs = {
title: string
summary: string
relay: {
principle: string
latency: string
}
socket: {
title: string
description: string
events: string[]
authentication: {
headers: string[]
signature: string
replay_protection: string
}
}
webhook: {
title: string
description: string
event: string
statuses: string[]
authentication: {
headers: string[]
signature: string
}
example: Record<string, unknown>
}
api_authentication: {
header: string
format: string
note: string
}
endpoints: Array<{ method: string; path: string; description: string }>
}
type EndpointPayload = {
name: string
phone_number_id: string
url: string
description?: string | null
enabled?: boolean
}
export const integrationsApi = {
docs: () => jsonRequest<IntegrationsDocs>('/api/v1/integrations/docs'),
listSockets: () => jsonRequest<VoiceSocketEndpoint[]>('/api/v1/integrations/sockets'),
createSocket: (payload: EndpointPayload) =>
jsonRequest<VoiceSocketEndpoint>('/api/v1/integrations/sockets', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateSocket: (id: string, payload: Partial<EndpointPayload>) =>
jsonRequest<VoiceSocketEndpoint>(`/api/v1/integrations/sockets/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteSocket: (id: string) =>
jsonRequest<void>(`/api/v1/integrations/sockets/${id}`, { method: 'DELETE' }),
listWebhooks: () => jsonRequest<SmsDeliveryWebhook[]>('/api/v1/integrations/webhooks'),
createWebhook: (payload: EndpointPayload) =>
jsonRequest<SmsDeliveryWebhook>('/api/v1/integrations/webhooks', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateWebhook: (id: string, payload: Partial<EndpointPayload>) =>
jsonRequest<SmsDeliveryWebhook>(`/api/v1/integrations/webhooks/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteWebhook: (id: string) =>
jsonRequest<void>(`/api/v1/integrations/webhooks/${id}`, { method: 'DELETE' }),
testWebhook: (id: string) =>
jsonRequest<{ delivered: boolean; response_code: number }>(
`/api/v1/integrations/webhooks/${id}/test`,
{ method: 'POST' },
),
}
export type VoiceCall = {
id: string
direction: string
from: string
to: string
status: string
stream_id: string | null
call_id: string | null
bridge_mode: string
upstream: string | null
error_message: string | null
answered_at: string | null
ended_at: string | null
created_at: string
media_url?: string
}
export type VoiceApiDocs = {
title: string
summary: string
method: string
path: string
authentication: {
header: string
format: string
alternate_header?: string
}
request_body: Record<string, {
type: string
required: boolean
example: string
description?: string
}>
response_example: Record<string, unknown>
relay: {
principle: string
latency: string
}
outbound_flow: string[]
endpoints: Array<{ method: string; path: string; description: string }>
}
export const voiceApi = {
docs: () => jsonRequest<VoiceApiDocs>('/api/v1/voice/docs'),
createCall: (payload: { to: string; from: string; bridge_mode?: 'console' | 'upstream'; upstream?: string }) =>
jsonRequest<VoiceCall>('/api/v1/voice/calls', { method: 'POST', body: JSON.stringify(payload) }),
getCall: (callId: string) => jsonRequest<VoiceCall>(`/api/v1/voice/calls/${callId}`),
createCallWithApiKey: (
apiKey: string,
payload: { to: string; from: string; bridge_mode?: 'console' | 'upstream'; upstream?: string },
) =>
fetch(`${API_BASE_URL}/api/v1/voice/calls`, {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}).then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Request failed')
}
return response.json() as Promise<VoiceCall>
}),
}
export type AdminPhoneNumber = PhoneNumber & {
user: { id: string; name: string; email: string } | null
}
export const numbersApi = {
catalog: () => jsonRequest<PhoneNumber[]>('/api/v1/numbers/catalog'),
mine: () => jsonRequest<PhoneNumber[]>('/api/v1/numbers/mine'),
purchase: (numberId: string) =>
jsonRequest<PhoneNumber>(`/api/v1/numbers/${numberId}/purchase`, { method: 'POST' }),
}
export type AdminTemplateConfig = TemplateConfig & {
user: { id: string; name: string; email: string }
}
export const adminApi = {
me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'),
login: (username: string, password: string) =>
jsonRequest<{ username: string }>('/api/v1/admin/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
logout: () => fetch(`${API_BASE_URL}/api/v1/admin/logout`, { method: 'POST', credentials: 'include' }),
listKyc: () => jsonRequest<AdminKycApplication[]>('/api/v1/admin/compliance/kyc'),
reviewDocument: (applicationId: string, documentId: string, status: 'approved' | 'rejected', review_note?: string) =>
jsonRequest<AdminKycApplication>(`/api/v1/admin/compliance/kyc/${applicationId}/documents/${documentId}`, {
method: 'PATCH',
body: JSON.stringify({ status, review_note: review_note || null }),
}),
listDlt: () => jsonRequest<AdminDltApplication[]>('/api/v1/admin/compliance/dlt'),
reviewDltDocument: (applicationId: string, documentId: string, status: 'approved' | 'rejected', review_note?: string) =>
jsonRequest<AdminDltApplication>(`/api/v1/admin/compliance/dlt/${applicationId}/documents/${documentId}`, {
method: 'PATCH',
body: JSON.stringify({ status, review_note: review_note || null }),
}),
listSenders: () => jsonRequest<AdminSenderConfig[]>('/api/v1/admin/compliance/senders'),
reviewSender: (senderId: string, status: 'approved' | 'rejected', review_note?: string) =>
jsonRequest<AdminSenderConfig>(`/api/v1/admin/compliance/senders/${senderId}`, {
method: 'PATCH',
body: JSON.stringify({ status, review_note: review_note || null }),
}),
listTemplates: () => jsonRequest<AdminTemplateConfig[]>('/api/v1/admin/compliance/templates'),
reviewTemplate: (templateId: string, status: 'approved' | 'rejected', review_note?: string) =>
jsonRequest<AdminTemplateConfig>(`/api/v1/admin/compliance/templates/${templateId}`, {
method: 'PATCH',
body: JSON.stringify({ status, review_note: review_note || null }),
}),
listUrmiOtpProjects: () =>
jsonRequest<AdminUrmiOtpProject[]>('/api/v1/admin/compliance/urmiotp-projects'),
reviewUrmiOtpProject: (projectId: string, status: 'approved' | 'rejected', review_note?: string) =>
jsonRequest<AdminUrmiOtpProject>(`/api/v1/admin/compliance/urmiotp-projects/${projectId}`, {
method: 'PATCH',
body: JSON.stringify({ status, review_note: review_note || null }),
}),
listPhoneNumbers: () => jsonRequest<AdminPhoneNumber[]>('/api/v1/admin/phone-numbers'),
createPhoneNumber: (payload: {
phone_number: string
monthly_number_price: number
inbound_price: number
outbound_price: number
user_email?: string | null
}) => jsonRequest<AdminPhoneNumber>('/api/v1/admin/phone-numbers', {
method: 'POST',
body: JSON.stringify(payload),
}),
updatePhoneNumber: (numberId: string, payload: {
monthly_number_price?: number
inbound_price?: number
outbound_price?: number
user_email?: string | null
release?: boolean
}) => jsonRequest<AdminPhoneNumber>(`/api/v1/admin/phone-numbers/${numberId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
}
export type LogType = 'outbound' | 'inbound' | 'sms' | 'otp'
export type LogEntry = {
id: string
from?: string
to?: string
template?: string
otp_type?: string
project?: string
socket_name?: string
status?: string
time: string
}
export type LogsResponse = {
type: LogType
page: number
page_size: number
total: number
total_pages: number
from: string
to: string
items: LogEntry[]
}
export const logsApi = {
list: (params: { type: LogType; from: string; to: string; page?: number }) => {
const query = new URLSearchParams({
type: params.type,
from: params.from,
to: params.to,
page: String(params.page ?? 1),
})
return jsonRequest<LogsResponse>(`/api/v1/logs?${query.toString()}`)
},
}
export type AnalyticsPeriod = 'weekly' | 'monthly' | 'quarterly'
export type AnalyticsService = 'sms' | 'inbound' | 'outbound' | 'otp'
export type AnalyticsGroupBy = 'dlt_sender' | 'dlt_template' | 'did' | 'socket' | 'project' | 'otp_type'
export type UsageAnalytics = {
period: AnalyticsPeriod
service: AnalyticsService
group_by: AnalyticsGroupBy
from: string
to: string
totals: {
sms: number
inbound: number
outbound: number
otp: number
}
timeline: Array<{
label: string
period_start: string
sms: number
inbound: number
outbound: number
otp: number
}>
breakdown: Array<{
label: string
detail: string
count: number
}>
}
export const analyticsApi = {
usage: (params: { period: AnalyticsPeriod; service: AnalyticsService; group_by: AnalyticsGroupBy }) => {
const query = new URLSearchParams({
period: params.period,
service: params.service,
group_by: params.group_by,
})
return jsonRequest<UsageAnalytics>(`/api/v1/analytics/usage?${query.toString()}`)
},
}

13
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from './features/auth/AuthContext.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
)

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})