This commit is contained in:
parent
8726c931fa
commit
8c46d091ff
49 changed files with 1607 additions and 712 deletions
|
|
@ -19,7 +19,7 @@ export function AgentTemplateDocsPage() {
|
|||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading API documentation…</div>
|
||||
if (loading) return <div className="auth-loading">Loading sending instructions…</div>
|
||||
if (!docs) return <section className="panel"><p className="auth-error">{error}</p></section>
|
||||
|
||||
const requestExample = JSON.stringify(docs.api.example, null, 2)
|
||||
|
|
@ -42,19 +42,19 @@ export function AgentTemplateDocsPage() {
|
|||
<ArrowLeft size={14} /> Back to templates
|
||||
</button>
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">UrmiAI · {docs.template.slug}</span>
|
||||
<span className="eyebrow">WhatsApp · {docs.template.name}</span>
|
||||
<h1>{docs.template.name}</h1>
|
||||
<p>{docs.template.description}</p>
|
||||
</div>
|
||||
|
||||
<section className="panel otp-api-overview">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Send agent WhatsApp message</h2><p>{docs.api.summary}</p></div>
|
||||
<div><h2>Send this WhatsApp message</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>{API_BASE_URL}{docs.api.path}</code></div>
|
||||
<div className="explorer-field-row"><span>Address</span><code>{API_BASE_URL}{docs.api.path}</code></div>
|
||||
<div className="explorer-field-row">
|
||||
<span>Authentication</span>
|
||||
<span>Access</span>
|
||||
<code>{docs.api.authentication.header}: {docs.api.authentication.format}</code>
|
||||
</div>
|
||||
<div className="otp-parameter-list">
|
||||
|
|
@ -67,13 +67,13 @@ export function AgentTemplateDocsPage() {
|
|||
<button className="secondary-button" onClick={copyExample}><Copy size={13} /> {copied ? 'Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
<pre className="explorer-code">{requestExample}</pre>
|
||||
<div className="otp-code-heading"><strong>Example cURL</strong></div>
|
||||
<div className="otp-code-heading"><strong>Example request</strong></div>
|
||||
<pre className="explorer-code">{curlExample}</pre>
|
||||
</section>
|
||||
|
||||
<section className="panel agent-template-docs-preview">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Template preview</h2><p>Approved Meta template relayed by Urmi.</p></div>
|
||||
<div><h2>What the customer sees</h2><p>The approved WhatsApp message Urmi sends.</p></div>
|
||||
</div>
|
||||
<div className="agent-template-preview card">
|
||||
{docs.template.header_text && <strong>{docs.template.header_text}</strong>}
|
||||
|
|
@ -84,7 +84,7 @@ export function AgentTemplateDocsPage() {
|
|||
<ul className="agent-template-notes">
|
||||
{docs.notes.map((note) => <li key={note}>{note}</li>)}
|
||||
</ul>
|
||||
<Link to="/console/api-explorer" className="secondary-button">Try in API Explorer</Link>
|
||||
<Link to="/console/api-explorer" className="secondary-button">Try a live request</Link>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function TestModal({
|
|||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
<p className="agent-template-test-note">
|
||||
One console test per day. Urmi sends from the Urmi AI WhatsApp business number on your behalf.
|
||||
One test per day. Urmi sends it from the Urmi WhatsApp business number.
|
||||
</p>
|
||||
<div className="otp-project-form">
|
||||
<label>
|
||||
|
|
@ -126,16 +126,15 @@ export function AgentTemplatesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading agent templates…</div>
|
||||
if (loading) return <div className="auth-loading">Loading WhatsApp messages…</div>
|
||||
|
||||
return (
|
||||
<div className="agent-templates-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Agent · WhatsApp</span>
|
||||
<h1><Bot size={24} /> UrmiAI agent messages</h1>
|
||||
<span className="eyebrow">Products</span>
|
||||
<h1><Bot size={24} /> WhatsApp messages</h1>
|
||||
<p>
|
||||
Subscribe to approved WhatsApp agent templates. Urmi authenticates your API key and sends messages
|
||||
from the Urmi AI business number on behalf of your agents.
|
||||
Turn on the WhatsApp templates you want to send. Urmi delivers them from the Urmi business number once your access key is accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -143,7 +142,7 @@ export function AgentTemplatesPage() {
|
|||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Available templates</h2>
|
||||
<p>Subscribe to enable API access and console testing.</p>
|
||||
<p>Turn one on to send it and try it from this workspace.</p>
|
||||
</div>
|
||||
<Code2 size={19} />
|
||||
</div>
|
||||
|
|
@ -169,7 +168,7 @@ export function AgentTemplatesPage() {
|
|||
<div className="agent-template-row" key={template.id}>
|
||||
<div>
|
||||
<strong>{template.name}</strong>
|
||||
<small><code>{template.slug}</code> · Meta: {template.meta_template_name}</small>
|
||||
<small>{template.slug} · WhatsApp: {template.meta_template_name}</small>
|
||||
<small>{template.description}</small>
|
||||
</div>
|
||||
<div className="agent-template-preview">
|
||||
|
|
@ -188,7 +187,7 @@ export function AgentTemplatesPage() {
|
|||
</div>
|
||||
<div>
|
||||
<span className={`doc-status ${template.subscribed ? 'approved' : 'pending'}`}>
|
||||
{template.subscribed ? 'Subscribed' : 'Not subscribed'}
|
||||
{template.subscribed ? 'On' : 'Off'}
|
||||
</span>
|
||||
{template.subscribed && status && !status.allowed && status.retry_after && (
|
||||
<small>Test again after {new Date(status.retry_after).toLocaleString()}</small>
|
||||
|
|
@ -199,7 +198,7 @@ export function AgentTemplatesPage() {
|
|||
className={template.subscribed ? 'secondary-button' : 'primary-button'}
|
||||
onClick={() => toggleSubscribe(template)}
|
||||
>
|
||||
{template.subscribed ? 'Unsubscribe' : 'Subscribe'}
|
||||
{template.subscribed ? 'Turn off' : 'Turn on'}
|
||||
</button>
|
||||
{template.subscribed && (
|
||||
<>
|
||||
|
|
@ -208,13 +207,13 @@ export function AgentTemplatesPage() {
|
|||
disabled={!canTest}
|
||||
onClick={() => setTestTemplate(template)}
|
||||
>
|
||||
<Play size={14} /> Test API
|
||||
<Play size={14} /> Send a test
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(`/console/agent/urmiai/templates/${template.slug}/docs`)}
|
||||
>
|
||||
View docs <ArrowRight size={14} />
|
||||
How to send <ArrowRight size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -231,16 +230,16 @@ export function AgentTemplatesPage() {
|
|||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>How it works</h2>
|
||||
<p>Urmi acts as your WhatsApp tech provider middle layer.</p>
|
||||
<p>Urmi sends the WhatsApp message for you.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="relay-steps">
|
||||
<li>Subscribe to a template in this console.</li>
|
||||
<li>Create an API key under Develop → API Keys.</li>
|
||||
<li>Call <code>POST /api/v1/agent/messages/send</code> with your Urmi API key.</li>
|
||||
<li>Urmi validates your subscription and relays the message to Meta WhatsApp Cloud API.</li>
|
||||
<li>Turn on a template on this page.</li>
|
||||
<li>Create an access key under Connections → Access keys.</li>
|
||||
<li>Your system sends the message with that key.</li>
|
||||
<li>Urmi checks the template and delivers it on WhatsApp.</li>
|
||||
</ol>
|
||||
<Link to="/console/api-explorer" className="secondary-button">Open API Explorer</Link>
|
||||
<Link to="/console/api-explorer" className="secondary-button">See a ready-made example</Link>
|
||||
</section>
|
||||
|
||||
{testTemplate && (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function UrmiOtpDocsPage() {
|
|||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading API documentation…</div>
|
||||
if (loading) return <div className="auth-loading">Loading sending instructions…</div>
|
||||
if (!docs) return <section className="panel"><p className="auth-error">{error}</p></section>
|
||||
|
||||
const requestExample = JSON.stringify(docs.api.example, null, 2)
|
||||
|
|
@ -35,8 +35,8 @@ export function UrmiOtpDocsPage() {
|
|||
<ArrowLeft size={14} /> Back to projects
|
||||
</button>
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">UrmiOTP · {docs.project.project_name}</span>
|
||||
<h1>API documentation</h1>
|
||||
<span className="eyebrow">Verification codes · {docs.project.project_name}</span>
|
||||
<h1>How to send a code</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>
|
||||
|
|
@ -46,15 +46,14 @@ export function UrmiOtpDocsPage() {
|
|||
|
||||
<section className="panel otp-api-overview">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Generate verification OTP</h2><p>{docs.api.summary}</p></div>
|
||||
<div><h2>Send a verification code</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="explorer-field-row"><span>Address</span><code>{docs.api.path}</code></div>
|
||||
<div className="otp-api-notice">
|
||||
<Code2 size={16} />
|
||||
<span>
|
||||
SMS delivery uses the DLT-approved URMI OTP template. <code>template_type</code> is for
|
||||
categorization; the sent text matches <code>message_preview</code> in the API response.
|
||||
Customers receive the approved verification text. The type you choose only groups the request. The message they see matches the preview.
|
||||
</span>
|
||||
</div>
|
||||
<div className="otp-parameter-list">
|
||||
|
|
|
|||
|
|
@ -168,9 +168,9 @@ export function UrmiOtpPage() {
|
|||
<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>
|
||||
<span className="eyebrow">Products</span>
|
||||
<h1>Verification codes</h1>
|
||||
<p>Register each product that sends login and security codes. Approved projects can send from a shared set of templates.</p>
|
||||
</div>
|
||||
<button className="primary-button" onClick={() => setModalOpen(true)}><Plus size={15} /> Add a project</button>
|
||||
</div>
|
||||
|
|
@ -179,7 +179,7 @@ export function UrmiOtpPage() {
|
|||
|
||||
<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>
|
||||
<div><h2>Your projects</h2><p>Each product is reviewed before you can send codes from it.</p></div>
|
||||
<Smartphone size={19} />
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
|
|
@ -212,10 +212,10 @@ export function UrmiOtpPage() {
|
|||
className="secondary-button"
|
||||
onClick={() => navigate(`/console/agent/urmiotp/projects/${project.id}/docs`)}
|
||||
>
|
||||
API documentation <ArrowRight size={14} />
|
||||
How to send codes <ArrowRight size={14} />
|
||||
</button>
|
||||
) : project.status === 'pending' ? (
|
||||
<span className="otp-awaiting"><CheckCircle2 size={13} /> Awaiting admin review</span>
|
||||
<span className="otp-awaiting"><CheckCircle2 size={13} /> Waiting for review</span>
|
||||
) : (
|
||||
<button className="secondary-button" onClick={() => setModalOpen(true)}>Register another</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -59,18 +59,18 @@ function GroupModal({
|
|||
</div>
|
||||
<div className="otp-project-form">
|
||||
<label>
|
||||
<span>Report group</span>
|
||||
<span>Group name</span>
|
||||
<input
|
||||
value={reportGroup}
|
||||
onChange={(event) => setReportGroup(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder="acme-agent-runs"
|
||||
placeholder="northwind-reports"
|
||||
required
|
||||
/>
|
||||
<small>Used in URLs: urmiai.com/reports/{'{report_group}'}/{'{report_id}'}</small>
|
||||
<small>Links look like urmiai.com/reports/your-group/report-name</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name (optional)</span>
|
||||
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Acme agent runs" />
|
||||
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Northwind reports" />
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Default access</legend>
|
||||
|
|
@ -84,9 +84,9 @@ function GroupModal({
|
|||
</label>
|
||||
)}
|
||||
<fieldset>
|
||||
<legend>Report ID mode</legend>
|
||||
<label><input type="radio" checked={idMode === 'auto'} onChange={() => setIdMode('auto')} /> Auto-generate postfix</label>
|
||||
<label><input type="radio" checked={idMode === 'manual'} onChange={() => setIdMode('manual')} /> Require report_id in API</label>
|
||||
<legend>How reports are named</legend>
|
||||
<label><input type="radio" checked={idMode === 'auto'} onChange={() => setIdMode('auto')} /> Urmi names each report</label>
|
||||
<label><input type="radio" checked={idMode === 'manual'} onChange={() => setIdMode('manual')} /> You choose the name when you publish</label>
|
||||
</fieldset>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</div>
|
||||
|
|
@ -136,9 +136,9 @@ export function UrmiportPage() {
|
|||
<div className="urmiport-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Agent</span>
|
||||
<h1><FileText size={24} /> Urmiport</h1>
|
||||
<p>Host HTML reports at urmiai.com/reports/{'{report_group}'}/{'{report_id}'} — no Urmi branding on the public page.</p>
|
||||
<span className="eyebrow">Products</span>
|
||||
<h1><FileText size={24} /> Shared reports</h1>
|
||||
<p>Publish a page for each report and share the link. The public page does not show Urmi branding.</p>
|
||||
</div>
|
||||
<button className="primary-button" onClick={() => setShowModal(true)}>
|
||||
<Plus size={16} /> New report group
|
||||
|
|
@ -151,7 +151,7 @@ export function UrmiportPage() {
|
|||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Report groups</h2>
|
||||
<p>Each group is a namespace for hosted reports. Publish HTML via the API below.</p>
|
||||
<p>Each group keeps related reports together. Publish a page from the section below.</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
|
|
@ -163,9 +163,9 @@ export function UrmiportPage() {
|
|||
<div className="urmiport-group-table-head">
|
||||
<span>Group</span>
|
||||
<span>Access</span>
|
||||
<span>ID mode</span>
|
||||
<span>Naming</span>
|
||||
<span>Reports</span>
|
||||
<span>URL pattern</span>
|
||||
<span>Link</span>
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
|
|
@ -193,17 +193,17 @@ export function UrmiportPage() {
|
|||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Reports in {selectedGroup}</h2>
|
||||
<p>Published via API. Same report_group + report_id updates the HTML body.</p>
|
||||
<p>Published from your systems. Sending the same group and name again updates the page.</p>
|
||||
</div>
|
||||
</div>
|
||||
{reportsLoading ? (
|
||||
<p className="panel-empty">Loading reports…</p>
|
||||
) : !reports.length ? (
|
||||
<p className="panel-empty">No reports yet. Publish one using the API docs below.</p>
|
||||
<p className="panel-empty">No reports yet. Use the section below to publish one.</p>
|
||||
) : (
|
||||
<div className="urmiport-report-table">
|
||||
<div className="urmiport-report-table-head">
|
||||
<span>Report ID</span>
|
||||
<span>Name</span>
|
||||
<span>Access</span>
|
||||
<span>Updated</span>
|
||||
<span>URL</span>
|
||||
|
|
@ -227,16 +227,16 @@ export function UrmiportPage() {
|
|||
<section className="panel urmiport-docs-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2><Code2 size={18} /> API documentation</h2>
|
||||
<h2><Code2 size={18} /> Publish from your systems</h2>
|
||||
<p>{docs.summary}</p>
|
||||
</div>
|
||||
<span className="explorer-method">{docs.method}</span>
|
||||
</div>
|
||||
<div className="urmiport-docs-grid">
|
||||
<div>
|
||||
<h3>Endpoint</h3>
|
||||
<h3>Where to publish</h3>
|
||||
<code>POST /api/v1/urmiport/reports</code>
|
||||
<h3>Authentication</h3>
|
||||
<h3>Access</h3>
|
||||
<p><code>{docs.authentication.header}: {docs.authentication.format}</code></p>
|
||||
<h3>Request body</h3>
|
||||
<ul className="agent-template-notes">
|
||||
|
|
@ -255,15 +255,15 @@ export function UrmiportPage() {
|
|||
<div>
|
||||
<h3>Example request</h3>
|
||||
<pre className="explorer-code">{JSON.stringify({
|
||||
report_group: 'acme-agent-runs',
|
||||
report_id: 'run-abc123',
|
||||
body: '<html><body><h1>Agent run complete</h1></body></html>',
|
||||
report_group: 'northwind',
|
||||
report_id: 'april-summary',
|
||||
body: '<html><body><h1>April summary</h1></body></html>',
|
||||
access: 'public',
|
||||
}, null, 2)}</pre>
|
||||
<h3>Example response</h3>
|
||||
<pre className="explorer-code">{JSON.stringify(docs.responses['200']?.example ?? {}, null, 2)}</pre>
|
||||
<Link to="/console/api-explorer" className="text-button">
|
||||
Try in API Explorer <ArrowRight size={14} />
|
||||
Try a live request <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,27 +24,27 @@ const PERIODS: Array<{ value: AnalyticsPeriod; label: string; hint: string }> =
|
|||
]
|
||||
|
||||
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' },
|
||||
{ value: 'sms', label: 'Text messages', hint: 'Messages sent from approved templates' },
|
||||
{ value: 'inbound', label: 'Calls received', hint: 'Calls to your numbers' },
|
||||
{ value: 'outbound', label: 'Calls placed', hint: 'Calls you placed' },
|
||||
{ value: 'otp', label: 'Verification codes', hint: 'Codes sent to customers' },
|
||||
]
|
||||
|
||||
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' },
|
||||
{ value: 'dlt_sender', label: 'Sender ID', hint: 'Approved sender name' },
|
||||
{ value: 'dlt_template', label: 'Template', hint: 'Message template' },
|
||||
],
|
||||
inbound: [
|
||||
{ value: 'did', label: 'DID number', hint: 'Which Urmi number received the call' },
|
||||
{ value: 'did', label: 'Phone number', hint: 'Which of your numbers 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' },
|
||||
{ value: 'socket', label: 'Call connection', hint: 'The name linked to a number' },
|
||||
{ value: 'did', label: 'Phone number', hint: 'Which of your numbers placed the call' },
|
||||
],
|
||||
otp: [
|
||||
{ value: 'project', label: 'OTP project', hint: 'UrmiOTP project name' },
|
||||
{ value: 'otp_type', label: 'OTP type', hint: 'Verification template type' },
|
||||
{ value: 'project', label: 'Project', hint: 'Verification project name' },
|
||||
{ value: 'otp_type', label: 'Code type', hint: 'The kind of verification' },
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -183,9 +183,9 @@ export function AnalyticsPage() {
|
|||
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>
|
||||
<span className="eyebrow">Insights</span>
|
||||
<h1>Performance</h1>
|
||||
<p>Choose messages, calls, or verification codes, then see the results by sender, number, or project.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel analytics-toolbar">
|
||||
|
|
@ -239,28 +239,28 @@ export function AnalyticsPage() {
|
|||
{error && <p className="auth-error">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="explorer-loading">Loading analytics…</p>
|
||||
<p className="explorer-loading">Loading performance…</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" />
|
||||
<SummaryCard label="Text messages" value={data.totals.sms} tone="sms" />
|
||||
<SummaryCard label="Calls received" value={data.totals.inbound} tone="inbound" />
|
||||
<SummaryCard label="Calls placed" value={data.totals.outbound} tone="outbound" />
|
||||
<SummaryCard label="Verification codes" 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>
|
||||
<h2>Everything over time</h2>
|
||||
<p>Texts, calls, and verification codes in the period you selected</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>
|
||||
<span><i style={{ background: SERVICE_COLORS.sms }} /> Texts</span>
|
||||
<span><i style={{ background: SERVICE_COLORS.inbound }} /> Received</span>
|
||||
<span><i style={{ background: SERVICE_COLORS.outbound }} /> Placed</span>
|
||||
<span><i style={{ background: SERVICE_COLORS.otp }} /> Codes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-wrap analytics-chart-wrap">
|
||||
|
|
@ -272,10 +272,10 @@ export function AnalyticsPage() {
|
|||
<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]} />
|
||||
<Bar dataKey="sms" name="Texts" fill={SERVICE_COLORS.sms} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="inbound" name="Calls received" fill={SERVICE_COLORS.inbound} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="outbound" name="Calls placed" fill={SERVICE_COLORS.outbound} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="otp" name="Codes" fill={SERVICE_COLORS.otp} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function AccountSettings() {
|
|||
<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>
|
||||
<div className="panel-heading"><div><h2>{user?.has_password ? 'Change password' : 'Add a password'}</h2><p>{user?.has_password ? 'Choose a new password for this account.' : 'You signed up with Google. Add a password if you also want to sign in with email.'}</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>
|
||||
|
|
|
|||
|
|
@ -46,12 +46,12 @@ export function AuthPage({ initialMode }: { initialMode: 'login' | 'register' })
|
|||
<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>
|
||||
<span>CUSTOMER MESSAGING</span>
|
||||
<h1>Messages your customers actually receive.</h1>
|
||||
<p>Text, WhatsApp, verification codes, and calls in one workspace, with the approvals Indian businesses need.</p>
|
||||
<div><span><Check size={15} /> SMS, WhatsApp, and phone</span><span><Check size={15} /> KYC and DLT in setup</span><span><Check size={15} /> Delivery you can check</span></div>
|
||||
</div>
|
||||
<small>Build → Test → Deploy → Observe</small>
|
||||
<small>Set up · Approve · Send · Review</small>
|
||||
</div>
|
||||
|
||||
<main className="auth-main">
|
||||
|
|
@ -62,8 +62,8 @@ export function AuthPage({ initialMode }: { initialMode: 'login' | 'register' })
|
|||
<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>
|
||||
<h2>{mode === 'login' ? 'Welcome back' : 'Create your workspace'}</h2>
|
||||
<p>{mode === 'login' ? 'Sign in to messages, calls, and your account.' : 'Set up Urmi and send your first message.'}</p>
|
||||
</div>
|
||||
|
||||
<a className="google-button" href={authApi.googleUrl}>
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export function CampaignDetailPage() {
|
|||
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Campaign · {campaign.name}</span>
|
||||
<h1>CSV uploads</h1>
|
||||
<h1>Recipient files</h1>
|
||||
<p>
|
||||
{campaign.sender_id} · {campaign.template_name} · number column <code>{campaign.number_column}</code>
|
||||
{campaign.variable_columns.length > 0 && (
|
||||
|
|
@ -100,7 +100,7 @@ export function CampaignDetailPage() {
|
|||
<section className="panel campaign-upload-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Upload CSV</h2>
|
||||
<h2>Upload a file</h2>
|
||||
<p>File is validated against <strong>{campaign.template_name}</strong> before it is stored.</p>
|
||||
</div>
|
||||
<Upload size={19} />
|
||||
|
|
@ -129,7 +129,7 @@ export function CampaignDetailPage() {
|
|||
|
||||
<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><h2>Uploaded files</h2><p>{uploads.length} file{uploads.length === 1 ? '' : 's'} in this campaign</p></div>
|
||||
</div>
|
||||
|
||||
{uploads.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -262,44 +262,44 @@ export function CampaignsPage() {
|
|||
return (
|
||||
<div className="campaigns-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Communicate</span>
|
||||
<span className="eyebrow">Messages</span>
|
||||
<h1>Campaigns</h1>
|
||||
<p>Define CSV automations linked to approved senders and templates. Upload and run when ready.</p>
|
||||
<p>Prepare a message for a list of customers. Upload the list when the sender and template are approved.</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>
|
||||
<h2>New campaign</h2>
|
||||
<p>Match the columns in your file to the template, then upload recipients.</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.
|
||||
Sending is not switched on yet. You can still save the campaign and upload a recipient file.
|
||||
</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>
|
||||
<div><h2>Your campaigns</h2><p>{campaigns.length} campaign{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>
|
||||
<span>Create one, match your file columns to the template, then upload recipients.</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>When to send</span>
|
||||
<span>Files</span>
|
||||
<span />
|
||||
</div>
|
||||
{campaigns.map((campaign) => (
|
||||
|
|
@ -328,7 +328,7 @@ export function CampaignsPage() {
|
|||
disabled={runningId === campaign.id || campaign.csv_upload_count === 0}
|
||||
onClick={() => runAutomation(campaign.id)}
|
||||
>
|
||||
<Play size={14} /> {runningId === campaign.id ? 'Running…' : 'Run automation'}
|
||||
<Play size={14} /> {runningId === campaign.id ? 'Running…' : 'Run campaign'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function ComplianceGate() {
|
|||
const { status, loading } = useCompliance()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) return <div className="auth-loading">Checking compliance status…</div>
|
||||
if (loading) return <div className="auth-loading">Checking your approvals…</div>
|
||||
|
||||
const unlocked = status?.console_unlocked ?? false
|
||||
const allowed = COMPLIANCE_ONLY_PATHS.includes(location.pathname) || location.pathname === '/console/account'
|
||||
|
|
|
|||
|
|
@ -139,15 +139,15 @@ export function DltPage() {
|
|||
return (
|
||||
<div className="kyc-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Compliance</span>
|
||||
<span className="eyebrow">Get ready</span>
|
||||
<h1>DLT registration</h1>
|
||||
<p>Complete DLT compliance steps for telecom registry verification.</p>
|
||||
<p>Finish the telecom registration so your sender IDs and templates can be approved.</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><strong>DLT completed</strong><span>Your registration documents are approved.</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -155,15 +155,15 @@ export function DltPage() {
|
|||
<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>
|
||||
<strong>Finish DLT to open the rest of your workspace</strong>
|
||||
<span>Until this is approved, only Business KYC and DLT registration are available.</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>
|
||||
<div><h2>Documents we need</h2><p>Upload proof of directorship and confirm your chain binding.</p></div>
|
||||
<Upload size={20} />
|
||||
</div>
|
||||
{dlt?.documents.map((doc) => (
|
||||
|
|
|
|||
|
|
@ -113,21 +113,21 @@ export function KycPage() {
|
|||
return (
|
||||
<div className="kyc-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Compliance</span>
|
||||
<span className="eyebrow">Get ready</span>
|
||||
<h1>Business KYC</h1>
|
||||
<p>Upload your business documents for telecom compliance verification.</p>
|
||||
<p>Share your business details so we can verify the account before messages go out.</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><strong>KYC completed</strong><span>Your business documents are approved.</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>
|
||||
<div className="panel-heading"><div><h2>Business details</h2><p>Add PAN and GST before you upload 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>
|
||||
|
|
@ -138,7 +138,7 @@ export function KycPage() {
|
|||
</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>
|
||||
<div className="panel-heading"><div><h2>KYC form</h2><p>Download the form, sign it, and upload it with your documents.</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>
|
||||
|
|
@ -162,7 +162,7 @@ export function KycPage() {
|
|||
</div>
|
||||
))}
|
||||
{kyc?.status !== 'approved' && (
|
||||
<button className="primary-button" onClick={submitForReview}>Submit for compliance review</button>
|
||||
<button className="primary-button" onClick={submitForReview}>Submit for review</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {
|
||||
ArrowRight, Check, ChevronRight, Clock3, Code2, MessageSquareText,
|
||||
Activity, ArrowRight, Check, ChevronRight, Clock3, MessageSquareText,
|
||||
Phone, Plus, Send, ShieldCheck, Sparkles, TrendingUp, TriangleAlert,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
|
@ -29,6 +29,13 @@ function formatCount(value: number) {
|
|||
return value.toLocaleString('en-IN')
|
||||
}
|
||||
|
||||
function greeting() {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Good morning'
|
||||
if (hour < 17) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string) {
|
||||
const diffMs = Date.now() - new Date(value).getTime()
|
||||
const minutes = Math.max(1, Math.round(diffMs / 60_000))
|
||||
|
|
@ -54,10 +61,10 @@ export function Dashboard() {
|
|||
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' },
|
||||
{ icon: Phone, title: 'Call completed', detail: '+91 98765 43210 · 2 min 14 sec', time: '2 min ago', tone: 'blue' },
|
||||
{ icon: MessageSquareText, title: 'Text message delivered', detail: 'Order update · +91 99876 54321', time: '6 min ago', tone: 'cyan' },
|
||||
{ icon: ShieldCheck, title: 'Message template approved', detail: 'Login code · URMIOT', time: '18 min ago', tone: 'violet' },
|
||||
{ icon: TriangleAlert, title: 'Delivery update retried', detail: 'Checkout alerts · second try', time: '31 min ago', tone: 'amber' },
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -83,10 +90,10 @@ export function Dashboard() {
|
|||
}, [])
|
||||
|
||||
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' },
|
||||
{ label: 'Calls', value: formatCount(metrics.voice_calls), change: '+8.4%', hint: 'vs. last week', icon: Phone, tone: 'blue' },
|
||||
{ label: 'Texts sent', value: formatCount(metrics.messages_sent), change: '+12.2%', hint: 'vs. last week', icon: MessageSquareText, tone: 'cyan' },
|
||||
{ label: 'Delivered', value: `${metrics.delivery_rate}%`, change: '+2.1%', hint: 'vs. last week', icon: Send, tone: 'violet' },
|
||||
{ label: 'Activity', value: formatCount(metrics.api_requests), change: '+5.8%', hint: 'vs. last week', icon: Activity, tone: 'amber' },
|
||||
], [metrics])
|
||||
|
||||
const firstName = user?.name.split(' ')[0] ?? 'there'
|
||||
|
|
@ -96,8 +103,8 @@ export function 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>
|
||||
<h1>{greeting()}, {firstName} <span>👋</span></h1>
|
||||
<p>A snapshot of calls, messages, and delivery today.</p>
|
||||
</div>
|
||||
<div className="welcome-actions">
|
||||
<button className="secondary-button"><Clock3 size={16} /> Last 7 days</button>
|
||||
|
|
@ -125,8 +132,8 @@ export function Dashboard() {
|
|||
<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><h2>Calls and texts</h2><p>The last seven days</p></div>
|
||||
<div className="legend"><span><i className="calls-dot" /> Calls</span><span><i className="messages-dot" /> Texts</span></div>
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
|
@ -147,15 +154,15 @@ export function Dashboard() {
|
|||
</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>
|
||||
<div className="panel-heading"><div><h2>Worth a look</h2><p>What needs attention</p></div><Sparkles size={18} /></div>
|
||||
<div className="insight success"><span><Check size={16} /></span><div><strong>More messages are getting through</strong><p>Delivery is up 2.1% from last week</p></div></div>
|
||||
<div className="insight info"><span><TrendingUp size={16} /></span><div><strong>Busiest calling hour: 10–11 AM</strong><p>Customers are reaching you faster</p></div></div>
|
||||
<div className="insight warning"><span><TriangleAlert size={16} /></span><div><strong>3 message templates need a review</strong><p>Update them before they expire</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="panel-heading"><div><h2>Recent activity</h2><p>Latest calls, messages, and approvals</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}`}>
|
||||
|
|
@ -168,16 +175,16 @@ export function Dashboard() {
|
|||
</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>
|
||||
<div className="panel-heading"><div><h2>Approvals</h2><p>Ready to message customers in India</p></div><span className="ready-badge">Ready</span></div>
|
||||
{[
|
||||
['Business KYC', 'Approved'],
|
||||
['DLT principal entity', 'Approved'],
|
||||
['Headers', '3 approved'],
|
||||
['Content templates', '12 approved'],
|
||||
['DLT registration', 'Approved'],
|
||||
['Sender IDs', '3 approved'],
|
||||
['Message 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>
|
||||
<button className="secondary-button full-width">Review approvals</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function ExplorerGroup({ title, children }: { title: string; children: ReactNode
|
|||
function RelayCallout({ principle, latency }: { principle: string; latency: string }) {
|
||||
return (
|
||||
<div className="relay-callout">
|
||||
<strong>Transparent relay — minimum latency</strong>
|
||||
<strong>Calls go through as they happen</strong>
|
||||
<p>{principle}</p>
|
||||
<p>{latency}</p>
|
||||
</div>
|
||||
|
|
@ -74,8 +74,8 @@ function normalizeVoiceDocs(raw: Partial<VoiceApiDocs> | null): VoiceApiDocs | n
|
|||
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.',
|
||||
'Connect the number for live call audio under Delivery updates.',
|
||||
'Place the call from your system with the from and to numbers.',
|
||||
],
|
||||
endpoints: raw.endpoints ?? [
|
||||
{ method: 'POST', path: '/api/v1/voice/calls', description: 'Place an outbound call' },
|
||||
|
|
@ -87,12 +87,12 @@ function normalizeVoiceDocs(raw: Partial<VoiceApiDocs> | null): VoiceApiDocs | n
|
|||
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.',
|
||||
title: raw.title ?? 'Delivery updates',
|
||||
summary: raw.summary ?? 'Tell Urmi where to send call audio and text updates for each number.',
|
||||
relay: raw.relay ?? DEFAULT_RELAY,
|
||||
socket: raw.socket ?? {
|
||||
title: 'Voice sockets',
|
||||
description: 'Link a WebSocket endpoint to a number for live call audio.',
|
||||
title: 'Live call audio',
|
||||
description: 'Connect a secure address so call audio can reach your systems.',
|
||||
events: ['connected', 'start', 'media', 'end'],
|
||||
authentication: {
|
||||
headers: ['X-Urmi-Endpoint-Id', 'X-Urmi-Timestamp', 'X-Urmi-Signature'],
|
||||
|
|
@ -101,8 +101,8 @@ function normalizeIntegrationDocs(raw: Partial<IntegrationsDocs> | null): Integr
|
|||
},
|
||||
},
|
||||
webhook: raw.webhook ?? {
|
||||
title: 'SMS delivery webhooks',
|
||||
description: 'Receive SMS delivery status updates at your HTTPS endpoint.',
|
||||
title: 'Text delivery updates',
|
||||
description: 'Receive a secure update when a text message is delivered.',
|
||||
event: 'message.delivery.updated',
|
||||
statuses: ['queued', 'sent', 'delivered', 'undelivered', 'failed'],
|
||||
authentication: {
|
||||
|
|
@ -114,7 +114,7 @@ function normalizeIntegrationDocs(raw: Partial<IntegrationsDocs> | null): Integr
|
|||
api_authentication: raw.api_authentication ?? {
|
||||
header: 'Authorization',
|
||||
format: 'Bearer urmi_live_<your_api_key>',
|
||||
note: 'Use API keys from your server only.',
|
||||
note: 'Keep access keys on your own systems. Do not put them in a public page.',
|
||||
},
|
||||
endpoints: raw.endpoints ?? [],
|
||||
}
|
||||
|
|
@ -149,7 +149,7 @@ export function ApiExplorerPage() {
|
|||
const [urmiportDoc, setUrmiportDoc] = useState<UrmiportApiDoc | null>(null)
|
||||
const [urmiportGroup, setUrmiportGroup] = useState('')
|
||||
const [urmiportReportId, setUrmiportReportId] = useState('')
|
||||
const [urmiportBody, setUrmiportBody] = useState('<html><body><h1>Agent run complete</h1><p>All checks passed.</p></body></html>')
|
||||
const [urmiportBody, setUrmiportBody] = useState('<html><body><h1>April summary</h1><p>Orders delivered this week: 128.</p></body></html>')
|
||||
const [urmiportAccess, setUrmiportAccess] = useState('public')
|
||||
const [urmiportPassword, setUrmiportPassword] = useState('')
|
||||
const [urmiportResponse, setUrmiportResponse] = useState<UrmiportPublishResponse | null>(null)
|
||||
|
|
@ -177,12 +177,12 @@ export function ApiExplorerPage() {
|
|||
.then((results) => {
|
||||
const [smsResult, agentResult, urmiportResult, voiceResult, integrationResult, numbersResult] = results
|
||||
if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value)
|
||||
if (agentResult.status === 'fulfilled') {
|
||||
if (agentResult.status === 'fulfilled' && agentResult.value.request_body) {
|
||||
setAgentDoc(agentResult.value)
|
||||
setAgentTemplate(agentResult.value.request_body.template?.example ?? 'agent_run_completion')
|
||||
setAgentReportGroup(agentResult.value.request_body.report_group?.example ?? 'acme-agent-runs')
|
||||
}
|
||||
if (urmiportResult.status === 'fulfilled') {
|
||||
if (urmiportResult.status === 'fulfilled' && urmiportResult.value.request_body) {
|
||||
setUrmiportDoc(urmiportResult.value)
|
||||
setUrmiportGroup(urmiportResult.value.request_body.report_group?.example ?? 'acme-agent-runs')
|
||||
}
|
||||
|
|
@ -196,7 +196,7 @@ export function ApiExplorerPage() {
|
|||
}
|
||||
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.')
|
||||
setLoadError('These examples could not be loaded. Check that Urmi is available.')
|
||||
} else if (failures.length > 0) {
|
||||
setLoadError('Some documentation sections could not be loaded.')
|
||||
}
|
||||
|
|
@ -207,10 +207,10 @@ export function ApiExplorerPage() {
|
|||
const smsCurl = useMemo(() => {
|
||||
if (!smsDoc) return ''
|
||||
const body = JSON.stringify({
|
||||
sender_id: senderId || smsDoc.request_body.sender_id.example,
|
||||
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,
|
||||
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}' \\`,
|
||||
|
|
@ -223,9 +223,9 @@ export function ApiExplorerPage() {
|
|||
const agentCurl = useMemo(() => {
|
||||
if (!agentDoc) return ''
|
||||
const body = JSON.stringify({
|
||||
template: agentTemplate || agentDoc.request_body.template.example,
|
||||
to: agentTo || agentDoc.request_body.to.example,
|
||||
customer_name: agentCustomerName || agentDoc.request_body.customer_name.example,
|
||||
template: agentTemplate || agentDoc.request_body.template?.example || '',
|
||||
to: agentTo || agentDoc.request_body.to?.example || '',
|
||||
customer_name: agentCustomerName || agentDoc.request_body.customer_name?.example || '',
|
||||
report_group: agentReportGroup || agentDoc.request_body.report_group?.example || 'acme-agent-runs',
|
||||
report_id: agentReportId || agentDoc.request_body.report_id?.example,
|
||||
}, null, 2)
|
||||
|
|
@ -240,7 +240,7 @@ export function ApiExplorerPage() {
|
|||
const urmiportCurl = useMemo(() => {
|
||||
if (!urmiportDoc) return ''
|
||||
const body = JSON.stringify({
|
||||
report_group: urmiportGroup || urmiportDoc.request_body.report_group.example,
|
||||
report_group: urmiportGroup || urmiportDoc.request_body.report_group?.example || '',
|
||||
report_id: urmiportReportId || urmiportDoc.request_body.report_id?.example,
|
||||
body: urmiportBody,
|
||||
access: urmiportAccess,
|
||||
|
|
@ -257,8 +257,8 @@ export function ApiExplorerPage() {
|
|||
const voiceCurl = useMemo(() => {
|
||||
if (!voiceDoc) return ''
|
||||
const body = JSON.stringify({
|
||||
to: callTo || voiceDoc.request_body.to.example,
|
||||
from: fromDid || voiceDoc.request_body.from.example,
|
||||
to: callTo || voiceDoc.request_body.to?.example || '',
|
||||
from: fromDid || voiceDoc.request_body.from?.example || '',
|
||||
bridge_mode: 'upstream',
|
||||
}, null, 2)
|
||||
return [
|
||||
|
|
@ -356,7 +356,7 @@ export function ApiExplorerPage() {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="api-explorer-page">
|
||||
<p className="explorer-loading">Loading API explorer…</p>
|
||||
<p className="explorer-loading">Loading examples…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -364,10 +364,10 @@ export function ApiExplorerPage() {
|
|||
return (
|
||||
<div className="api-explorer-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Develop</span>
|
||||
<h1>API Explorer</h1>
|
||||
<span className="eyebrow">Connections</span>
|
||||
<h1>Try a request</h1>
|
||||
</div>
|
||||
<p className="auth-error">{loadError || 'Unable to load API docs.'}</p>
|
||||
<p className="auth-error">{loadError || 'These examples could not be loaded.'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -375,15 +375,15 @@ export function ApiExplorerPage() {
|
|||
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>
|
||||
<span className="eyebrow">Connections</span>
|
||||
<h1>Try a request</h1>
|
||||
<p>Fill in a form and send a live request, or copy an example for your own systems. Urmi delivers the message as you wrote it.</p>
|
||||
</div>
|
||||
|
||||
{loadError && <p className="relay-success integration-feedback">{loadError}</p>}
|
||||
|
||||
{smsDoc && (
|
||||
<ExplorerGroup title="Messaging">
|
||||
<ExplorerGroup title="Text messages">
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
|
|
@ -393,8 +393,8 @@ export function ApiExplorerPage() {
|
|||
<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}`} />
|
||||
<FieldRow label="Address" value={`${API_BASE_URL}${smsDoc.path}`} />
|
||||
<FieldRow label="Access" value={`${smsDoc.authentication.header}: ${smsDoc.authentication.format}`} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Request body</h4>
|
||||
|
|
@ -424,10 +424,10 @@ export function ApiExplorerPage() {
|
|||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>API key</h4>
|
||||
<h4>Access 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>
|
||||
<strong>Access key</strong>
|
||||
<span>Paste a key from Access keys. It is used only for this try.</span>
|
||||
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -437,7 +437,7 @@ export function ApiExplorerPage() {
|
|||
<Play size={15} /> {smsRunning ? 'Sending…' : 'Try request'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => copyCurl('sms')}>
|
||||
<Copy size={15} /> {copied === 'sms' ? 'Copied cURL' : 'Copy cURL'}
|
||||
<Copy size={15} /> {copied === 'sms' ? 'Copied' : 'Copy example'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -450,7 +450,7 @@ export function ApiExplorerPage() {
|
|||
)}
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example cURL</h4>
|
||||
<h4>Example request</h4>
|
||||
<pre className="explorer-code">{smsCurl}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -458,7 +458,7 @@ export function ApiExplorerPage() {
|
|||
)}
|
||||
|
||||
{agentDoc && (
|
||||
<ExplorerGroup title="Agent messages">
|
||||
<ExplorerGroup title="WhatsApp messages">
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
|
|
@ -468,8 +468,8 @@ export function ApiExplorerPage() {
|
|||
<span className="explorer-method">{agentDoc.method}</span>
|
||||
</div>
|
||||
|
||||
<FieldRow label="Endpoint" value={`${API_BASE_URL}${agentDoc.path}`} />
|
||||
<FieldRow label="Auth" value={`${agentDoc.authentication.header}: ${agentDoc.authentication.format}`} />
|
||||
<FieldRow label="Address" value={`${API_BASE_URL}${agentDoc.path}`} />
|
||||
<FieldRow label="Access" value={`${agentDoc.authentication.header}: ${agentDoc.authentication.format}`} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Request body</h4>
|
||||
|
|
@ -503,10 +503,10 @@ export function ApiExplorerPage() {
|
|||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>API key</h4>
|
||||
<h4>Access key</h4>
|
||||
<label className="explorer-input">
|
||||
<strong>Bearer token</strong>
|
||||
<span>Subscribe to the template in Agent → UrmiAI before testing.</span>
|
||||
<strong>Access key</strong>
|
||||
<span>Turn on the template under Products → WhatsApp messages before you try it.</span>
|
||||
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -516,7 +516,7 @@ export function ApiExplorerPage() {
|
|||
<Play size={15} /> {agentRunning ? 'Sending…' : 'Try request'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => copyCurl('agent')}>
|
||||
<Copy size={15} /> {copied === 'agent' ? 'Copied cURL' : 'Copy cURL'}
|
||||
<Copy size={15} /> {copied === 'agent' ? 'Copied' : 'Copy example'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -529,7 +529,7 @@ export function ApiExplorerPage() {
|
|||
)}
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example cURL</h4>
|
||||
<h4>Example request</h4>
|
||||
<pre className="explorer-code">{agentCurl}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -537,7 +537,7 @@ export function ApiExplorerPage() {
|
|||
)}
|
||||
|
||||
{urmiportDoc && (
|
||||
<ExplorerGroup title="Urmiport">
|
||||
<ExplorerGroup title="Shared reports">
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
|
|
@ -547,8 +547,8 @@ export function ApiExplorerPage() {
|
|||
<span className="explorer-method">{urmiportDoc.method}</span>
|
||||
</div>
|
||||
|
||||
<FieldRow label="Endpoint" value={`${API_BASE_URL}${urmiportDoc.path}`} />
|
||||
<FieldRow label="Auth" value={`${urmiportDoc.authentication.header}: ${urmiportDoc.authentication.format}`} />
|
||||
<FieldRow label="Address" value={`${API_BASE_URL}${urmiportDoc.path}`} />
|
||||
<FieldRow label="Access" value={`${urmiportDoc.authentication.header}: ${urmiportDoc.authentication.format}`} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Request body</h4>
|
||||
|
|
@ -556,15 +556,15 @@ export function ApiExplorerPage() {
|
|||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>report_group</strong>
|
||||
<span>{urmiportDoc.request_body.report_group.description}</span>
|
||||
<input value={urmiportGroup} onChange={(e) => setUrmiportGroup(e.target.value)} placeholder={urmiportDoc.request_body.report_group.example} />
|
||||
<span>{urmiportDoc.request_body.report_group?.description}</span>
|
||||
<input value={urmiportGroup} onChange={(e) => setUrmiportGroup(e.target.value)} placeholder={urmiportDoc.request_body.report_group?.example} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>report_id</strong>
|
||||
<span>{urmiportDoc.request_body.report_id.description}</span>
|
||||
<input value={urmiportReportId} onChange={(e) => setUrmiportReportId(e.target.value)} placeholder={urmiportDoc.request_body.report_id.example} />
|
||||
<span>{urmiportDoc.request_body.report_id?.description}</span>
|
||||
<input value={urmiportReportId} onChange={(e) => setUrmiportReportId(e.target.value)} placeholder={urmiportDoc.request_body.report_id?.example} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input explorer-input-wide">
|
||||
|
|
@ -588,8 +588,8 @@ export function ApiExplorerPage() {
|
|||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>password</strong>
|
||||
<span>{urmiportDoc.request_body.password.description}</span>
|
||||
<input type="password" value={urmiportPassword} onChange={(e) => setUrmiportPassword(e.target.value)} placeholder={urmiportDoc.request_body.password.example} />
|
||||
<span>{urmiportDoc.request_body.password?.description}</span>
|
||||
<input type="password" value={urmiportPassword} onChange={(e) => setUrmiportPassword(e.target.value)} placeholder={urmiportDoc.request_body.password?.example} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -597,10 +597,10 @@ export function ApiExplorerPage() {
|
|||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>API key</h4>
|
||||
<h4>Access key</h4>
|
||||
<label className="explorer-input">
|
||||
<strong>Bearer token</strong>
|
||||
<span>Create the report group in Agent → Urmiport before publishing.</span>
|
||||
<strong>Access key</strong>
|
||||
<span>Create the report group under Products → Shared reports before you publish.</span>
|
||||
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -610,7 +610,7 @@ export function ApiExplorerPage() {
|
|||
<Play size={15} /> {urmiportRunning ? 'Publishing…' : 'Try request'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => copyCurl('urmiport')}>
|
||||
<Copy size={15} /> {copied === 'urmiport' ? 'Copied cURL' : 'Copy cURL'}
|
||||
<Copy size={15} /> {copied === 'urmiport' ? 'Copied' : 'Copy example'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -623,7 +623,7 @@ export function ApiExplorerPage() {
|
|||
)}
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example cURL</h4>
|
||||
<h4>Example request</h4>
|
||||
<pre className="explorer-code">{urmiportCurl}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -631,7 +631,7 @@ export function ApiExplorerPage() {
|
|||
)}
|
||||
|
||||
{voiceDoc && integrationDocs && (
|
||||
<ExplorerGroup title="Numbers">
|
||||
<ExplorerGroup title="Phone calls">
|
||||
<RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} />
|
||||
|
||||
<section className="panel explorer-endpoint-card">
|
||||
|
|
@ -643,8 +643,8 @@ export function ApiExplorerPage() {
|
|||
<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}`} />
|
||||
<FieldRow label="Address" value={`${API_BASE_URL}${voiceDoc.path}`} />
|
||||
<FieldRow label="Access" value={`${voiceDoc.authentication.header}: ${voiceDoc.authentication.format}`} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>How outbound calls work</h4>
|
||||
|
|
@ -659,7 +659,7 @@ export function ApiExplorerPage() {
|
|||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>from</strong>
|
||||
<span>{voiceDoc.request_body.from.description}</span>
|
||||
<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) => (
|
||||
|
|
@ -671,23 +671,23 @@ export function ApiExplorerPage() {
|
|||
<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} />
|
||||
<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.
|
||||
Connect this number for live call audio first under{' '}
|
||||
<Link to="/console/webhooks">Delivery updates</Link>. Urmi passes the call audio through as it happens.
|
||||
</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'}
|
||||
<Phone size={15} /> {voiceRunning ? 'Dialing…' : 'Place a call'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => copyCurl('voice')}>
|
||||
<Copy size={15} /> {copied === 'voice' ? 'Copied cURL' : 'Copy cURL'}
|
||||
<Copy size={15} /> {copied === 'voice' ? 'Copied' : 'Copy example'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -700,12 +700,12 @@ export function ApiExplorerPage() {
|
|||
)}
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example cURL</h4>
|
||||
<h4>Example request</h4>
|
||||
<pre className="explorer-code">{voiceCurl}</pre>
|
||||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Related endpoints</h4>
|
||||
<h4>Related actions</h4>
|
||||
<ul className="relay-endpoints">
|
||||
{voiceDoc.endpoints.map((endpoint) => (
|
||||
<li key={endpoint.path}>
|
||||
|
|
@ -726,9 +726,9 @@ export function ApiExplorerPage() {
|
|||
<Radio size={18} />
|
||||
</div>
|
||||
|
||||
<p className="relay-note">Events relayed to your server: {integrationDocs.socket.events.join(', ')}.</p>
|
||||
<p className="relay-note">Updates sent to your systems: {integrationDocs.socket.events.join(', ')}.</p>
|
||||
<div className="explorer-section">
|
||||
<h4>Verify connections</h4>
|
||||
<h4>How we confirm it is you</h4>
|
||||
<p>{integrationDocs.socket.authentication.signature} {integrationDocs.socket.authentication.replay_protection}</p>
|
||||
<p>Headers: {integrationDocs.socket.authentication.headers.join(', ')}</p>
|
||||
</div>
|
||||
|
|
@ -740,7 +740,7 @@ export function ApiExplorerPage() {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="ghost-button integration-api-link" to="/console/webhooks">Configure voice sockets</Link>
|
||||
<Link className="ghost-button integration-api-link" to="/console/webhooks">Set up call audio</Link>
|
||||
</section>
|
||||
|
||||
<section className="panel explorer-endpoint-card">
|
||||
|
|
@ -756,7 +756,7 @@ export function ApiExplorerPage() {
|
|||
<FieldRow label="Statuses" value={integrationDocs.webhook.statuses.join(', ')} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example payload</h4>
|
||||
<h4>Example update</h4>
|
||||
<pre className="explorer-code">{JSON.stringify(integrationDocs.webhook.example, null, 2)}</pre>
|
||||
<p className="relay-note">{integrationDocs.webhook.authentication.signature}</p>
|
||||
</div>
|
||||
|
|
@ -768,7 +768,7 @@ export function ApiExplorerPage() {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="ghost-button integration-api-link" to="/console/webhooks">Configure delivery webhooks</Link>
|
||||
<Link className="ghost-button integration-api-link" to="/console/webhooks">Set up delivery updates</Link>
|
||||
</section>
|
||||
</ExplorerGroup>
|
||||
)}
|
||||
|
|
@ -776,8 +776,8 @@ export function ApiExplorerPage() {
|
|||
{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}`} />
|
||||
<h4>Access</h4>
|
||||
<FieldRow label="Sign-in" 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>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function TokenRevealModal({
|
|||
<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>
|
||||
<p className="api-key-warning">Copy this key now. It will not be shown again.</p>
|
||||
<div className="api-key-token-box">
|
||||
<code>{apiKey.token}</code>
|
||||
<button type="button" className="secondary-button" onClick={copyToken}>
|
||||
|
|
@ -63,7 +63,7 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||
onCreated(created)
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create API key')
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create access key')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
|
@ -73,12 +73,12 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||
<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>
|
||||
<h2>Create an access 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} />
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Billing system" maxLength={60} />
|
||||
</label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
|
|
@ -99,14 +99,14 @@ export function ApiKeysPage() {
|
|||
const [message, setMessage] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [revealedKey, setRevealedKey] = useState<ApiKeyWithToken | null>(null)
|
||||
const [revealTitle, setRevealTitle] = useState('Your new API key')
|
||||
const [revealTitle, setRevealTitle] = useState('Your new access key')
|
||||
|
||||
async function refresh() {
|
||||
setKeys(await apiKeysApi.list())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => setError('Unable to load API keys')).finally(() => setLoading(false))
|
||||
refresh().catch(() => setError('Unable to load access keys')).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function regenerate(keyId: string) {
|
||||
|
|
@ -116,10 +116,10 @@ export function ApiKeysPage() {
|
|||
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')
|
||||
setRevealTitle('Your new access key')
|
||||
setRevealedKey(regenerated)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to regenerate API key')
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to replace this access key')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,37 +129,37 @@ export function ApiKeysPage() {
|
|||
try {
|
||||
await apiKeysApi.revoke(keyId)
|
||||
setKeys((current) => current.filter((item) => item.id !== keyId))
|
||||
setMessage('API key revoked.')
|
||||
setMessage('Access key turned off.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to revoke API key')
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to turn off this access key')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreated(created: ApiKeyWithToken) {
|
||||
const { token: _token, ...keyMeta } = created
|
||||
setKeys((current) => [keyMeta, ...current])
|
||||
setRevealTitle('Your new API key')
|
||||
setRevealTitle('Your new access key')
|
||||
setRevealedKey(created)
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading API keys…</div>
|
||||
if (loading) return <div className="auth-loading">Loading access 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>
|
||||
<span className="eyebrow">Connections</span>
|
||||
<h1>Access keys</h1>
|
||||
<p>Create a key when another system needs to send messages or place calls for you. Copy it once and store it safely.</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>
|
||||
<div><h2>Your keys</h2><p>Each key is shown in full only when you create or replace it.</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>
|
||||
<p className="empty-copy">No access keys yet. Create one when you are ready to connect another system.</p>
|
||||
) : (
|
||||
keys.map((item) => (
|
||||
<div className="api-key-row" key={item.id}>
|
||||
|
|
|
|||
|
|
@ -107,14 +107,14 @@ export function WebhooksSocketsPage() {
|
|||
? await integrationsApi.createSocket(payload)
|
||||
: await integrationsApi.createWebhook(payload)
|
||||
setRevealedSecret(created.signing_secret ?? '')
|
||||
setNotice(`${tab === 'sockets' ? 'Voice socket' : 'Delivery webhook'} created.`)
|
||||
setNotice(`${tab === 'sockets' ? 'Call connection saved.' : 'Delivery update saved.'}`)
|
||||
await load()
|
||||
setName('')
|
||||
setUrl('')
|
||||
setDescription('')
|
||||
setNumberId('')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create endpoint')
|
||||
setError(reason instanceof Error ? reason.message : 'Could not save this connection.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ export function WebhooksSocketsPage() {
|
|||
}
|
||||
|
||||
async function remove(kind: Tab, id: string) {
|
||||
if (!window.confirm(`Delete this ${kind === 'sockets' ? 'voice socket' : 'webhook'}?`)) return
|
||||
if (!window.confirm(`Delete this ${kind === 'sockets' ? 'call connection' : 'delivery update'}?`)) return
|
||||
setError('')
|
||||
try {
|
||||
if (kind === 'sockets') {
|
||||
|
|
@ -143,7 +143,7 @@ export function WebhooksSocketsPage() {
|
|||
}
|
||||
setNotice('Endpoint deleted.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to delete endpoint')
|
||||
setError(reason instanceof Error ? reason.message : 'Could not delete this connection.')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ export function WebhooksSocketsPage() {
|
|||
window.setTimeout(() => setCopied(false), 1800)
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading integrations…</div>
|
||||
if (loading) return <div className="auth-loading">Loading…</div>
|
||||
|
||||
const emptyNumbers = numbers.length === 0
|
||||
const noSocketSlots = tab === 'sockets' && selectableNumbers.length === 0
|
||||
|
|
@ -172,17 +172,17 @@ export function WebhooksSocketsPage() {
|
|||
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>
|
||||
<span className="eyebrow">Connections</span>
|
||||
<h1>Delivery updates</h1>
|
||||
<p>Tell Urmi where to send call audio and text-message updates for each number. Examples are in <Link to="/console/api-explorer">Try a request</Link>.</p>
|
||||
</div>
|
||||
|
||||
<div className="integration-tabs" role="tablist">
|
||||
<button className={tab === 'sockets' ? 'is-active' : ''} onClick={() => switchTab('sockets')}>
|
||||
<Radio size={16} /> Voice sockets
|
||||
<Radio size={16} /> Live call audio
|
||||
</button>
|
||||
<button className={tab === 'webhooks' ? 'is-active' : ''} onClick={() => switchTab('webhooks')}>
|
||||
<MessageSquareText size={16} /> SMS delivery webhooks
|
||||
<MessageSquareText size={16} /> Text delivery updates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -205,11 +205,11 @@ export function WebhooksSocketsPage() {
|
|||
<section className="panel endpoint-list-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>{tab === 'sockets' ? 'Voice sockets' : 'SMS delivery webhooks'}</h2>
|
||||
<h2>{tab === 'sockets' ? 'Live call audio' : 'Text delivery updates'}</h2>
|
||||
<p>
|
||||
{tab === 'sockets'
|
||||
? 'One secure WebSocket per number. Urmi relays live audio with no processing.'
|
||||
: 'Named HTTPS endpoints for SMS delivery status updates.'}
|
||||
? 'One secure connection per number. Call audio is passed through as it happens.'
|
||||
: 'A secure address that receives updates when a text is delivered.'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="endpoint-count">{tab === 'sockets' ? sockets.length : webhooks.length}</span>
|
||||
|
|
@ -227,7 +227,7 @@ export function WebhooksSocketsPage() {
|
|||
<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)}>
|
||||
<button className="icon-button" aria-label="Remove call connection" onClick={() => void remove('sockets', endpoint.id)}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -239,7 +239,7 @@ export function WebhooksSocketsPage() {
|
|||
<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>
|
||||
<span>{formatPhone(endpoint.phone_number)} · Text delivery</span>
|
||||
<code>{endpoint.url}</code>
|
||||
{endpoint.description && <small>{endpoint.description}</small>}
|
||||
</div>
|
||||
|
|
@ -250,7 +250,7 @@ export function WebhooksSocketsPage() {
|
|||
<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)}>
|
||||
<button className="icon-button" aria-label="Remove delivery update" onClick={() => void remove('webhooks', endpoint.id)}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -259,8 +259,8 @@ export function WebhooksSocketsPage() {
|
|||
|
||||
{((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>
|
||||
<strong>No {tab === 'sockets' ? 'call connections' : 'delivery updates'} yet</strong>
|
||||
<span>Add one so this number can reach your systems.</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -268,7 +268,7 @@ export function WebhooksSocketsPage() {
|
|||
<form className="panel endpoint-create-panel" onSubmit={createEndpoint}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Add {tab === 'sockets' ? 'voice socket' : 'delivery webhook'}</h2>
|
||||
<h2>Add {tab === 'sockets' ? 'a call connection' : 'a delivery update'}</h2>
|
||||
<p>Name it clearly and link it to one of your numbers.</p>
|
||||
</div>
|
||||
<Plus size={18} />
|
||||
|
|
@ -277,36 +277,36 @@ export function WebhooksSocketsPage() {
|
|||
<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 />
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={tab === 'sockets' ? 'Support calls' : 'Order delivery updates'} required />
|
||||
</label>
|
||||
<label className="explorer-input">
|
||||
<strong>Phone number</strong>
|
||||
<span>Events for this number are sent to the endpoint.</span>
|
||||
<span>Updates for this number are sent to the address below.</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 />
|
||||
<strong>{tab === 'sockets' ? 'Call address' : 'Update address'}</strong>
|
||||
<span>{tab === 'sockets' ? 'Use a public wss:// address.' : 'Use a public https:// address.'}</span>
|
||||
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder={tab === 'sockets' ? 'wss://calls.yourcompany.com/live' : 'https://yourcompany.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" />
|
||||
<span>A short note so your team knows what this update is for.</span>
|
||||
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} placeholder="Updates order status when a text is delivered" />
|
||||
</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.'}
|
||||
{emptyNumbers ? 'Add a phone number before creating a connection.' : 'Every number already has a call connection.'}
|
||||
</p>
|
||||
)}
|
||||
<button className="primary-button" type="submit" disabled={saving || emptyNumbers || noSocketSlots}>
|
||||
<Plus size={15} /> {saving ? 'Creating…' : 'Create endpoint'}
|
||||
<Plus size={15} /> {saving ? 'Creating…' : 'Save connection'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ArrowRight, Bot, Braces, Check, MessageSquare, ShieldCheck, Zap } from 'lucide-react'
|
||||
import { ArrowRight, Check, MessageSquare, Phone, ShieldCheck } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function LandingPage() {
|
||||
|
|
@ -8,62 +8,74 @@ export function LandingPage() {
|
|||
<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>
|
||||
<a href="#how">How it works</a>
|
||||
<a href="#security">Trust</a>
|
||||
</nav>
|
||||
<div className="landing-actions">
|
||||
<Link to="/login">Log in</Link>
|
||||
<Link to="/register" className="landing-cta">Start building <ArrowRight size={15} /></Link>
|
||||
<Link to="/register" className="landing-cta">Get started <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>
|
||||
<span className="hero-pill"><i /> Customer messaging for Indian businesses</span>
|
||||
<h1>Reach customers by text, WhatsApp, and phone.</h1>
|
||||
<p>Send order updates, verification codes, and campaigns from one workspace. Urmi takes care of delivery and the approvals your messages need.</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>
|
||||
<Link to="/register" className="hero-primary">Create your account <ArrowRight size={17} /></Link>
|
||||
<a href="#product" className="hero-secondary">See what you can send</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 className="hero-proof"><span><Check size={13} /> No credit card to start</span><span><Check size={13} /> WhatsApp and SMS</span><span><Check size={13} /> Built for India</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 className="demo-top"><span className="thread-brand">Northwind</span><b>Delivered</b></div>
|
||||
<div className="thread-list">
|
||||
<article>
|
||||
<small>Text · 10:14</small>
|
||||
<p>Your order is out for delivery and should arrive by 6 pm.</p>
|
||||
</article>
|
||||
<article>
|
||||
<small>WhatsApp · 10:16</small>
|
||||
<p>Hi Asha, your appointment today at 4:30 is confirmed.</p>
|
||||
</article>
|
||||
<article>
|
||||
<small>Call · 10:22</small>
|
||||
<p>Answered · 1 minute</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-orbit orbit-one"><Bot size={19} /></div>
|
||||
<div className="agent-orbit orbit-two"><Zap size={18} /></div>
|
||||
<div className="agent-orbit orbit-one"><MessageSquare size={19} /></div>
|
||||
<div className="agent-orbit orbit-two"><Phone 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>
|
||||
<span>Used by teams that talk to customers every day</span>
|
||||
<div><b>Retail</b><b>Finance</b><b>Clinics</b><b>Logistics</b><b>Education</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="section-heading"><span>WHAT YOU CAN DO</span><h2>One place for every customer conversation.</h2><p>Write the message, choose who should receive it, and see whether it arrived.</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>
|
||||
<article><span><MessageSquare /></span><h3>Texts people can trust</h3><p>Send from an approved sender ID, with templates that are ready before the first message goes out.</p></article>
|
||||
<article><span><Phone /></span><h3>WhatsApp, SMS, and calls</h3><p>Keep business messaging, verification codes, and phone calls in the same account.</p></article>
|
||||
<article id="security"><span><ShieldCheck /></span><h3>Approvals included</h3><p>Business KYC, DLT registration, and sender checks are part of setup, not a separate project.</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 className="closing-band" id="how">
|
||||
<div><span>START TODAY</span><h2>Open your workspace and send the first message.</h2><p>Add your business details, connect WhatsApp or a phone number, and reach customers the same day your approvals are ready.</p></div>
|
||||
<Link to="/register">Create your account <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>
|
||||
<span>Customer messaging, clearly run.</span>
|
||||
<Link to="/privacy" className="legal-footer-link">Privacy Policy</Link>
|
||||
<small>© 2026 Urmi Technologies</small>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function PrivacyPolicyPage() {
|
|||
<Link to="/" className="landing-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link>
|
||||
<div className="landing-actions">
|
||||
<Link to="/login">Log in</Link>
|
||||
<Link to="/register" className="landing-cta">Start building</Link>
|
||||
<Link to="/register" className="landing-cta">Get started</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { logsApi, type LogType, type LogsResponse } from '../../lib/api'
|
||||
|
||||
const LOG_TYPES: Array<{ value: LogType; label: string }> = [
|
||||
{ value: 'outbound', label: 'Outbound' },
|
||||
{ value: 'inbound', label: 'Inbound' },
|
||||
{ value: 'sms', label: 'SMS' },
|
||||
{ value: 'otp', label: 'OTP' },
|
||||
{ value: 'outbound', label: 'Calls placed' },
|
||||
{ value: 'inbound', label: 'Calls received' },
|
||||
{ value: 'sms', label: 'Text messages' },
|
||||
{ value: 'otp', label: 'Verification codes' },
|
||||
]
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
|
@ -134,11 +134,11 @@ export function LogsPage() {
|
|||
case 'sms':
|
||||
return ['From', 'To', 'Template', 'Status', 'Time']
|
||||
case 'otp':
|
||||
return ['To', 'OTP type', 'Project', 'Status', 'Time']
|
||||
return ['To', 'Code type', 'Project', 'Status', 'Time']
|
||||
case 'inbound':
|
||||
return ['From', 'To', 'Status', 'Time']
|
||||
default:
|
||||
return ['From', 'To', 'Socket', 'Status', 'Time']
|
||||
return ['From', 'To', 'Connection', 'Status', 'Time']
|
||||
}
|
||||
}, [logType])
|
||||
|
||||
|
|
@ -148,15 +148,15 @@ export function LogsPage() {
|
|||
return (
|
||||
<div className="logs-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Test & observe</span>
|
||||
<h1>Logs</h1>
|
||||
<p>Browse outbound and inbound calls, SMS sends, and OTP deliveries. Default view covers the last week, up to one month.</p>
|
||||
<span className="eyebrow">Insights</span>
|
||||
<h1>Activity history</h1>
|
||||
<p>Review calls, text messages, and verification codes. The default view is the last week, and you can look back up to a month.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel logs-toolbar">
|
||||
<div className="logs-toolbar-row">
|
||||
<label className="logs-field">
|
||||
<span>Log type</span>
|
||||
<span>Activity</span>
|
||||
<select value={logType} onChange={(event) => changeType(event.target.value as LogType)}>
|
||||
{LOG_TYPES.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
|
|
|
|||
|
|
@ -188,30 +188,30 @@ export function ComposeMessage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading messaging…</div>
|
||||
if (loading) return <div className="auth-loading">Loading your message…</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>
|
||||
<span className="eyebrow">Messages</span>
|
||||
<h1>Send a message</h1>
|
||||
<p>Choose an approved sender and template, then add the people who should receive it.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel compose-message-card">
|
||||
<div className="compose-card-header">
|
||||
<div>
|
||||
<MessageSquareText size={18} />
|
||||
<h2>Compose New Message</h2>
|
||||
<h2>New text message</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compose-grid">
|
||||
<label className="compose-field">
|
||||
<span>Message Channel</span>
|
||||
<span>Message type</span>
|
||||
<div className="select-wrap">
|
||||
<select value={channel} onChange={(event) => handleChannelChange(event.target.value)}>
|
||||
<option value="">Select a channel</option>
|
||||
<option value="">Select a type</option>
|
||||
<option value="transactional">Transactional</option>
|
||||
<option value="promotional">Promotional</option>
|
||||
</select>
|
||||
|
|
@ -220,7 +220,7 @@ export function ComposeMessage() {
|
|||
</label>
|
||||
|
||||
<label className="compose-field">
|
||||
<span>SenderID</span>
|
||||
<span>Sender ID</span>
|
||||
<div className="select-wrap">
|
||||
<select
|
||||
value={senderConfigId}
|
||||
|
|
@ -232,7 +232,7 @@ export function ComposeMessage() {
|
|||
disabled={!channel || approvedSenders.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{!channel ? 'Select a channel first' : approvedSenders.length === 0 ? 'No approved sender IDs' : 'Select a senderid'}
|
||||
{!channel ? 'Select a type first' : approvedSenders.length === 0 ? 'No approved sender IDs' : 'Select a sender ID'}
|
||||
</option>
|
||||
{approvedSenders.map((sender) => (
|
||||
<option key={sender.id} value={sender.id}>{sender.sender_id}</option>
|
||||
|
|
@ -243,24 +243,24 @@ export function ComposeMessage() {
|
|||
</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" />
|
||||
<span>Campaign name</span>
|
||||
<input value={campaignName} onChange={(event) => setCampaignName(event.target.value)} placeholder="For example, April order updates" />
|
||||
</label>
|
||||
|
||||
<label className="compose-field compose-field-wide">
|
||||
<span>Numbers ({recipientCount})</span>
|
||||
<span>Recipients ({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"
|
||||
placeholder="Separate numbers with commas, for example 919999999999, 918888888888"
|
||||
/>
|
||||
</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>
|
||||
<span>Template</span>
|
||||
<div className="select-wrap">
|
||||
<select
|
||||
value={templateId}
|
||||
|
|
@ -307,8 +307,8 @@ export function ComposeMessage() {
|
|||
)}
|
||||
|
||||
<div className="compose-message-meta">
|
||||
<span>{characterCount} Characters Used</span>
|
||||
<span>Count {messageCount}</span>
|
||||
<span>{characterCount} characters</span>
|
||||
<span>{messageCount} SMS {messageCount === 1 ? 'part' : 'parts'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -344,13 +344,13 @@ export function ComposeMessage() {
|
|||
<div className="compose-footer-actions">
|
||||
<label className="compose-draft-toggle">
|
||||
<input type="checkbox" checked={saveDraft} onChange={(event) => setSaveDraft(event.target.checked)} />
|
||||
Save As Draft
|
||||
Save as draft
|
||||
</label>
|
||||
<button type="button" className="primary-button" onClick={handleSendNow} disabled={sending}>
|
||||
<Send size={15} /> {sending ? 'Sending…' : 'Send Now'}
|
||||
<Send size={15} /> {sending ? 'Sending…' : 'Send now'}
|
||||
</button>
|
||||
<button type="button" className="primary-button">
|
||||
<CalendarClock size={15} /> Schedule For Later
|
||||
<CalendarClock size={15} /> Schedule for later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -286,26 +286,26 @@ export function MessageSettings() {
|
|||
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>
|
||||
<span className="eyebrow">Get ready</span>
|
||||
<h1>Senders and templates</h1>
|
||||
<p>Add the sender IDs and message templates you want approved before you text customers.</p>
|
||||
</div>
|
||||
|
||||
<AccordionSection
|
||||
title="Sender configs"
|
||||
description="Headers, numeric senders, and routing defaults."
|
||||
title="Sender IDs"
|
||||
description="The names and numbers your texts are sent from."
|
||||
open={senderOpen}
|
||||
onToggle={() => setSenderOpen(!senderOpen)}
|
||||
>
|
||||
<div className="accordion-toolbar">
|
||||
<div className="accordion-heading"><SlidersHorizontal size={18} /><span>Registered senders for this project</span></div>
|
||||
<div className="accordion-heading"><SlidersHorizontal size={18} /><span>Sender IDs on this account</span></div>
|
||||
<button className="primary-button" onClick={() => setSenderModalOpen(true)}><Plus size={15} /> Add</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="empty-copy">Loading sender configs…</p>
|
||||
<p className="empty-copy">Loading sender IDs…</p>
|
||||
) : senders.length === 0 ? (
|
||||
<p className="empty-copy">No sender IDs yet. Add one to request admin approval.</p>
|
||||
<p className="empty-copy">No sender IDs yet. Add one and we’ll review it.</p>
|
||||
) : (
|
||||
senders.map((item) => (
|
||||
<div className="compliance-row" key={item.id}>
|
||||
|
|
@ -319,20 +319,20 @@ export function MessageSettings() {
|
|||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
title="Template configs"
|
||||
description="DLT-approved message templates linked to senders."
|
||||
title="Message templates"
|
||||
description="Approved wording linked to a sender ID."
|
||||
open={templateOpen}
|
||||
onToggle={() => setTemplateOpen(!templateOpen)}
|
||||
>
|
||||
<div className="accordion-toolbar">
|
||||
<div className="accordion-heading"><ShieldCheck size={18} /><span>Registered content templates</span></div>
|
||||
<div className="accordion-heading"><ShieldCheck size={18} /><span>Templates on this account</span></div>
|
||||
<button className="primary-button" onClick={() => setTemplateModalOpen(true)}><Plus size={15} /> Add</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="empty-copy">Loading template configs…</p>
|
||||
<p className="empty-copy">Loading templates…</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="empty-copy">No templates yet. Add one to request admin approval.</p>
|
||||
<p className="empty-copy">No templates yet. Add one and we’ll review it.</p>
|
||||
) : (
|
||||
templates.map((item) => (
|
||||
<div className="compliance-row" key={item.id}>
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ export function TemplatesPage() {
|
|||
return (
|
||||
<div className="templates-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Communicate</span>
|
||||
<span className="eyebrow">Messages</span>
|
||||
<h1>Templates</h1>
|
||||
<p>All registered DLT templates for your account. Send a message using any approved template.</p>
|
||||
<p>Approved message wording for this account. Send from any template that is ready.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel templates-panel">
|
||||
|
|
@ -66,8 +66,8 @@ export function TemplatesPage() {
|
|||
{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>
|
||||
<span>Add sender IDs and templates first, then return here to send.</span>
|
||||
<Link className="secondary-button" to="/console/message-settings">Senders and templates</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="templates-table">
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ export function MetaAdAccountsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<MetaScreen title="Ad accounts" subtitle="Ad accounts Meta returned for this authorization. Spend is the amount Meta reports for that account.">
|
||||
<MetaScreen title="Ad accounts" subtitle="Ad accounts Facebook shared with this workspace. Spend is the amount Facebook reports.">
|
||||
{error && <MetaState error={error} />}
|
||||
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.accounts.length === 0 ? (
|
||||
<MetaEmpty title="No ad accounts returned" body="This login cannot see an ad account yet. Check the granted permissions in Meta Settings." to="/console/meta/settings" action="Open settings" />
|
||||
<MetaEmpty title="No ad accounts yet" body="This login cannot see an ad account yet. Check permissions in Ad settings." to="/console/meta/settings" action="Open settings" />
|
||||
) : (
|
||||
<div className="meta-table-wrap panel">
|
||||
<table className="meta-table">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState } from 'react'
|
||||
import { metaApi, type MetaAd, MetaRequestError } from './api'
|
||||
import { ConfirmDialog, MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, useMetaLoad } from './ui'
|
||||
import { ConfirmDialog, MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, plainLabel, useMetaLoad } from './ui'
|
||||
|
||||
type Tab = 'ads' | 'adsets'
|
||||
|
||||
const CTA_LABELS: Record<string, string> = {
|
||||
LEARN_MORE: 'Learn more',
|
||||
|
|
@ -12,7 +14,9 @@ const CTA_LABELS: Record<string, string> = {
|
|||
}
|
||||
|
||||
export function MetaAdsPage() {
|
||||
const [tab, setTab] = useState<Tab>('ads')
|
||||
const list = useMetaLoad('ads', () => metaApi.ads())
|
||||
const adsets = useMetaLoad(tab === 'adsets' ? 'adsets' : 'adsets-skip', () => metaApi.adsets())
|
||||
const [selected, setSelected] = useState<string>('')
|
||||
const [draft, setDraft] = useState<MetaAd | null>(null)
|
||||
const [pending, setPending] = useState<MetaAd | null>(null)
|
||||
|
|
@ -57,20 +61,46 @@ export function MetaAdsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<MetaScreen title="Ads and creatives" subtitle={list.data?.account_name ? `Ads in ${list.data.account_name}.` : 'Ads in the selected ad account.'}>
|
||||
<MetaScreen title="Ads" subtitle={list.data?.account_name ? `Ads and ad sets in ${list.data.account_name}.` : 'Ads and ad sets in the selected ad account.'}>
|
||||
<div className="meta-tabs">
|
||||
<button type="button" className={tab === 'ads' ? 'is-active' : ''} onClick={() => setTab('ads')}>Ads</button>
|
||||
<button type="button" className={tab === 'adsets' ? 'is-active' : ''} onClick={() => setTab('adsets')}>Ad sets</button>
|
||||
</div>
|
||||
{error && <MetaState error={error} />}
|
||||
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.ads.length === 0 ? (
|
||||
<MetaEmpty title="No ads returned" body="Meta did not return ads for the selected ad account." />
|
||||
{tab === 'adsets' ? (
|
||||
adsets.loading ? <MetaLoading /> : adsets.error ? <MetaState error={adsets.error} /> : adsets.data?.adsets.length === 0 ? (
|
||||
<MetaEmpty title="No ad sets yet" body="Facebook did not return ad sets for the selected ad account." />
|
||||
) : (
|
||||
<div className="meta-table-wrap panel">
|
||||
<table className="meta-table">
|
||||
<thead><tr><th>Ad set</th><th>Campaign</th><th>Status</th><th>Goal</th><th>Budget</th></tr></thead>
|
||||
<tbody>
|
||||
{adsets.data?.adsets.map((adset) => (
|
||||
<tr key={adset.id}>
|
||||
<td><strong>{adset.name}</strong></td>
|
||||
<td>{adset.campaign_name || '—'}</td>
|
||||
<td><StatusPill value={adset.status} /></td>
|
||||
<td>{plainLabel(adset.optimization_goal)}</td>
|
||||
<td>{adset.budget || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.ads.length === 0 ? (
|
||||
<MetaEmpty title="No ads yet" body="Facebook did not return ads for the selected ad account." />
|
||||
) : (
|
||||
<div className="meta-split">
|
||||
<div className="meta-table-wrap panel">
|
||||
<table className="meta-table">
|
||||
<thead><tr><th>Ad</th><th>Campaign</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>Ad</th><th>Campaign</th><th>Ad set</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{list.data?.ads.map((item) => (
|
||||
<tr key={item.id} className={ad?.id === item.id ? 'is-selected' : ''} onClick={() => setSelected(item.id)}>
|
||||
<td><strong>{item.name}</strong></td>
|
||||
<td>{item.campaign_name || '—'}</td>
|
||||
<td>{item.adset_name || '—'}</td>
|
||||
<td><StatusPill value={item.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -82,20 +112,20 @@ export function MetaAdsPage() {
|
|||
{ad.image_url ? <img src={ad.image_url} alt="" /> : <div className="meta-preview-empty">No image was returned for this creative.</div>}
|
||||
<strong>{ad.headline || ad.name}</strong>
|
||||
<p>{ad.message || 'No primary text was returned.'}</p>
|
||||
<p className="meta-muted">{CTA_LABELS[ad.call_to_action] || ad.call_to_action || 'No call to action returned'}</p>
|
||||
<p className="meta-muted">{ad.adset_name ? `${ad.adset_name} · ` : ''}{CTA_LABELS[ad.call_to_action] || ad.call_to_action || 'No call to action returned'}</p>
|
||||
{list.data?.can_manage && (
|
||||
<div className="welcome-actions">
|
||||
<button className="secondary-button" type="button" onClick={() => { setDraft(ad); setError(null) }} disabled={!ad.editable}>Edit creative</button>
|
||||
<button className="secondary-button" type="button" onClick={() => setPending(ad)}>{ad.status === 'ACTIVE' ? 'Pause' : 'Resume'}</button>
|
||||
</div>
|
||||
)}
|
||||
{list.data?.can_manage && <p className="meta-muted">{ad.editable ? 'Name, text, link, and call to action can be replaced for this Page link ad. The image shown is the one Meta already stored; this screen does not upload new media.' : 'Urmi can change status for this ad. Its creative is not a Page link ad, so text and call to action stay read-only.'}</p>}
|
||||
{list.data?.can_manage && <p className="meta-muted">{ad.editable ? 'You can replace the name, text, link, and button for this Page ad. The image stays as Facebook stored it. This screen does not upload a new image.' : 'You can pause or resume this ad. The text and button stay as they are because this is not a simple Page link ad.'}</p>}
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{draft && (
|
||||
<ConfirmDialog title="Update this creative in Meta?" body="Urmi creates a replacement Page link creative and attaches it to the ad. Meta may reject unsupported media or call-to-action combinations." confirmLabel="Save creative" busy={busy} onCancel={() => setDraft(null)} onConfirm={save}>
|
||||
<ConfirmDialog title="Update this ad?" body="Urmi replaces the Page link creative and attaches it to the ad. Facebook may reject a button or link it does not support." confirmLabel="Save ad" busy={busy} onCancel={() => setDraft(null)} onConfirm={save}>
|
||||
<label className="meta-field">Name<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label>
|
||||
<label className="meta-field">Primary text<textarea rows={3} value={draft.message} onChange={(event) => setDraft({ ...draft, message: event.target.value })} /></label>
|
||||
<label className="meta-field">Headline<input value={draft.headline} onChange={(event) => setDraft({ ...draft, headline: event.target.value })} /></label>
|
||||
|
|
@ -110,7 +140,7 @@ export function MetaAdsPage() {
|
|||
{pending && (
|
||||
<ConfirmDialog
|
||||
title={pending.status === 'ACTIVE' ? `Pause ${pending.name}?` : `Resume ${pending.name}?`}
|
||||
body="Urmi will send this status change to Meta. The ad changes only if Meta accepts it."
|
||||
body="This status change is sent to Facebook. The ad changes only if Facebook accepts it."
|
||||
confirmLabel={pending.status === 'ACTIVE' ? 'Pause ad' : 'Resume ad'}
|
||||
busy={busy}
|
||||
onCancel={() => setPending(null)}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ export function MetaAnalyticsPage() {
|
|||
const report = useMetaLoad(range, () => metaApi.analytics(range))
|
||||
return (
|
||||
<MetaScreen
|
||||
title="Analytics"
|
||||
subtitle={report.data?.account_name ? `Delivery for ${report.data.account_name}.` : 'Delivery for the selected ad account.'}
|
||||
title="Ad results"
|
||||
subtitle={report.data?.account_name ? `Results for ${report.data.account_name}.` : 'Results for the selected ad account. Connect Facebook and choose an ad account first.'}
|
||||
actions={
|
||||
<select className="meta-select" value={range} onChange={(event) => setRange(event.target.value)}>
|
||||
{RANGES.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||
|
|
@ -25,7 +25,7 @@ export function MetaAnalyticsPage() {
|
|||
}
|
||||
>
|
||||
{report.loading ? <MetaLoading /> : report.error ? <MetaState error={report.error} /> : !report.data?.has_delivery ? (
|
||||
<MetaEmpty title="No delivery in this range" body="Meta returned no insights for the selected ad account and dates." />
|
||||
<MetaEmpty title="No results in this range" body="Facebook returned no results for the selected ad account and dates." />
|
||||
) : (
|
||||
<>
|
||||
<div className="meta-metrics">
|
||||
|
|
@ -38,7 +38,7 @@ export function MetaAnalyticsPage() {
|
|||
</div>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><h2>Spend</h2><p>{report.data.currency}</p></div>
|
||||
{report.data.series.length === 0 ? <MetaEmpty title="No daily breakdown" body="Meta returned a total without daily points for this range." /> : (
|
||||
{report.data.series.length === 0 ? <MetaEmpty title="No daily breakdown" body="Facebook returned a total without daily points for this range." /> : (
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={report.data.series}>
|
||||
|
|
@ -52,6 +52,9 @@ export function MetaAnalyticsPage() {
|
|||
</div>
|
||||
)}
|
||||
</article>
|
||||
<InsightTable title="Campaigns" empty="No campaign insights in this range." rows={report.data.campaigns ?? []} />
|
||||
<InsightTable title="Ad sets" empty="No ad set insights in this range." rows={report.data.adsets ?? []} />
|
||||
<InsightTable title="Ads" empty="No ad insights in this range." rows={report.data.ads ?? []} />
|
||||
</>
|
||||
)}
|
||||
</MetaScreen>
|
||||
|
|
@ -61,3 +64,29 @@ export function MetaAnalyticsPage() {
|
|||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return <article className="stat-card meta-metric"><span>{label}</span><strong>{value}</strong></article>
|
||||
}
|
||||
|
||||
function InsightTable({ title, empty, rows }: { title: string; empty: string; rows: { name: string; spend: string | null; impressions: number | null; clicks: number | null; ctr: number | null }[] }) {
|
||||
return (
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><h2>{title}</h2></div>
|
||||
{rows.length === 0 ? <p className="meta-muted">{empty}</p> : (
|
||||
<div className="meta-table-wrap">
|
||||
<table className="meta-table">
|
||||
<thead><tr><th>Name</th><th>Spend</th><th>Impressions</th><th>Clicks</th><th>CTR</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={`${title}-${row.name}`}>
|
||||
<td><strong>{row.name}</strong></td>
|
||||
<td>{row.spend || '—'}</td>
|
||||
<td>{formatCount(row.impressions)}</td>
|
||||
<td>{formatCount(row.clicks)}</td>
|
||||
<td>{row.ctr == null ? '—' : `${row.ctr}%`}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ const OBJECTIVES = [
|
|||
{ id: 'OUTCOME_TRAFFIC', label: 'Traffic', detail: 'Send people to a website or WhatsApp.', enabled: true },
|
||||
{ id: 'OUTCOME_ENGAGEMENT', label: 'Engagement', detail: 'Promote the connected Page.', enabled: true },
|
||||
{ id: 'OUTCOME_AWARENESS', label: 'Awareness', detail: 'Reach people with the connected Page.', enabled: true },
|
||||
{ id: 'OUTCOME_LEADS', label: 'Leads', detail: 'Needs a lead form on the Page. Not available on this connection.', enabled: false },
|
||||
{ id: 'OUTCOME_SALES', label: 'Sales', detail: 'Needs a Meta pixel on the ad account. Not available on this connection.', enabled: false },
|
||||
{ id: 'OUTCOME_APP_PROMOTION', label: 'App promotion', detail: 'Needs a linked app. Not available on this connection.', enabled: false },
|
||||
{ id: 'OUTCOME_LEADS', label: 'Leads', detail: 'Needs a lead form on your Page. Not available yet.', enabled: false },
|
||||
{ id: 'OUTCOME_SALES', label: 'Sales', detail: 'Needs a Facebook pixel on your ad account. Not available yet.', enabled: false },
|
||||
{ id: 'OUTCOME_APP_PROMOTION', label: 'App promotion', detail: 'Needs a linked app. Not available yet.', enabled: false },
|
||||
]
|
||||
|
||||
export function MetaCampaignBuilderPage() {
|
||||
|
|
@ -42,13 +42,13 @@ export function MetaCampaignBuilderPage() {
|
|||
const destinations = useMemo(() => [
|
||||
{ id: 'website', label: 'Website', enabled: true, detail: 'Requires a public http or https link.' },
|
||||
{ id: 'page', label: 'Facebook Page', enabled: true, detail: 'Uses the Page you select on the next step.' },
|
||||
{ id: 'whatsapp', label: 'WhatsApp', enabled: objective !== 'OUTCOME_AWARENESS', detail: 'Meta accepts this only when the Page is allowed to send people to WhatsApp. A rejection is shown as a Meta error.' },
|
||||
{ id: 'whatsapp', label: 'WhatsApp', enabled: objective !== 'OUTCOME_AWARENESS', detail: 'Facebook accepts this only when the Page can send people to WhatsApp. If it does not, the error is shown here.' },
|
||||
], [objective])
|
||||
|
||||
function next() {
|
||||
setError(null)
|
||||
if (step === 2 && !pageId) {
|
||||
setError(new MetaRequestError('Select a Page Meta returned for this login.', 'validation'))
|
||||
setError(new MetaRequestError('Select a Page Facebook returned for this login.', 'validation'))
|
||||
return
|
||||
}
|
||||
if (step === 3 && !(Number(budget) > 0)) {
|
||||
|
|
@ -84,19 +84,19 @@ export function MetaCampaignBuilderPage() {
|
|||
navigate('/console/meta/campaigns')
|
||||
} catch (err) {
|
||||
setConfirming(null)
|
||||
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta did not publish the campaign'))
|
||||
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Facebook did not publish the campaign'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MetaScreen title="Campaign builder" subtitle="Each step is sent to Meta only when you confirm publish. Unavailable objectives stay disabled.">
|
||||
<MetaScreen title="New ad campaign" subtitle="Nothing is sent to Facebook until you confirm. Goals that are not available stay off.">
|
||||
<ol className="meta-steps">
|
||||
{STEPS.map((label, index) => <li key={label} className={index === step ? 'is-current' : index < step ? 'is-done' : ''}>{label}</li>)}
|
||||
</ol>
|
||||
{blocked && status.data && <MetaState error={new MetaRequestError('Meta has not granted Ads management for this connection.', 'permission_denied', 'ads_management')} />}
|
||||
{!account && accounts.data && <MetaState error={new MetaRequestError('Select an ad account before building a campaign.', 'no_ad_account')} />}
|
||||
{blocked && status.data && <MetaState error={new MetaRequestError('Facebook has not allowed ad management for this connection.', 'permission_denied', 'ads_management')} />}
|
||||
{!account && accounts.data && <MetaState error={new MetaRequestError('Choose an ad account before creating a campaign.', 'no_ad_account')} />}
|
||||
{error && <MetaState error={error} />}
|
||||
{pages.loading || accounts.loading || status.loading ? <MetaLoading /> : (
|
||||
<article className="panel meta-builder">
|
||||
|
|
@ -120,7 +120,7 @@ export function MetaCampaignBuilderPage() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (pages.error ? <MetaState error={pages.error} /> : pages.data?.pages.length === 0 ? <p className="meta-muted">Meta returned no Pages for this login.</p> : (
|
||||
{step === 2 && (pages.error ? <MetaState error={pages.error} /> : pages.data?.pages.length === 0 ? <p className="meta-muted">Facebook returned no Pages for this login.</p> : (
|
||||
<div className="meta-choice-grid">
|
||||
{pages.data?.pages.map((page) => (
|
||||
<button key={page.id} type="button" className={pageId === page.id ? 'is-selected' : ''} onClick={() => setPageId(page.id)}>
|
||||
|
|
@ -190,8 +190,8 @@ export function MetaCampaignBuilderPage() {
|
|||
)}
|
||||
{confirming && (
|
||||
<ConfirmDialog
|
||||
title="Create this campaign in Meta?"
|
||||
body="Urmi will create the campaign, ad set, creative, and ad in the selected ad account. It stays paused unless you publish it live. If Meta rejects any step, the error is shown here and recorded in the audit trail. A paused campaign may still exist if a later step fails."
|
||||
title="Create this campaign on Facebook?"
|
||||
body="Urmi will create the campaign, audience, creative, and ad in the selected ad account. It stays paused unless you publish it live. If Facebook rejects a step, the message is shown here. A paused campaign may still exist if a later step fails."
|
||||
confirmLabel={confirming === 'ACTIVE' ? 'Publish live' : 'Create paused'}
|
||||
busy={busy}
|
||||
onCancel={() => setConfirming(null)}
|
||||
|
|
|
|||
78
frontend/src/features/meta/CampaignDetailPage.tsx
Normal file
78
frontend/src/features/meta/CampaignDetailPage.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { Link, useParams } from 'react-router-dom'
|
||||
import { metaApi } from './api'
|
||||
import { MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, formatCount, formatWhen, plainLabel, useMetaLoad } from './ui'
|
||||
|
||||
export function MetaCampaignDetailPage() {
|
||||
const { campaignId } = useParams()
|
||||
const detail = useMetaLoad(campaignId || 'none', () => campaignId ? metaApi.campaign(campaignId) : Promise.reject(new Error('Missing campaign')))
|
||||
|
||||
if (!campaignId) {
|
||||
return (
|
||||
<MetaScreen title="Campaign details" subtitle="Open a campaign from Advertising → Ad campaigns.">
|
||||
<MetaEmpty title="No campaign selected" body="Choose a campaign from the list first." to="/console/meta/campaigns" action="Back to campaigns" />
|
||||
</MetaScreen>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MetaScreen
|
||||
title={detail.data?.campaign.name || 'Campaign details'}
|
||||
subtitle={detail.data?.account_name ? `Ad sets and results for this campaign in ${detail.data.account_name}.` : 'Ad sets Facebook returned for this campaign.'}
|
||||
actions={<Link className="secondary-button" to="/console/meta/campaigns">Back to campaigns</Link>}
|
||||
>
|
||||
{detail.loading ? <MetaLoading /> : detail.error ? <MetaState error={detail.error} /> : detail.data && (
|
||||
<>
|
||||
<div className="meta-metrics">
|
||||
<Metric label="Status" value={detail.data.campaign.status} />
|
||||
<Metric label="Objective" value={plainLabel(detail.data.campaign.objective)} />
|
||||
<Metric label="Spend (30d)" value={detail.data.metrics.spend || '—'} />
|
||||
<Metric label="Impressions" value={formatCount(detail.data.metrics.impressions)} />
|
||||
<Metric label="Clicks" value={formatCount(detail.data.metrics.clicks)} />
|
||||
<Metric label="CTR" value={detail.data.metrics.ctr == null ? '—' : `${detail.data.metrics.ctr}%`} />
|
||||
</div>
|
||||
{detail.data.insights_error && <p className="meta-banner is-error">{detail.data.insights_error.message}</p>}
|
||||
<article className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Ad sets</h2>
|
||||
<p>From Facebook · Created {formatWhen(detail.data.campaign.created_time)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{detail.data.adsets.length === 0 ? (
|
||||
<MetaEmpty title="No ad sets yet" body="Facebook did not return ad sets for this campaign." />
|
||||
) : (
|
||||
<div className="meta-table-wrap">
|
||||
<table className="meta-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ad set</th>
|
||||
<th>Status</th>
|
||||
<th>Goal</th>
|
||||
<th>Budget</th>
|
||||
<th>Schedule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.data.adsets.map((adset) => (
|
||||
<tr key={adset.id}>
|
||||
<td><strong>{adset.name}</strong></td>
|
||||
<td><StatusPill value={adset.status} /></td>
|
||||
<td>{plainLabel(adset.optimization_goal)}</td>
|
||||
<td>{adset.budget || '—'}</td>
|
||||
<td><small>{formatWhen(adset.start_time)}{adset.end_time ? ` → ${formatWhen(adset.end_time)}` : ''}</small></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</>
|
||||
)}
|
||||
</MetaScreen>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return <article className="stat-card meta-metric"><span>{label}</span><strong>{value}</strong></article>
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { metaApi, type MetaCampaign, MetaRequestError } from './api'
|
||||
import { ConfirmDialog, MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, formatCount, formatWhen, useMetaLoad } from './ui'
|
||||
import { ConfirmDialog, MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, formatCount, formatWhen, plainLabel, useMetaLoad } from './ui'
|
||||
|
||||
export function MetaCampaignsPage() {
|
||||
const list = useMetaLoad('campaigns', () => metaApi.campaigns())
|
||||
|
|
@ -22,7 +22,7 @@ export function MetaCampaignsPage() {
|
|||
setPending(null)
|
||||
list.reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta did not change the campaign'))
|
||||
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Facebook did not change the campaign'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export function MetaCampaignsPage() {
|
|||
setEditing(null)
|
||||
list.reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta did not update the campaign'))
|
||||
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Facebook did not update the campaign'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ export function MetaCampaignsPage() {
|
|||
{error && <MetaState error={error} />}
|
||||
{list.data?.insights_error && <p className="meta-banner is-error">{list.data.insights_error.message}</p>}
|
||||
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.campaigns.length === 0 ? (
|
||||
<MetaEmpty title="No campaigns returned" body="Meta did not return campaigns for the selected ad account." />
|
||||
<MetaEmpty title="No campaigns yet" body="Facebook did not return campaigns for the selected ad account." />
|
||||
) : (
|
||||
<div className="meta-table-wrap panel">
|
||||
<table className="meta-table">
|
||||
|
|
@ -65,12 +65,13 @@ export function MetaCampaignsPage() {
|
|||
<tr key={campaign.id}>
|
||||
<td><strong>{campaign.name}</strong><small>{formatWhen(campaign.created_time)}</small></td>
|
||||
<td><StatusPill value={campaign.status} /></td>
|
||||
<td>{campaign.objective}</td>
|
||||
<td>{plainLabel(campaign.objective)}</td>
|
||||
<td>{campaign.spend || '—'}</td>
|
||||
<td>{formatCount(campaign.impressions)}</td>
|
||||
<td>{formatCount(campaign.clicks)}</td>
|
||||
<td>{campaign.ctr == null ? '—' : `${campaign.ctr}%`}</td>
|
||||
<td className="meta-actions">
|
||||
<Link to={`/console/meta/campaigns/${campaign.id}`}>View</Link>
|
||||
{list.data?.can_manage && (
|
||||
<>
|
||||
<button type="button" onClick={() => { setEditing(campaign); setName(campaign.name); setBudget(''); setError(null) }}>Edit</button>
|
||||
|
|
@ -84,11 +85,11 @@ export function MetaCampaignsPage() {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!list.data?.can_manage && list.data && <p className="meta-muted">Creating, editing, pausing, and resuming stay off until Meta grants Ads management.</p>}
|
||||
{!list.data?.can_manage && list.data && <p className="meta-muted">Creating, editing, pausing, and resuming stay off until Facebook allows ad management.</p>}
|
||||
{pending && (
|
||||
<ConfirmDialog
|
||||
title={pending.status === 'ACTIVE' ? `Pause ${pending.name}?` : `Resume ${pending.name}?`}
|
||||
body="Urmi will send this status change to Meta for the selected ad account. Delivery and spend change only if Meta accepts it."
|
||||
body="This status change is sent to Facebook for the selected ad account. Delivery and spend change only if Facebook accepts it."
|
||||
confirmLabel={pending.status === 'ACTIVE' ? 'Pause campaign' : 'Resume campaign'}
|
||||
busy={busy}
|
||||
onCancel={() => setPending(null)}
|
||||
|
|
@ -98,8 +99,8 @@ export function MetaCampaignsPage() {
|
|||
{editing && (
|
||||
<ConfirmDialog
|
||||
title={`Update ${editing.name}`}
|
||||
body="This sends the new name, and a daily budget when you enter one, to Meta. Budget edits work when the campaign has a single ad set."
|
||||
confirmLabel="Save to Meta"
|
||||
body="This sends the new name, and a daily budget when you enter one, to Facebook. Budget edits work when the campaign has a single ad set."
|
||||
confirmLabel="Save campaign"
|
||||
busy={busy}
|
||||
onCancel={() => setEditing(null)}
|
||||
onConfirm={saveEdit}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { metaApi, type MetaConnection, MetaRequestError } from './api'
|
||||
import { MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, formatWhen, useMetaLoad } from './ui'
|
||||
|
|
@ -14,8 +15,8 @@ export function MetaConnectionsPage() {
|
|||
const pages = useMetaLoad(status.data?.capabilities.pages ? 'pages' : 'pages-skip', () =>
|
||||
status.data?.capabilities.pages ? metaApi.pages() : Promise.resolve({ pages: [], selected_name: '' }),
|
||||
)
|
||||
const accounts = useMetaLoad(status.data?.capabilities.ads_read || status.data?.capabilities.business ? 'accounts' : 'accounts-skip', () =>
|
||||
status.data?.capabilities.ads_read || status.data?.capabilities.business ? metaApi.adAccounts() : Promise.resolve({ accounts: [], selected_name: '' }),
|
||||
const accounts = useMetaLoad(status.data?.capabilities.ads_read || status.data?.capabilities.ads_manage ? 'accounts' : 'accounts-skip', () =>
|
||||
status.data?.capabilities.ads_read || status.data?.capabilities.ads_manage ? metaApi.adAccounts() : Promise.resolve({ accounts: [], selected_name: '' }),
|
||||
)
|
||||
|
||||
async function connect() {
|
||||
|
|
@ -33,13 +34,23 @@ export function MetaConnectionsPage() {
|
|||
const connection = status.data
|
||||
return (
|
||||
<MetaScreen
|
||||
title="Connections"
|
||||
subtitle="Authorization for this workspace only. Urmi stores the Meta token on the server and does not show it here."
|
||||
actions={<button className="primary-button" type="button" onClick={connect} disabled={busy}>{connection?.connected ? 'Reconnect' : 'Connect Meta'}</button>}
|
||||
title="Facebook connection"
|
||||
subtitle="Sign in, or paste an access token. Urmi keeps the connection private and does not show the token here."
|
||||
actions={<button className="primary-button" type="button" onClick={connect} disabled={busy}>{connection?.connected ? 'Reconnect' : 'Connect Facebook'}</button>}
|
||||
>
|
||||
{params.get('connected') && <p className="meta-banner">Meta authorization completed for this workspace.</p>}
|
||||
{params.get('connected') && <p className="meta-banner">Facebook is connected for this workspace.</p>}
|
||||
{params.get('error') && <p className="meta-banner is-error">{params.get('error')}</p>}
|
||||
{error && <MetaState error={error} />}
|
||||
{connection?.oauth_redirect_uri && <MetaOAuthSetup redirectUri={connection.oauth_redirect_uri} />}
|
||||
<MetaTokenSetup
|
||||
onSaved={() => {
|
||||
status.reload()
|
||||
businesses.reload()
|
||||
pages.reload()
|
||||
accounts.reload()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
{status.loading || !connection ? <MetaLoading /> : (
|
||||
<div className="meta-split">
|
||||
<article className="panel">
|
||||
|
|
@ -52,11 +63,11 @@ export function MetaConnectionsPage() {
|
|||
<p><span>Selected Page</span><strong>{connection.selected_page_name || 'Not selected'}</strong></p>
|
||||
<p><span>Selected ad account</span><strong>{connection.selected_ad_account_name || 'Not selected'}</strong></p>
|
||||
</div>
|
||||
) : <MetaEmpty title="No Meta user is connected" body="Start Facebook Login for Business. Urmi will only list assets that login grants to this workspace." />}
|
||||
) : <MetaEmpty title="Facebook is not connected" body="Sign in with Facebook, or save an access token above. Urmi only lists the Pages and ad accounts that connection allows." />}
|
||||
</article>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><h2>Granted permissions</h2></div>
|
||||
{connection.scopes.length === 0 ? <MetaEmpty title="No permissions granted" body="Permissions appear here after a successful login. A screen is not available just because it is in the menu." /> : (
|
||||
{connection.scopes.length === 0 ? <MetaEmpty title="No permissions yet" body="Permissions appear here after you sign in. A page in the menu is available only when Facebook allows it." /> : (
|
||||
<ul className="meta-list">
|
||||
{connection.scopes.map((scope) => <li key={scope.scope}><strong>{scope.label}</strong><span>Granted</span></li>)}
|
||||
</ul>
|
||||
|
|
@ -69,7 +80,7 @@ export function MetaConnectionsPage() {
|
|||
title="Business portfolio"
|
||||
loading={businesses.loading}
|
||||
error={gatedError(connection, 'business', 'business_management', 'Business portfolio', businesses.error)}
|
||||
empty="Meta did not return a business portfolio for this login."
|
||||
empty="Facebook did not return a business portfolio for this login."
|
||||
>
|
||||
{businesses.data?.businesses.map((item) => <li key={item.name}><strong>{item.name}</strong></li>)}
|
||||
</Section>
|
||||
|
|
@ -77,15 +88,15 @@ export function MetaConnectionsPage() {
|
|||
title="Pages"
|
||||
loading={pages.loading}
|
||||
error={gatedError(connection, 'pages', 'pages_show_list', 'Page list', pages.error)}
|
||||
empty="Meta did not return any Pages for this login."
|
||||
empty="Facebook did not return any Pages for this login."
|
||||
>
|
||||
{pages.data?.pages.map((page) => <li key={page.id}><strong>{page.name}</strong><span>{page.category}</span></li>)}
|
||||
</Section>
|
||||
<Section
|
||||
title="Ad accounts"
|
||||
loading={accounts.loading}
|
||||
error={gatedError(connection, 'ads_read', 'ads_read', 'Ads reporting', accounts.error, connection?.capabilities.business)}
|
||||
empty="Meta did not return any ad accounts for this login."
|
||||
error={gatedError(connection, 'ads_read', 'ads_read', 'Ads reporting', accounts.error, connection?.capabilities.ads_manage)}
|
||||
empty="Facebook did not return any ad accounts for this login."
|
||||
>
|
||||
{accounts.data?.accounts.map((account) => <li key={account.id}><strong>{account.name}</strong><span>{account.currency} · {account.status}</span></li>)}
|
||||
</Section>
|
||||
|
|
@ -94,6 +105,83 @@ export function MetaConnectionsPage() {
|
|||
)
|
||||
}
|
||||
|
||||
function MetaTokenSetup({ onSaved, onError }: { onSaved: () => void; onError: (error: MetaRequestError | null) => void }) {
|
||||
const [token, setToken] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
onError(null)
|
||||
try {
|
||||
await metaApi.saveToken(token.trim())
|
||||
setToken('')
|
||||
setSaved(true)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
onError(err instanceof MetaRequestError ? err : new MetaRequestError('This token could not be saved'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel meta-token-setup" onSubmit={submit}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Use an access token</h2>
|
||||
<p>Paste a user access token from Meta instead of signing in here. Urmi checks it, stores it for this workspace, and does not show it again.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="explorer-input">
|
||||
<strong>Access token</strong>
|
||||
<span>It needs permission to see your Pages, business, or ad accounts.</span>
|
||||
<input type="password" value={token} onChange={(event) => setToken(event.target.value)} autoComplete="off" required />
|
||||
</label>
|
||||
<div className="meta-token-actions">
|
||||
<button className="primary-button" type="submit" disabled={saving || token.trim().length < 20}>
|
||||
{saving ? 'Checking token…' : 'Save token'}
|
||||
</button>
|
||||
{saved && <span>Token saved for this workspace.</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaOAuthSetup({ redirectUri }: { redirectUri: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(redirectUri)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="panel meta-oauth-setup">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>If Facebook blocks the connection</h2>
|
||||
<p>Add this exact return address in your Facebook app before you connect. A “URL Blocked” message means it is missing.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="meta-oauth-steps">
|
||||
<li>Open <a href="https://developers.facebook.com/apps/" target="_blank" rel="noreferrer">your Facebook app</a> → <strong>Use cases</strong> or <strong>Facebook Login</strong> → <strong>Settings</strong>.</li>
|
||||
<li>Turn on <strong>Client OAuth login</strong> and <strong>Web OAuth login</strong>.</li>
|
||||
<li>Paste the return address below under <strong>Valid OAuth redirect URIs</strong>.</li>
|
||||
<li>Under <strong>Settings → Basic</strong>, set <strong>App domains</strong> to your site (for Urmi: <code>urmiai.com</code>).</li>
|
||||
<li>Save, wait a minute, then choose Connect Facebook again.</li>
|
||||
</ol>
|
||||
<div className="meta-oauth-uri">
|
||||
<code>{redirectUri}</code>
|
||||
<button type="button" className="secondary-button" onClick={copy}><Copy size={14} />{copied ? 'Copied' : 'Copy address'}</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function gatedError(
|
||||
connection: MetaConnection | null | undefined,
|
||||
capability: keyof MetaConnection['capabilities'],
|
||||
|
|
@ -103,9 +191,9 @@ function gatedError(
|
|||
alsoAllowed = false,
|
||||
) {
|
||||
if (!connection) return error
|
||||
if (!connection.connected) return new MetaRequestError('Connect Meta before using this screen.', 'not_connected')
|
||||
if (!connection.connected) return new MetaRequestError('Connect Facebook before using this screen.', 'not_connected')
|
||||
if (!connection.capabilities[capability] && !alsoAllowed) {
|
||||
return new MetaRequestError(`Meta has not granted ${label} for this connection.`, 'permission_denied', permission)
|
||||
return new MetaRequestError(`Facebook has not allowed ${label} for this connection.`, 'permission_denied', permission)
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ import { MetaEmpty, MetaLoading, MetaScreen, StatusPill, auditLabel, formatCount
|
|||
|
||||
export function MetaOverviewPage() {
|
||||
const { data, loading } = useMetaLoad('overview', () => metaApi.overview())
|
||||
if (loading || !data) return <MetaScreen title="Overview" subtitle="Connected Meta assets for this workspace."><MetaLoading /></MetaScreen>
|
||||
if (loading || !data) return <MetaScreen title="Overview" subtitle="Pages, ad accounts, and results for this workspace."><MetaLoading /></MetaScreen>
|
||||
const connection = data.connection
|
||||
return (
|
||||
<MetaScreen
|
||||
title="Overview"
|
||||
subtitle="Pages, ad accounts, campaigns, and delivery that this Meta authorization can actually see."
|
||||
actions={!connection.connected ? <Link className="primary-button" to="/console/meta/connections">Connect Meta</Link> : undefined}
|
||||
subtitle="What this Facebook connection can see: Pages, ad accounts, campaigns, and results."
|
||||
actions={!connection.connected ? <Link className="primary-button" to="/console/meta/connections">Connect Facebook</Link> : undefined}
|
||||
>
|
||||
<div className="meta-card-grid">
|
||||
<AssetCard title="Meta Business" state={data.businesses.state} message={data.businesses.message} empty="No business portfolio was returned for this login.">
|
||||
<AssetCard title="Business" state={data.businesses.state} message={data.businesses.message} empty="No business portfolio was returned for this login.">
|
||||
{data.businesses.items.map((item) => <p key={item.name}><strong>{item.name}</strong><StatusPill value={connection.connected ? 'Connected' : connection.status} /></p>)}
|
||||
</AssetCard>
|
||||
<AssetCard title="Facebook Pages" state={data.pages.state} message={data.pages.message} empty="No Pages are available to this login.">
|
||||
|
|
@ -41,8 +41,8 @@ export function MetaOverviewPage() {
|
|||
|
||||
<div className="meta-split">
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><h2>Recent Meta activity</h2></div>
|
||||
{data.activity.length === 0 ? <MetaEmpty title="No Meta actions yet" body="Connect, publish, pause, and disconnect events for this workspace appear here." /> : (
|
||||
<div className="panel-heading"><h2>Recent activity</h2></div>
|
||||
{data.activity.length === 0 ? <MetaEmpty title="No advertising activity yet" body="Connecting, publishing, pausing, and disconnecting show up here." /> : (
|
||||
<ul className="meta-activity">
|
||||
{data.activity.map((event) => (
|
||||
<li key={`${event.action}-${event.created_at}`}>
|
||||
|
|
@ -64,7 +64,7 @@ export function MetaOverviewPage() {
|
|||
<p><span>Permissions</span><strong>{connection.scopes.map((scope) => scope.label).join(', ') || 'None granted'}</strong></p>
|
||||
</div>
|
||||
) : (
|
||||
<MetaEmpty title="Nothing is connected" body={connection.last_error || 'Authorize Meta to load Pages, the business portfolio, and ad accounts for this workspace.'} to="/console/meta/connections" action="Connect Meta" />
|
||||
<MetaEmpty title="Nothing is connected" body={connection.last_error || 'Connect Facebook to load Pages, your business, and ad accounts for this workspace.'} to="/console/meta/connections" action="Connect Facebook" />
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -74,7 +74,7 @@ export function MetaOverviewPage() {
|
|||
|
||||
function performanceNote(state: string, message: string) {
|
||||
if (state === 'ok') return 'Last 30 days'
|
||||
if (state === 'not_connected') return 'Connect Meta to load delivery'
|
||||
if (state === 'not_connected') return 'Connect Facebook to see results'
|
||||
return message || 'Delivery is not available'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ export function MetaPagesPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<MetaScreen title="Facebook Pages" subtitle="Pages this Meta login can administer. Engagement is shown only when that permission was granted.">
|
||||
<MetaScreen title="Facebook Pages" subtitle="Pages this Facebook login can manage. Follower and post activity appear when Facebook allows it.">
|
||||
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.pages.length === 0 ? (
|
||||
<MetaEmpty title="No Pages returned" body="Meta did not include any Facebook Pages for this authorization." to="/console/meta/connections" action="Review connection" />
|
||||
<MetaEmpty title="No Pages yet" body="Facebook did not include any Pages for this connection." to="/console/meta/connections" action="Review connection" />
|
||||
) : (
|
||||
<div className="meta-table-wrap panel">
|
||||
<table className="meta-table">
|
||||
|
|
@ -59,11 +59,11 @@ export function MetaPagesPage() {
|
|||
<div className="meta-kv">
|
||||
<p><span>Username</span><strong>{detail.data.page.username ? `@${detail.data.page.username}` : '—'}</strong></p>
|
||||
<p><span>Followers</span><strong>{formatCount(detail.data.page.fan_count)}</strong></p>
|
||||
<p><span>Tasks Meta returned</span><strong>{detail.data.page.tasks.join(', ') || '—'}</strong></p>
|
||||
<p><span>What you can do</span><strong>{detail.data.page.tasks.join(', ') || '—'}</strong></p>
|
||||
</div>
|
||||
<p className="meta-muted">{detail.data.engagement.note}</p>
|
||||
{detail.data.engagement.error ? <MetaState error={new MetaRequestError(detail.data.engagement.error.message, detail.data.engagement.error.code, detail.data.engagement.error.permission)} /> : detail.data.posts.length === 0 ? (
|
||||
<MetaEmpty title="No recent posts" body="Meta returned no posts for this Page." />
|
||||
<MetaEmpty title="No recent posts" body="Facebook returned no posts for this Page." />
|
||||
) : (
|
||||
<ul className="meta-posts">
|
||||
{detail.data.posts.map((post) => (
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ export function MetaSettingsPage() {
|
|||
const connection = view.data?.connection
|
||||
return (
|
||||
<MetaScreen
|
||||
title="Meta settings"
|
||||
subtitle="Permissions, connection health, and disconnect for this workspace."
|
||||
title="Ad settings"
|
||||
subtitle="Connection health, permissions, and disconnect for this workspace."
|
||||
actions={
|
||||
<>
|
||||
<button className="secondary-button" type="button" onClick={() => setDebug((value) => !value)}>Troubleshooting</button>
|
||||
|
|
@ -65,16 +65,16 @@ export function MetaSettingsPage() {
|
|||
</article>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><h2>Permissions</h2></div>
|
||||
{connection.scopes.length === 0 ? <MetaEmpty title="No granted permissions" body="Reconnect Meta and accept the permissions this workspace needs." /> : (
|
||||
{connection.scopes.length === 0 ? <MetaEmpty title="No permissions yet" body="Reconnect Facebook and accept the access this workspace needs." /> : (
|
||||
<ul className="meta-list">{connection.scopes.map((scope) => <li key={scope.scope}><strong>{scope.label}</strong><span>Granted</span></li>)}</ul>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
<div className="meta-split">
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><h2>Webhooks</h2></div>
|
||||
<p className="meta-muted">Urmi does not subscribe a separate ads webhook. WhatsApp message webhooks stay on the WhatsApp connection.</p>
|
||||
{view.data.whatsapp.length === 0 ? <MetaEmpty title="No WhatsApp connection" body="WhatsApp webhook health appears when a WhatsApp Business account is connected." /> : (
|
||||
<div className="panel-heading"><h2>Message updates</h2></div>
|
||||
<p className="meta-muted">Ad results update inside Urmi. WhatsApp message updates stay with your WhatsApp connection.</p>
|
||||
{view.data.whatsapp.length === 0 ? <MetaEmpty title="No WhatsApp connection" body="WhatsApp delivery health appears when a WhatsApp Business account is connected." /> : (
|
||||
<ul className="meta-list">
|
||||
{view.data.whatsapp.map((account) => <li key={account.name}><strong>{account.name}</strong><StatusPill value={account.webhooks} /></li>)}
|
||||
</ul>
|
||||
|
|
@ -82,7 +82,7 @@ export function MetaSettingsPage() {
|
|||
</article>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><h2>Audit trail</h2></div>
|
||||
{view.data.audit.length === 0 ? <MetaEmpty title="No Meta actions yet" body="Publishing, status changes, and disconnects for this workspace are recorded here." /> : (
|
||||
{view.data.audit.length === 0 ? <MetaEmpty title="No advertising activity yet" body="Publishing, status changes, and disconnects for this workspace are recorded here." /> : (
|
||||
<ul className="meta-activity">
|
||||
{view.data.audit.map((event) => (
|
||||
<li key={`${event.action}-${event.created_at}`}>
|
||||
|
|
@ -97,13 +97,13 @@ export function MetaSettingsPage() {
|
|||
</div>
|
||||
{debug && (
|
||||
<aside className="panel meta-debug">
|
||||
<div className="panel-heading"><h2>Troubleshooting</h2><p>Identifiers are masked. Tokens are not shown.</p></div>
|
||||
<div className="panel-heading"><h2>Troubleshooting</h2><p>Reference numbers are shortened. Sign-in secrets are never shown.</p></div>
|
||||
<div className="meta-kv">
|
||||
<p><span>App configured</span><strong>{view.data.debug.app_configured ? 'Yes' : 'No'}</strong></p>
|
||||
<p><span>Redirect configured</span><strong>{view.data.debug.redirect_configured ? 'Yes' : 'No'}</strong></p>
|
||||
<p><span>Login config</span><strong>{view.data.debug.login_config_set ? 'Set' : 'Not set'}</strong></p>
|
||||
<p><span>Granted scopes</span><strong>{view.data.debug.granted_scopes.join(', ') || 'None'}</strong></p>
|
||||
<p><span>Last Graph code</span><strong>{view.data.debug.last_graph_code ?? '—'}</strong></p>
|
||||
<p><span>Facebook app ready</span><strong>{view.data.debug.app_configured ? 'Yes' : 'No'}</strong></p>
|
||||
<p><span>Return address</span><strong>{view.data.debug.oauth_redirect_uri || '—'}</strong></p>
|
||||
<p><span>Sign-in setup</span><strong>{view.data.debug.login_config_set ? 'Ready' : 'Not set'}</strong></p>
|
||||
<p><span>Permissions on file</span><strong>{view.data.debug.granted_scopes.join(', ') || 'None'}</strong></p>
|
||||
<p><span>Last Facebook code</span><strong>{view.data.debug.last_graph_code ?? '—'}</strong></p>
|
||||
<p><span>Last error</span><strong>{view.data.debug.last_error || 'None'}</strong></p>
|
||||
<p><span>User reference</span><strong>{view.data.debug.meta_user_ref || '—'}</strong></p>
|
||||
<p><span>Page reference</span><strong>{view.data.debug.page_ref || '—'}</strong></p>
|
||||
|
|
@ -115,9 +115,9 @@ export function MetaSettingsPage() {
|
|||
)}
|
||||
{confirm && (
|
||||
<ConfirmDialog
|
||||
title="Disconnect Meta?"
|
||||
body="Urmi deletes the stored token for this workspace and stops loading Pages, ad accounts, and campaigns. WhatsApp connections are left as they are. You can authorize Meta again later."
|
||||
confirmLabel="Disconnect Meta"
|
||||
title="Disconnect Facebook?"
|
||||
body="Urmi removes the saved Facebook connection for this workspace and stops loading Pages, ad accounts, and campaigns. WhatsApp stays as it is. You can connect Facebook again later."
|
||||
confirmLabel="Disconnect Facebook"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirm(false)}
|
||||
onConfirm={disconnect}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export type MetaConnection = {
|
|||
selected_page_name: string
|
||||
selected_ad_account_name: string
|
||||
last_error: string
|
||||
oauth_redirect_uri: string
|
||||
}
|
||||
|
||||
export type MetaPage = {
|
||||
|
|
@ -78,10 +79,24 @@ export type MetaCampaign = {
|
|||
reach: number | null
|
||||
}
|
||||
|
||||
export type MetaAdSet = {
|
||||
id: string
|
||||
name: string
|
||||
campaign_id: string
|
||||
campaign_name: string
|
||||
status: string
|
||||
optimization_goal: string
|
||||
billing_event: string
|
||||
budget: string | null
|
||||
start_time: string | null
|
||||
end_time: string | null
|
||||
}
|
||||
|
||||
export type MetaAd = {
|
||||
id: string
|
||||
name: string
|
||||
campaign_name: string
|
||||
adset_name: string
|
||||
status: string
|
||||
headline: string
|
||||
message: string
|
||||
|
|
@ -124,6 +139,8 @@ async function metaRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
export const metaApi = {
|
||||
status: () => metaRequest<MetaConnection>('/status'),
|
||||
start: () => metaRequest<{ authorization_url: string }>('/oauth/start'),
|
||||
saveToken: (accessToken: string) =>
|
||||
metaRequest<MetaConnection>('/token', { method: 'POST', body: JSON.stringify({ access_token: accessToken }) }),
|
||||
disconnect: () => metaRequest('/disconnect', { method: 'POST', body: JSON.stringify({ confirm: true }) }),
|
||||
overview: () => metaRequest<MetaOverview>('/overview'),
|
||||
businesses: () => metaRequest<{ businesses: { name: string }[] }>('/businesses'),
|
||||
|
|
@ -133,6 +150,8 @@ export const metaApi = {
|
|||
adAccounts: () => metaRequest<{ accounts: MetaAdAccount[]; selected_name: string }>('/ad-accounts'),
|
||||
selectAdAccount: (id: string) => metaRequest('/ad-accounts/select', { method: 'POST', body: JSON.stringify({ id, confirm: true }) }),
|
||||
campaigns: () => metaRequest<{ account_name: string; currency: string; campaigns: MetaCampaign[]; can_manage: boolean; insights_error: { code: string; message: string } | null }>('/campaigns'),
|
||||
campaign: (id: string) => metaRequest<MetaCampaignDetail>(`/campaigns/${id}`),
|
||||
adsets: (campaignId?: string) => metaRequest<{ account_name: string; currency: string; adsets: MetaAdSet[] }>(campaignId ? `/adsets?campaign_id=${campaignId}` : '/adsets'),
|
||||
publishCampaign: (payload: Record<string, unknown>) => metaRequest<{ name: string; status: string }>('/campaigns', { method: 'POST', body: JSON.stringify({ ...payload, confirm: true }) }),
|
||||
editCampaign: (id: string, payload: { name?: string; daily_budget?: number }) =>
|
||||
metaRequest(`/campaigns/${id}`, { method: 'POST', body: JSON.stringify({ ...payload, confirm: true }) }),
|
||||
|
|
@ -164,6 +183,28 @@ export type MetaOverview = {
|
|||
activity: MetaAudit[]
|
||||
}
|
||||
|
||||
export type MetaCampaignDetail = {
|
||||
account_name: string
|
||||
currency: string
|
||||
can_manage: boolean
|
||||
campaign: {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
objective: string
|
||||
created_time: string | null
|
||||
}
|
||||
adsets: MetaAdSet[]
|
||||
metrics: {
|
||||
spend: string | null
|
||||
impressions: number | null
|
||||
clicks: number | null
|
||||
reach: number | null
|
||||
ctr: number | null
|
||||
}
|
||||
insights_error: { code: string; message: string } | null
|
||||
}
|
||||
|
||||
export type MetaAnalytics = {
|
||||
account_name: string
|
||||
currency: string
|
||||
|
|
@ -179,6 +220,18 @@ export type MetaAnalytics = {
|
|||
cpm: string | null
|
||||
}
|
||||
series: { date: string; spend: number; impressions: number; clicks: number }[]
|
||||
campaigns: MetaInsightRow[]
|
||||
adsets: MetaInsightRow[]
|
||||
ads: MetaInsightRow[]
|
||||
}
|
||||
|
||||
export type MetaInsightRow = {
|
||||
name: string
|
||||
spend: string | null
|
||||
impressions: number | null
|
||||
clicks: number | null
|
||||
ctr: number | null
|
||||
reach: number | null
|
||||
}
|
||||
|
||||
export type MetaSettings = {
|
||||
|
|
@ -189,6 +242,7 @@ export type MetaSettings = {
|
|||
debug: {
|
||||
app_configured: boolean
|
||||
redirect_configured: boolean
|
||||
oauth_redirect_uri: string
|
||||
login_config_set: boolean
|
||||
granted_scopes: string[]
|
||||
last_error: string
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function MetaScreen({
|
|||
<section className="meta-page">
|
||||
<div className="welcome-row">
|
||||
<div>
|
||||
<span className="eyebrow">Meta</span>
|
||||
<span className="eyebrow">Advertising</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
|
|
@ -68,24 +68,24 @@ export function MetaState({ error }: { error: MetaRequestError }) {
|
|||
return (
|
||||
<div className={`meta-state ${denied ? 'is-denied' : expired ? 'is-expired' : ''}`}>
|
||||
<strong>
|
||||
{error.code === 'not_configured' && 'Meta Login is not configured'}
|
||||
{error.code === 'not_connected' && 'Meta is not connected'}
|
||||
{expired && 'Meta access has expired'}
|
||||
{error.code === 'not_configured' && 'Facebook sign-in is not set up yet'}
|
||||
{error.code === 'not_connected' && 'Facebook is not connected'}
|
||||
{expired && 'Facebook access has expired'}
|
||||
{denied && 'This connection does not include that permission'}
|
||||
{needsAccount && 'Choose an asset first'}
|
||||
{error.code === 'feature_unavailable' && 'This Meta feature is not available'}
|
||||
{!['not_configured', 'not_connected', 'token_expired', 'permission_denied', 'no_ad_account', 'no_page', 'feature_unavailable'].includes(error.code) && 'Meta could not complete this request'}
|
||||
{needsAccount && 'Choose an account or Page first'}
|
||||
{error.code === 'feature_unavailable' && 'This is not available on your Facebook connection'}
|
||||
{!['not_configured', 'not_connected', 'token_expired', 'permission_denied', 'no_ad_account', 'no_page', 'feature_unavailable'].includes(error.code) && 'Facebook could not complete this request'}
|
||||
</strong>
|
||||
<p>{error.message}</p>
|
||||
{disconnected && <Link to="/console/meta/connections">Open connections</Link>}
|
||||
{expired && <Link to="/console/meta/settings">Reconnect Meta</Link>}
|
||||
{needsAccount && <Link to={error.code === 'no_page' ? '/console/meta/pages' : '/console/meta/ad-accounts'}>Choose an asset</Link>}
|
||||
{disconnected && <Link to="/console/meta/connections">Connect Facebook</Link>}
|
||||
{expired && <Link to="/console/meta/settings">Reconnect Facebook</Link>}
|
||||
{needsAccount && <Link to={error.code === 'no_page' ? '/console/meta/pages' : '/console/meta/ad-accounts'}>{error.code === 'no_page' ? 'Choose a Page' : 'Choose an ad account'}</Link>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetaLoading() {
|
||||
return <div className="meta-state">Loading this workspace’s Meta data…</div>
|
||||
return <div className="meta-state">Loading your Facebook data…</div>
|
||||
}
|
||||
|
||||
export function MetaEmpty({ title, body, to, action }: { title: string; body: string; to?: string; action?: string }) {
|
||||
|
|
@ -142,8 +142,8 @@ export function formatCount(value: number | null | undefined) {
|
|||
|
||||
export function auditLabel(action: string) {
|
||||
const labels: Record<string, string> = {
|
||||
'connection.authorized': 'Meta connected',
|
||||
'connection.disconnected': 'Meta disconnected',
|
||||
'connection.authorized': 'Facebook connected',
|
||||
'connection.disconnected': 'Facebook disconnected',
|
||||
'page.selected': 'Page selected',
|
||||
'ad_account.selected': 'Ad account selected',
|
||||
'campaign.publish': 'Campaign published',
|
||||
|
|
@ -157,8 +157,32 @@ export function auditLabel(action: string) {
|
|||
return labels[action] || action
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: 'Active',
|
||||
PAUSED: 'Paused',
|
||||
connected: 'Connected',
|
||||
expired: 'Expired',
|
||||
Disabled: 'Off',
|
||||
Active: 'Active',
|
||||
}
|
||||
|
||||
export function plainLabel(value: string) {
|
||||
const known: Record<string, string> = {
|
||||
OUTCOME_TRAFFIC: 'Traffic',
|
||||
OUTCOME_ENGAGEMENT: 'Engagement',
|
||||
OUTCOME_AWARENESS: 'Awareness',
|
||||
OUTCOME_LEADS: 'Leads',
|
||||
OUTCOME_SALES: 'Sales',
|
||||
OUTCOME_APP_PROMOTION: 'App promotion',
|
||||
}
|
||||
if (!value) return '—'
|
||||
if (known[value]) return known[value]
|
||||
if (STATUS_LABELS[value]) return STATUS_LABELS[value]
|
||||
return value.replaceAll('_', ' ').toLowerCase().replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
export function StatusPill({ value }: { value: string }) {
|
||||
const live = value === 'ACTIVE' || value === 'Active' || value === 'connected'
|
||||
const paused = value === 'PAUSED' || value === 'Disabled' || value === 'expired'
|
||||
return <span className={`meta-pill ${live ? 'is-live' : paused ? 'is-paused' : ''}`}>{value || 'Unknown'}</span>
|
||||
return <span className={`meta-pill ${live ? 'is-live' : paused ? 'is-paused' : ''}`}>{plainLabel(value)}</span>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ export function BuyNumbersPage() {
|
|||
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>
|
||||
<span className="eyebrow">Phone</span>
|
||||
<h1>Get a number</h1>
|
||||
<p>Choose a phone number. You pay a monthly fee, plus a per-minute rate for calls you receive and place.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel numbers-panel">
|
||||
|
|
@ -69,7 +69,7 @@ export function BuyNumbersPage() {
|
|||
{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>
|
||||
<span>Ask your account manager to add numbers, or check <Link to="/console/numbers">Your numbers</Link> for ones already on this account.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="numbers-table numbers-table-pricing">
|
||||
|
|
@ -132,15 +132,15 @@ export function MyNumbersPage() {
|
|||
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>
|
||||
<span className="eyebrow">Phone</span>
|
||||
<h1>Your numbers</h1>
|
||||
<p>Numbers on this account, whether you bought them or they were assigned to you.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel numbers-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Your inventory</h2>
|
||||
<h2>Numbers on this account</h2>
|
||||
<p>{numbers.length} active number{numbers.length === 1 ? '' : 's'}</p>
|
||||
</div>
|
||||
<Network size={19} />
|
||||
|
|
@ -149,7 +149,7 @@ export function MyNumbersPage() {
|
|||
{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>
|
||||
<span>Browse <Link to="/console/numbers/buy">Get a number</Link>, or ask your account manager to assign one.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="numbers-table numbers-table-pricing">
|
||||
|
|
|
|||
|
|
@ -236,13 +236,13 @@ function MessagingApiKeyTest({
|
|||
}
|
||||
|
||||
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'
|
||||
? `Paste a full key from Access keys. Active prefix: ${apiKeys.find((item) => item.status === 'active')?.key_prefix}…`
|
||||
: 'Create an access key under Connections → Access keys'
|
||||
|
||||
return (
|
||||
<ApiTestCard
|
||||
title="Send template SMS"
|
||||
summary="API key authentication for a single recipient."
|
||||
title="Send one text"
|
||||
summary="Send one text message with an access key."
|
||||
method="POST"
|
||||
path="/api/v1/messages/send"
|
||||
running={running}
|
||||
|
|
@ -251,7 +251,7 @@ function MessagingApiKeyTest({
|
|||
error={error}
|
||||
runDisabled={!apiKey.trim() || !senderId || !templateId || !message.trim() || approvedSenders.length === 0}
|
||||
>
|
||||
<TestField label="API key" hint={keyHint}>
|
||||
<TestField label="Access 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">
|
||||
|
|
@ -359,8 +359,8 @@ function MessagingConsoleTest({
|
|||
|
||||
return (
|
||||
<ApiTestCard
|
||||
title="Send SMS from console"
|
||||
summary="Session-authenticated batch send used by the compose screen."
|
||||
title="Send while signed in"
|
||||
summary="Send a batch the same way the message screen does, using your signed-in account."
|
||||
method="POST"
|
||||
path="/api/v1/messaging/send"
|
||||
running={running}
|
||||
|
|
@ -369,7 +369,7 @@ function MessagingConsoleTest({
|
|||
error={error}
|
||||
runDisabled={!senderConfigId || !templateId || !message.trim() || approvedSenders.length === 0}
|
||||
>
|
||||
<TestField label="Sender ID" hint="Pre-filled from your approved sender configs">
|
||||
<TestField label="Sender ID" hint="Filled in from your approved senders">
|
||||
<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) => (
|
||||
|
|
@ -487,22 +487,22 @@ function UrmiOtpGenerateTest({
|
|||
|
||||
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'
|
||||
: 'Create an access key under Connections → Access keys'
|
||||
|
||||
return (
|
||||
<ApiTestCard
|
||||
title="Generate verification OTP"
|
||||
summary="Unified UrmiOTP delivery API for approved projects."
|
||||
title="Send a verification code"
|
||||
summary="Send a verification code for an approved project."
|
||||
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."
|
||||
notice="Uses your signed-in account when no access key is set. Add a key to try the same send your own system would make. Each run is saved in Activity history and Performance."
|
||||
running={running}
|
||||
onRun={runTest}
|
||||
response={response}
|
||||
error={error}
|
||||
runDisabled={!projectName || approvedProjects.length === 0}
|
||||
>
|
||||
<TestField label="API key (optional)" hint={keyHint}>
|
||||
<TestField label="Access 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">
|
||||
|
|
@ -570,20 +570,20 @@ export function TestingCenterPage() {
|
|||
setExpanded((current) => (current === id ? null : id))
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading Testing Center…</div>
|
||||
if (loading) return <div className="auth-loading">Loading send options…</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>
|
||||
<span className="eyebrow">Insights</span>
|
||||
<h1>Send a test</h1>
|
||||
<p>Try a message with your approved senders, templates, and verification projects already filled in.</p>
|
||||
</div>
|
||||
|
||||
<TestingAccordion
|
||||
id="messaging"
|
||||
title="Messaging"
|
||||
description="Send DLT-approved SMS using API keys or your signed-in console session."
|
||||
title="Text message"
|
||||
description="Send an approved text with an access key, or while you are signed in."
|
||||
icon={MessageSquareText}
|
||||
open={expanded === 'messaging'}
|
||||
onToggle={toggleGroup}
|
||||
|
|
@ -594,8 +594,8 @@ export function TestingCenterPage() {
|
|||
|
||||
<TestingAccordion
|
||||
id="urmi-otp"
|
||||
title="URMI OTP"
|
||||
description="Generate and deliver verification codes from approved UrmiOTP projects."
|
||||
title="Verification code"
|
||||
description="Send a code from an approved verification project."
|
||||
icon={KeyRound}
|
||||
open={expanded === 'urmi-otp'}
|
||||
onToggle={toggleGroup}
|
||||
|
|
@ -609,7 +609,7 @@ export function TestingCenterPage() {
|
|||
<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>
|
||||
<span>Add sender IDs and templates, or register a verification project, then return here.</span>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export function VoicePage() {
|
|||
|
||||
socket.onopen = () => {
|
||||
setConsoleConnected(true)
|
||||
pushEvent('Console audio bridge connected')
|
||||
pushEvent('Call audio connected')
|
||||
}
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
|
|
@ -156,7 +156,7 @@ export function VoicePage() {
|
|||
|
||||
socket.onclose = () => {
|
||||
setConsoleConnected(false)
|
||||
pushEvent('Console audio bridge disconnected')
|
||||
pushEvent('Call audio disconnected')
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
|
|
@ -229,7 +229,7 @@ export function VoicePage() {
|
|||
async function startOutboundCall(useApiKey: boolean) {
|
||||
setError('')
|
||||
if (!consoleConnected) {
|
||||
setError('Console audio bridge is not connected — refresh this page')
|
||||
setError('Call audio is not connected yet. Refresh this page.')
|
||||
return
|
||||
}
|
||||
if (audioState !== 'granted') {
|
||||
|
|
@ -255,16 +255,15 @@ export function VoicePage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading voice console…</div>
|
||||
if (loading) return <div className="auth-loading">Loading calls…</div>
|
||||
|
||||
return (
|
||||
<div className="voice-page">
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">Numbers</span>
|
||||
<h1>Voice</h1>
|
||||
<span className="eyebrow">Phone</span>
|
||||
<h1>Calls</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.
|
||||
Place a test call from one of your numbers. Audio plays through this computer, and your microphone is what the other person hears.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -273,13 +272,13 @@ export function VoicePage() {
|
|||
<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>
|
||||
<h2>Call audio</h2>
|
||||
<p>Allow the microphone so you can hear and speak on this call.</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>
|
||||
<span>{consoleConnected ? 'Ready for a call' : 'Connecting…'}</span>
|
||||
</div>
|
||||
<div className="voice-audio-panel">
|
||||
<p className="voice-events-empty">
|
||||
|
|
@ -308,10 +307,10 @@ export function VoicePage() {
|
|||
</div>
|
||||
{!numbers.length && (
|
||||
<div className="numbers-empty">
|
||||
<strong>No purchased numbers</strong>
|
||||
<strong>No numbers yet</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.
|
||||
Get one from <Link to="/console/numbers/buy">Get a number</Link> or check{' '}
|
||||
<Link to="/console/numbers">Your numbers</Link>. Calls can only start from a number on this account.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -320,13 +319,13 @@ export function VoicePage() {
|
|||
<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>
|
||||
<h2>Call activity</h2>
|
||||
<p>What happens on this call, while you stay on the page.</p>
|
||||
</div>
|
||||
<Radio size={18} />
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="voice-events-empty">No call events yet.</p>
|
||||
<p className="voice-events-empty">No call activity yet.</p>
|
||||
) : (
|
||||
<ul className="voice-events">
|
||||
{events.map((event) => (
|
||||
|
|
@ -344,16 +343,16 @@ export function VoicePage() {
|
|||
<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>
|
||||
<h2>Place a call</h2>
|
||||
<p>Call someone from a number on this account.</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>
|
||||
<strong>From</strong>
|
||||
<span>A number already on this 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) => (
|
||||
|
|
@ -373,8 +372,8 @@ export function VoicePage() {
|
|||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>API key (optional)</strong>
|
||||
<span>Leave empty to use your console session cookie.</span>
|
||||
<strong>Access key (optional)</strong>
|
||||
<span>Leave this empty to place the call while you are signed in.</span>
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
|
|
@ -404,23 +403,23 @@ export function VoicePage() {
|
|||
<section className="panel explorer-endpoint-card voice-api-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>API for your projects</h2>
|
||||
<h2>Place calls from your own systems</h2>
|
||||
<p>{docs?.summary}</p>
|
||||
</div>
|
||||
<ShieldCheck size={18} />
|
||||
</div>
|
||||
<div className="explorer-field-row">
|
||||
<span>Outbound</span>
|
||||
<span>Place a call</span>
|
||||
<code>POST {API_BASE_URL}/api/v1/voice/calls</code>
|
||||
</div>
|
||||
<div className="explorer-field-row">
|
||||
<span>Status</span>
|
||||
<span>Check a call</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>
|
||||
<Link className="ghost-button" to="/console/api-explorer">See a ready-made example</Link>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
|
||||
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
whatsappApi,
|
||||
type WhatsAppAccount,
|
||||
|
|
@ -32,16 +32,16 @@ type FacebookSdk = {
|
|||
}
|
||||
|
||||
const STEPS = [
|
||||
'Connect or create a Meta business',
|
||||
'Create or select a WhatsApp Business Account',
|
||||
'Sign in to your Facebook business',
|
||||
'Choose or create a WhatsApp Business account',
|
||||
'Add and verify a business phone number',
|
||||
'Grant Urmi access to manage messaging',
|
||||
'Allow Urmi to send and receive messages',
|
||||
]
|
||||
|
||||
const WHY = [
|
||||
['Send and receive messages', 'Use the number you verify with Meta for customer conversations.'],
|
||||
['Manage templates', 'Review template status and quality for each WhatsApp Business Account.'],
|
||||
['Multiple numbers', 'Connect the businesses, accounts, and numbers Meta makes available to you.'],
|
||||
['Send and receive messages', 'Use the number you verify with Facebook for customer conversations.'],
|
||||
['Manage templates', 'Review template status and quality for each WhatsApp account.'],
|
||||
['Multiple numbers', 'Connect the businesses, accounts, and numbers Facebook makes available to you.'],
|
||||
['Built for businesses of all sizes', 'Each connection stays inside this Urmi workspace.'],
|
||||
]
|
||||
|
||||
|
|
@ -138,6 +138,10 @@ export function WhatsAppSetupPage() {
|
|||
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
|
||||
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [tokenValue, setTokenValue] = useState('')
|
||||
const [wabaId, setWabaId] = useState('')
|
||||
const [phoneNumberId, setPhoneNumberId] = useState('')
|
||||
const [pins, setPins] = useState<Record<string, string>>({})
|
||||
const sessionRef = useRef<SignupSession | null>(null)
|
||||
const codeRef = useRef<string | null>(null)
|
||||
|
|
@ -257,6 +261,41 @@ export function WhatsAppSetupPage() {
|
|||
reportRef.current = reportSession
|
||||
finishRef.current = finishIfReady
|
||||
|
||||
function mergeAccounts(incoming: WhatsAppAccount[]) {
|
||||
if (!incoming.length) return
|
||||
setAccounts((current) => {
|
||||
const byId = new Map(incoming.map((account) => [account.id, account]))
|
||||
const merged = current.map((account) => byId.get(account.id) || account)
|
||||
for (const account of incoming) {
|
||||
if (!merged.some((item) => item.id === account.id)) merged.unshift(account)
|
||||
}
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
async function submitToken(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const payload = await whatsappApi.connectToken({
|
||||
access_token: tokenValue.trim(),
|
||||
waba_id: wabaId.trim(),
|
||||
phone_number_id: phoneNumberId.trim() || null,
|
||||
})
|
||||
setResult(payload)
|
||||
mergeAccounts(payload.accounts)
|
||||
setTokenValue('')
|
||||
setShowToken(false)
|
||||
setPhase('result')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'WhatsApp could not be connected')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function finishIfReady() {
|
||||
const session = sessionRef.current
|
||||
const code = codeRef.current
|
||||
|
|
@ -389,18 +428,35 @@ export function WhatsAppSetupPage() {
|
|||
<div className="account-heading">
|
||||
<div className="wa-biz-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Communicate</span>
|
||||
<span className="eyebrow">Messages</span>
|
||||
<h1>WhatsApp Business</h1>
|
||||
<p>
|
||||
{accounts.length
|
||||
? 'Manage WhatsApp Business accounts and phone numbers connected to this Urmi workspace.'
|
||||
: 'Connect and manage WhatsApp Business accounts with Urmi.'}
|
||||
? 'The WhatsApp Business accounts and phone numbers on this workspace.'
|
||||
: 'Connect WhatsApp Business so you can message customers from Urmi.'}
|
||||
</p>
|
||||
</div>
|
||||
{accounts.length > 0 && phase !== 'provisioning' && (
|
||||
<button className="primary-button" type="button" onClick={() => void startEmbeddedSignup()} disabled={busy}>
|
||||
<Plus size={16} /> Connect account
|
||||
</button>
|
||||
<div className="wa-biz-heading-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (phase === 'list' && showToken) {
|
||||
setShowToken(false)
|
||||
return
|
||||
}
|
||||
setShowToken(true)
|
||||
if (phase === 'result') setPhase('list')
|
||||
}}
|
||||
>
|
||||
{phase === 'list' && showToken ? 'Hide token setup' : 'Use a token'}
|
||||
</button>
|
||||
<button className="primary-button" type="button" onClick={() => void startEmbeddedSignup()} disabled={busy}>
|
||||
<Plus size={16} /> Connect account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -413,8 +469,8 @@ export function WhatsAppSetupPage() {
|
|||
<div className="wa-biz-hero">
|
||||
<section className="wa-biz-card">
|
||||
<div className="wa-biz-mark"><MessageCircle size={22} /></div>
|
||||
<h2>Set up WhatsApp with Meta</h2>
|
||||
<p>Set up WhatsApp Business Platform through Meta's secure onboarding process.</p>
|
||||
<h2>Connect WhatsApp</h2>
|
||||
<p>We’ll walk you through Facebook’s secure setup. You stay in control of the business account.</p>
|
||||
<ul>
|
||||
{STEPS.map((step) => (
|
||||
<li key={step}><Check size={16} /> {step}</li>
|
||||
|
|
@ -422,16 +478,20 @@ export function WhatsAppSetupPage() {
|
|||
</ul>
|
||||
<button className="primary-button wa-biz-cta" onClick={() => void startEmbeddedSignup()} disabled={busy || !config?.ready}>
|
||||
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||
Set up WhatsApp with Meta
|
||||
Continue with Facebook
|
||||
</button>
|
||||
<button className="text-button wa-token-toggle" type="button" onClick={() => setShowToken((open) => !open)}>
|
||||
{showToken ? 'Hide token setup' : 'Use an access token instead'}
|
||||
</button>
|
||||
{showToken && <TokenConnectForm embedded busy={busy} tokenValue={tokenValue} wabaId={wabaId} phoneNumberId={phoneNumberId} onToken={setTokenValue} onWaba={setWabaId} onPhone={setPhoneNumberId} onSubmit={submitToken} />}
|
||||
<small>
|
||||
{config?.ready
|
||||
? 'Setup usually takes a few minutes. Meta decides which businesses, accounts, and numbers you can use.'
|
||||
: 'Embedded Signup is not available until the Meta configuration id is set for this environment.'}
|
||||
? 'Setup usually takes a few minutes. Facebook decides which businesses, accounts, and numbers you can use.'
|
||||
: 'WhatsApp setup is not available until this workspace is configured.'}
|
||||
</small>
|
||||
{config?.ready && (
|
||||
<small>
|
||||
If Meta shows a blank “Sorry, something went wrong” page, add https://urmiai.com and https://urmiai.com/console/whatsapp to Valid OAuth redirect URIs, allow urmiai.com for the JavaScript SDK, and confirm configuration 1871678794201108 was created as WhatsApp Embedded Signup.
|
||||
If Facebook shows a blank error page, this site still needs to be allowed in your Facebook app. Add https://urmiai.com and https://urmiai.com/console/whatsapp as return addresses, allow urmiai.com for Facebook sign-in, and confirm setup 1871678794201108 is for WhatsApp signup.
|
||||
</small>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -450,9 +510,9 @@ export function WhatsAppSetupPage() {
|
|||
{phase === 'provisioning' && (
|
||||
<section className="wa-biz-card wa-biz-provision">
|
||||
<div className="wa-biz-mark ok"><Check size={18} /></div>
|
||||
<h2>Meta authorization completed</h2>
|
||||
<p>We're configuring your WhatsApp Business connection. This screen updates from the setup result, not from a placeholder status.</p>
|
||||
<div className="wa-biz-wait"><Loader2 size={18} className="wa-spin" /> Checking the business, account, and phone Meta returned</div>
|
||||
<h2>Facebook sign-in completed</h2>
|
||||
<p>We’re finishing your WhatsApp connection. This screen updates from the result Facebook just returned.</p>
|
||||
<div className="wa-biz-wait"><Loader2 size={18} className="wa-spin" /> Checking the business, account, and phone</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
|
@ -472,7 +532,7 @@ export function WhatsAppSetupPage() {
|
|||
<div><dt>Status</dt><dd><StatusDot status={resultAccount.status} /></dd></div>
|
||||
<div><dt>Messaging</dt><dd><StatusDot status={resultAccount.messaging_status} label={capabilityLabel(resultAccount.messaging_status)} /></dd></div>
|
||||
<div><dt>Management</dt><dd><StatusDot status={resultAccount.management_status} label={capabilityLabel(resultAccount.management_status)} /></dd></div>
|
||||
<div><dt>Webhooks</dt><dd><StatusDot status={resultAccount.webhooks_status} label={capabilityLabel(resultAccount.webhooks_status)} /></dd></div>
|
||||
<div><dt>Delivery updates</dt><dd><StatusDot status={resultAccount.webhooks_status} label={capabilityLabel(resultAccount.webhooks_status)} /></dd></div>
|
||||
</dl>
|
||||
)}
|
||||
<div className="wa-biz-actions">
|
||||
|
|
@ -486,6 +546,10 @@ export function WhatsAppSetupPage() {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{phase === 'list' && showToken && (
|
||||
<TokenConnectForm busy={busy} tokenValue={tokenValue} wabaId={wabaId} phoneNumberId={phoneNumberId} onToken={setTokenValue} onWaba={setWabaId} onPhone={setPhoneNumberId} onSubmit={submitToken} />
|
||||
)}
|
||||
|
||||
{phase === 'list' && (
|
||||
<div className="wa-biz-list">
|
||||
{accounts.map((account) => (
|
||||
|
|
@ -520,7 +584,7 @@ export function WhatsAppSetupPage() {
|
|||
<small>{account.primary_phone?.verified_name || account.name}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>WABA</span>
|
||||
<span>Account reference</span>
|
||||
<strong>{account.waba_id_masked || '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -566,7 +630,7 @@ export function WhatsAppSetupPage() {
|
|||
<div className="wa-biz-tabs">
|
||||
{(['overview', 'numbers', 'templates', 'configuration', 'logs'] as DetailTab[]).map((item) => (
|
||||
<button key={item} className={tab === item ? 'active' : ''} onClick={() => setTab(item)}>
|
||||
{item === 'numbers' ? 'Phone Numbers' : item === 'configuration' ? 'Configuration' : item[0].toUpperCase() + item.slice(1)}
|
||||
{item === 'numbers' ? 'Phone numbers' : item === 'configuration' ? 'Delivery' : item === 'logs' ? 'History' : item[0].toUpperCase() + item.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -578,7 +642,7 @@ export function WhatsAppSetupPage() {
|
|||
<dl className="wa-biz-facts">
|
||||
<div><dt>Business portfolio</dt><dd>{selected.business_name || '—'}</dd></div>
|
||||
<div><dt>WhatsApp Business Account</dt><dd>{selected.name}</dd></div>
|
||||
<div><dt>WABA</dt><dd>{selected.waba_id_masked}</dd></div>
|
||||
<div><dt>Account reference</dt><dd>{selected.waba_id_masked}</dd></div>
|
||||
<div><dt>Connection</dt><dd><StatusDot status={selected.status === 'connected' ? 'connected' : selected.status} label={selected.status === 'connected' ? 'Healthy' : statusLabel(selected.status)} /></dd></div>
|
||||
</dl>
|
||||
{selected.last_error && <p className="wa-biz-inline-error">{selected.last_error}</p>}
|
||||
|
|
@ -589,7 +653,7 @@ export function WhatsAppSetupPage() {
|
|||
<li>Messaging <StatusDot status={selected.messaging_status} label={capabilityLabel(selected.messaging_status)} /></li>
|
||||
<li>Business management <StatusDot status={selected.management_status} label={capabilityLabel(selected.management_status)} /></li>
|
||||
<li>Templates <StatusDot status={selected.templates_status} label={capabilityLabel(selected.templates_status)} /></li>
|
||||
<li>Webhooks <StatusDot status={selected.webhooks_status} label={capabilityLabel(selected.webhooks_status)} /></li>
|
||||
<li>Delivery updates <StatusDot status={selected.webhooks_status} label={capabilityLabel(selected.webhooks_status)} /></li>
|
||||
</ul>
|
||||
{!selected.usable && (
|
||||
<p className="wa-biz-inline-error">Messaging stays unavailable until Urmi can verify a connected phone on this account.</p>
|
||||
|
|
@ -601,15 +665,15 @@ export function WhatsAppSetupPage() {
|
|||
{tab === 'numbers' && (
|
||||
<section className="wa-biz-card">
|
||||
<h2>Phone numbers</h2>
|
||||
<p className="wa-biz-muted">Messaging stays unavailable until Meta reports this number as connected for Cloud API. Pending means the number is on the account but not registered yet. Quality stays unknown until then.</p>
|
||||
{selected.phones.length === 0 && <p className="wa-biz-muted">Meta has not returned a phone number for this account yet.</p>}
|
||||
<p className="wa-biz-muted">Messaging stays off until Facebook confirms this number is connected. Pending means the number is on the account but not registered yet.</p>
|
||||
{selected.phones.length === 0 && <p className="wa-biz-muted">Facebook has not shared a phone number for this account yet.</p>}
|
||||
<ul className="wa-biz-numbers">
|
||||
{selected.phones.map((phone) => (
|
||||
<li key={phone.id}>
|
||||
<div>
|
||||
<strong>{phone.display_phone || 'Number pending'}</strong>
|
||||
<small>{phone.verified_name || 'Display name pending'}</small>
|
||||
{phone.needs_otp && <small>Complete the SMS or voice code in Meta, then refresh. Log this number out of the WhatsApp app first.</small>}
|
||||
{phone.needs_otp && <small>Finish the text or voice code in Facebook, then refresh. Sign this number out of the WhatsApp app first.</small>}
|
||||
</div>
|
||||
<StatusDot status={phone.status} />
|
||||
<span>Quality: {phone.quality || '—'}</span>
|
||||
|
|
@ -660,12 +724,12 @@ export function WhatsAppSetupPage() {
|
|||
<section className="wa-biz-card">
|
||||
<div className="wa-biz-card-head">
|
||||
<div>
|
||||
<h2>Developer configuration</h2>
|
||||
<p>Webhook delivery for this workspace. Verification credentials stay on Urmi's servers.</p>
|
||||
<h2>Message delivery</h2>
|
||||
<p>Where WhatsApp sends updates for this account. The verification details stay on Urmi.</p>
|
||||
</div>
|
||||
{developer && (
|
||||
<a className="ghost-button" href={developer.docs_url} target="_blank" rel="noreferrer">
|
||||
Meta docs <ExternalLink size={14} />
|
||||
WhatsApp help <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -674,7 +738,7 @@ export function WhatsAppSetupPage() {
|
|||
<>
|
||||
<div className="whatsapp-config-row">
|
||||
<div>
|
||||
<span>Callback URL</span>
|
||||
<span>Update address</span>
|
||||
<code>{developer.webhook_url}</code>
|
||||
</div>
|
||||
<button className="secondary-button" onClick={() => void copyWebhook(developer.webhook_url)}>
|
||||
|
|
@ -683,18 +747,18 @@ export function WhatsAppSetupPage() {
|
|||
</div>
|
||||
<div className="whatsapp-config-row">
|
||||
<div>
|
||||
<span>Verify token</span>
|
||||
<code>{developer.verify_token_configured ? 'Hidden — managed by Urmi' : 'Not configured'}</code>
|
||||
<small>The verify token is not shown in the customer console.</small>
|
||||
<span>Verification secret</span>
|
||||
<code>{developer.verify_token_configured ? 'Hidden — managed by Urmi' : 'Not set up yet'}</code>
|
||||
<small>This secret stays on Urmi and is not shown here.</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="wa-biz-subhead">Webhook subscriptions</p>
|
||||
<p className="wa-biz-subhead">Updates we receive</p>
|
||||
<ul className="whatsapp-field-list">
|
||||
{developer.recommended_fields.map((field) => (
|
||||
<li key={field}><Check size={14} /> {field}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="wa-biz-subhead">Webhook status <StatusDot status={developer.webhooks_status} label={capabilityLabel(developer.webhooks_status)} /></p>
|
||||
<p className="wa-biz-subhead">Delivery status <StatusDot status={developer.webhooks_status} label={capabilityLabel(developer.webhooks_status)} /></p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -704,7 +768,7 @@ export function WhatsAppSetupPage() {
|
|||
<div className="wa-biz-overview">
|
||||
<section className="wa-biz-card">
|
||||
<h2>Connection history</h2>
|
||||
{logs === null && <p className="wa-biz-muted">Loading logs…</p>}
|
||||
{logs === null && <p className="wa-biz-muted">Loading history…</p>}
|
||||
{logs && logs.audits.length === 0 && <p className="wa-biz-muted">No connection events yet.</p>}
|
||||
<ul className="wa-biz-log">
|
||||
{logs?.audits.map((item) => (
|
||||
|
|
@ -717,8 +781,8 @@ export function WhatsAppSetupPage() {
|
|||
</ul>
|
||||
</section>
|
||||
<section className="wa-biz-card">
|
||||
<h2>Recent webhook events</h2>
|
||||
{logs && logs.events.length === 0 && <p className="wa-biz-muted">No webhook events for this account.</p>}
|
||||
<h2>Recent delivery updates</h2>
|
||||
{logs && logs.events.length === 0 && <p className="wa-biz-muted">No delivery updates for this account yet.</p>}
|
||||
<ul className="wa-biz-log">
|
||||
{logs?.events.map((item) => (
|
||||
<li key={item.id}>
|
||||
|
|
@ -755,6 +819,56 @@ export function WhatsAppSetupPage() {
|
|||
)
|
||||
}
|
||||
|
||||
function TokenConnectForm({
|
||||
embedded = false,
|
||||
busy,
|
||||
tokenValue,
|
||||
wabaId,
|
||||
phoneNumberId,
|
||||
onToken,
|
||||
onWaba,
|
||||
onPhone,
|
||||
onSubmit,
|
||||
}: {
|
||||
embedded?: boolean
|
||||
busy: boolean
|
||||
tokenValue: string
|
||||
wabaId: string
|
||||
phoneNumberId: string
|
||||
onToken: (value: string) => void
|
||||
onWaba: (value: string) => void
|
||||
onPhone: (value: string) => void
|
||||
onSubmit: (event: FormEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<form className={embedded ? 'wa-token-embed' : 'wa-biz-card wa-token-card'} onSubmit={onSubmit}>
|
||||
<h2>Use your own access token</h2>
|
||||
<p>Paste the token and WhatsApp Business account ID from Meta. Urmi stores them for this workspace and does not show the token again.</p>
|
||||
<div className="wa-token-form">
|
||||
<label className="explorer-input">
|
||||
<strong>Access token</strong>
|
||||
<span>A token that can manage this WhatsApp account and send messages.</span>
|
||||
<input type="password" value={tokenValue} onChange={(event) => onToken(event.target.value)} autoComplete="off" required />
|
||||
</label>
|
||||
<label className="explorer-input">
|
||||
<strong>WhatsApp Business account ID</strong>
|
||||
<span>The account ID Meta shows for this business.</span>
|
||||
<input value={wabaId} onChange={(event) => onWaba(event.target.value)} inputMode="numeric" required />
|
||||
</label>
|
||||
<label className="explorer-input">
|
||||
<strong>Phone number ID</strong>
|
||||
<span>Optional. Add it if you want a specific number connected with this account.</span>
|
||||
<input value={phoneNumberId} onChange={(event) => onPhone(event.target.value)} inputMode="numeric" />
|
||||
</label>
|
||||
<button className="primary-button" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||
Save and connect
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot({ status, label }: { status: string; label?: string }) {
|
||||
const tone = status === 'connected' || status === 'active' ? 'ok' : status === 'unavailable' || status === 'failed' || status === 'expired' || status === 'disconnected' ? 'bad' : 'wait'
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue