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