This commit is contained in:
debu-2 2026-10-12 23:19:09 +05:30
parent 8726c931fa
commit 8c46d091ff
49 changed files with 1607 additions and 712 deletions

View file

@ -52,7 +52,11 @@ META_APP_SECRET=
META_EMBEDDED_SIGNUP_CONFIG_ID= META_EMBEDDED_SIGNUP_CONFIG_ID=
# Optional Facebook Login for Business config for Pages and Ads. Do not put the WhatsApp Embedded Signup config here. # Optional Facebook Login for Business config for Pages and Ads. Do not put the WhatsApp Embedded Signup config here.
META_LOGIN_CONFIG_ID= META_LOGIN_CONFIG_ID=
# Must match a Valid OAuth Redirect URI on the Meta app. Defaults to {PUBLIC_API_BASE_URL}/api/v1/meta/oauth/callback. # Must match a Valid OAuth Redirect URI on the Meta app exactly.
# Production (same-origin /api on urmiai.com):
# META_OAUTH_REDIRECT_URI=https://urmiai.com/api/v1/meta/oauth/callback
# Local dev:
# META_OAUTH_REDIRECT_URI=http://localhost:8000/api/v1/meta/oauth/callback
META_OAUTH_REDIRECT_URI= META_OAUTH_REDIRECT_URI=
WHATSAPP_BUSINESS_PHONE=919411257916 WHATSAPP_BUSINESS_PHONE=919411257916
WHATSAPP_API_VERSION=v21.0 WHATSAPP_API_VERSION=v21.0

View file

@ -7,6 +7,7 @@ needs to call a follow-up action.
from __future__ import annotations from __future__ import annotations
import json
import re import re
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -120,6 +121,72 @@ def act_id(value: str) -> str:
return raw if raw.startswith("act_") else f"act_{raw}" return raw if raw.startswith("act_") else f"act_{raw}"
def _equal_filter(field: str, value: str) -> str:
return json.dumps([{"field": field, "operator": "EQUAL", "value": value}])
def _in_filter(field: str, values: list[str]) -> str:
return json.dumps([{"field": field, "operator": "IN", "value": values}])
def _is_ads_api_path(path: str) -> bool:
"""ads_read / ads_management live on Marketing API paths, not Page/Business Graph."""
cleaned = path.lstrip("/")
if cleaned == "me/adaccounts" or cleaned.startswith("me/adaccounts?"):
return True
if cleaned.startswith("act_"):
return True
return bool(re.fullmatch(r"\d+(?:/[A-Za-z_]+)?", cleaned))
async def ads_api(
db: Session,
conn: MetaConnection,
method: str,
path: str,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Marketing API for ads_read and ads_management (campaigns, ad sets, ads, insights)."""
if not _is_ads_api_path(path):
raise MetaApiError(500, "graph_error", "Ads operations must use the Marketing API on an ad account.")
return await graph_as_user(db, conn, method, path.lstrip("/"), params, body)
async def marketing_get(
db: Session,
conn: MetaConnection,
account_id: str,
edge: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""GET /act_{id}/{edge} — Ads Insights / Ads Management read."""
return await ads_api(db, conn, "GET", f"{act_id(account_id)}/{edge.lstrip('/')}", params)
async def marketing_post(
db: Session,
conn: MetaConnection,
account_id: str,
edge: str,
body: dict[str, Any],
) -> dict[str, Any]:
"""POST /act_{id}/{edge} — Ads Management write."""
return await ads_api(db, conn, "POST", f"{act_id(account_id)}/{edge.lstrip('/')}", body=body)
async def ads_object_post(
db: Session,
conn: MetaConnection,
object_id: str,
body: dict[str, Any],
) -> dict[str, Any]:
"""POST /{campaign|adset|ad id} status and field updates (Marketing API object)."""
if not re.fullmatch(r"\d+", str(object_id)):
raise MetaApiError(422, "validation", "That Ads object is not valid for this ad account.")
return await ads_api(db, conn, "POST", str(object_id), body=body)
def currency_offset(currency: str) -> int: def currency_offset(currency: str) -> int:
return 1 if (currency or "").upper() in ZERO_DECIMAL else 100 return 1 if (currency or "").upper() in ZERO_DECIMAL else 100
@ -178,7 +245,6 @@ def ensure_configured() -> None:
def require_connection(db: Session, user: UserAccount) -> MetaConnection: def require_connection(db: Session, user: UserAccount) -> MetaConnection:
ensure_configured()
conn = load_connection(db, user) conn = load_connection(db, user)
if not conn or conn.status == "disconnected" or not conn.access_token: if not conn or conn.status == "disconnected" or not conn.access_token:
raise MetaApiError(409, "not_connected", "Connect Meta before using this screen.") raise MetaApiError(409, "not_connected", "Connect Meta before using this screen.")
@ -235,6 +301,7 @@ def audit(
def public_connection(conn: MetaConnection | None) -> dict[str, Any]: def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
redirect_uri = settings.meta_oauth_redirect_uri_resolved
if not conn or conn.status == "disconnected": if not conn or conn.status == "disconnected":
return { return {
"configured": settings.meta_oauth_configured, "configured": settings.meta_oauth_configured,
@ -248,6 +315,7 @@ def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
"selected_page_name": "", "selected_page_name": "",
"selected_ad_account_name": "", "selected_ad_account_name": "",
"last_error": "", "last_error": "",
"oauth_redirect_uri": redirect_uri,
} }
return { return {
"configured": settings.meta_oauth_configured, "configured": settings.meta_oauth_configured,
@ -265,6 +333,7 @@ def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
"selected_page_name": conn.selected_page_name, "selected_page_name": conn.selected_page_name,
"selected_ad_account_name": conn.selected_ad_account_name, "selected_ad_account_name": conn.selected_ad_account_name,
"last_error": conn.last_error, "last_error": conn.last_error,
"oauth_redirect_uri": redirect_uri,
} }
@ -323,6 +392,52 @@ async def finish_oauth(db: Session, code: str, state: str) -> None:
audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Meta Login completed") audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Meta Login completed")
async def connect_with_user_token(db: Session, user: UserAccount, access_token: str) -> dict[str, Any]:
"""Store a customer-supplied user token without the Facebook login redirect."""
token = "".join(str(access_token or "").split())
if token.lower().startswith("bearer"):
token = token[6:]
if len(token) < 20:
raise MetaApiError(400, "validation", "Paste the access token from Meta.")
profile = await graph("GET", "me", token, {"fields": "id,name"})
permission_rows = await graph("GET", "me/permissions", token)
granted = [
row.get("permission")
for row in permission_rows.get("data") or []
if isinstance(row, dict) and row.get("status") == "granted" and row.get("permission")
]
scopes = [scope for scope in META_SCOPES if scope in set(granted)]
if not any(scope != "public_profile" for scope in scopes):
raise MetaApiError(
403,
"permission_denied",
"This token cannot see Pages or ad accounts. Use a token that includes those permissions.",
)
stored = token
expires_at = None
if settings.meta_app_id and settings.meta_graph_app_secret:
long_token = await _exchange_long_lived(token)
if long_token.get("access_token"):
stored = str(long_token["access_token"])
expires_in = long_token.get("expires_in")
if expires_in:
expires_at = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
conn = load_connection(db, user) or MetaConnection(user_id=user.id)
conn.meta_user_id = str(profile.get("id") or "")
conn.meta_user_name = str(profile.get("name") or "")
conn.granted_scopes = ",".join(scopes)
conn.access_token = stored
conn.status = "connected"
conn.last_error = ""
conn.last_graph_code = None
conn.connected_at = datetime.now(timezone.utc)
conn.token_expires_at = expires_at
db.add(conn)
db.commit()
audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Access token saved")
return public_connection(conn)
async def _exchange_code(code: str) -> dict[str, Any]: async def _exchange_code(code: str) -> dict[str, Any]:
return await graph( return await graph(
"GET", "GET",
@ -557,48 +672,27 @@ async def list_businesses(db: Session, user: UserAccount) -> dict[str, Any]:
async def list_ad_accounts(db: Session, user: UserAccount) -> dict[str, Any]: async def list_ad_accounts(db: Session, user: UserAccount) -> dict[str, Any]:
conn = require_connection(db, user) conn = require_connection(db, user)
if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management") or has_scope(conn, "business_management")): if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")):
raise MetaApiError( raise MetaApiError(
403, 403,
"permission_denied", "permission_denied",
"Meta has not granted Ads reporting or Business portfolio for this connection.", "Meta has not granted Ads reporting or Ads management for this connection.",
permission="ads_read", permission="ads_read",
) )
rows: dict[str, dict[str, Any]] = {} data = await ads_api(
if has_scope(conn, "ads_read"):
data = await graph_as_user(
db, db,
conn, conn,
"GET", "GET",
"me/adaccounts", "me/adaccounts",
{"fields": "id,name,account_status,currency,amount_spent", "limit": "50"}, {"fields": "id,name,account_status,currency,amount_spent,business{name}", "limit": "50"},
) )
rows: dict[str, dict[str, Any]] = {}
for row in data.get("data") or []: for row in data.get("data") or []:
if isinstance(row, dict) and row.get("id"): if not isinstance(row, dict) or not row.get("id"):
continue
business = row.get("business") if isinstance(row.get("business"), dict) else {}
row["business_name"] = business.get("name") or ""
rows[act_id(str(row["id"]))] = row rows[act_id(str(row["id"]))] = row
if has_scope(conn, "business_management"):
businesses = await graph_as_user(db, conn, "GET", "me/businesses", {"fields": "id,name", "limit": "25"})
for business in businesses.get("data") or []:
if not isinstance(business, dict) or not business.get("id"):
continue
business_name = str(business.get("name") or "")
for edge in ("owned_ad_accounts", "client_ad_accounts"):
try:
owned = await graph_as_user(
db,
conn,
"GET",
f"{business['id']}/{edge}",
{"fields": "id,name,account_status,currency,amount_spent", "limit": "50"},
)
except MetaApiError as exc:
if exc.code == "permission_denied":
continue
raise
for row in owned.get("data") or []:
if isinstance(row, dict) and row.get("id"):
current = rows.setdefault(act_id(str(row["id"])), row)
current["business_name"] = business_name
accounts = [ accounts = [
_account_summary(row, selected=act_id(str(row.get("id"))) == act_id(conn.selected_ad_account_id)) _account_summary(row, selected=act_id(str(row.get("id"))) == act_id(conn.selected_ad_account_id))
for row in rows.values() for row in rows.values()
@ -625,7 +719,7 @@ def _account_summary(row: dict[str, Any], selected: bool) -> dict[str, Any]:
async def select_ad_account(db: Session, user: UserAccount, account_id: str) -> dict[str, Any]: async def select_ad_account(db: Session, user: UserAccount, account_id: str) -> dict[str, Any]:
conn = require_connection(db, user) conn = require_connection(db, user)
if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management") or has_scope(conn, "business_management")): if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")):
raise MetaApiError(403, "permission_denied", "Meta has not granted Ads reporting for this connection.", permission="ads_read") raise MetaApiError(403, "permission_denied", "Meta has not granted Ads reporting for this connection.", permission="ads_read")
accounts = await list_ad_accounts(db, user) accounts = await list_ad_accounts(db, user)
match = next((row for row in accounts["accounts"] if row["id"] == act_id(account_id)), None) match = next((row for row in accounts["accounts"] if row["id"] == act_id(account_id)), None)
@ -646,20 +740,142 @@ async def _selected_account(db: Session, user: UserAccount, manage: bool) -> tup
require_scope(conn, "ads_read") require_scope(conn, "ads_read")
if not conn.selected_ad_account_id: if not conn.selected_ad_account_id:
raise MetaApiError(409, "no_ad_account", "Select an ad account before opening campaigns.") raise MetaApiError(409, "no_ad_account", "Select an ad account before opening campaigns.")
accounts = await list_ad_accounts(db, user) return conn, act_id(conn.selected_ad_account_id)
match = next((row for row in accounts["accounts"] if row["id"] == act_id(conn.selected_ad_account_id)), None)
if not match:
raise MetaApiError(409, "no_ad_account", "The selected ad account is no longer available to this connection.") async def _account_object(
return conn, match["id"] db: Session,
conn: MetaConnection,
account_id: str,
edge: str,
object_id: str,
fields: str,
) -> dict[str, Any]:
data = await marketing_get(
db,
conn,
account_id,
edge,
{"fields": fields, "filtering": _equal_filter("id", object_id), "limit": "1"},
)
rows = [row for row in data.get("data") or [] if isinstance(row, dict) and str(row.get("id")) == object_id]
if not rows:
raise MetaApiError(404, "not_found", "That object is not in the selected ad account.")
return rows[0]
def _adset_summary(row: dict[str, Any], currency: str) -> dict[str, Any]:
campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {}
daily = minor_to_major(row.get("daily_budget"), currency)
lifetime = minor_to_major(row.get("lifetime_budget"), currency)
budget = money(daily, currency) if daily is not None else money(lifetime, currency) if lifetime is not None else None
goal = str(row.get("optimization_goal") or "").replace("_", " ").title()
return {
"id": str(row.get("id")),
"name": row.get("name") or "Ad set",
"campaign_id": str(row.get("campaign_id") or campaign.get("id") or ""),
"campaign_name": campaign.get("name") or "",
"status": row.get("effective_status") or row.get("status") or "",
"optimization_goal": goal or "—",
"billing_event": str(row.get("billing_event") or "").replace("_", " ").title() or "—",
"budget": budget,
"start_time": row.get("start_time"),
"end_time": row.get("end_time"),
}
async def list_adsets(db: Session, user: UserAccount, campaign_id: str | None = None) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False)
currency = await _account_currency(db, conn, account_id)
params: dict[str, Any] = {
"fields": "id,name,status,effective_status,daily_budget,lifetime_budget,optimization_goal,billing_event,campaign_id,start_time,end_time,campaign{name}",
"limit": "50",
}
if campaign_id:
params["filtering"] = _equal_filter("campaign.id", campaign_id)
data = await marketing_get(db, conn, account_id, "adsets", params)
adsets = [_adset_summary(row, currency) for row in data.get("data") or [] if isinstance(row, dict) and row.get("id")]
return {
"account_name": conn.selected_ad_account_name,
"currency": currency,
"adsets": adsets,
}
async def campaign_detail(db: Session, user: UserAccount, campaign_id: str) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False)
current = await _account_object(
db,
conn,
account_id,
"campaigns",
campaign_id,
"id,name,status,effective_status,objective,created_time",
)
currency = await _account_currency(db, conn, account_id)
adsets_payload = await marketing_get(
db,
conn,
account_id,
"adsets",
{
"fields": "id,name,status,effective_status,daily_budget,lifetime_budget,optimization_goal,billing_event,campaign_id,start_time,end_time",
"filtering": _equal_filter("campaign.id", campaign_id),
"limit": "50",
},
)
adsets = [_adset_summary(row, currency) for row in adsets_payload.get("data") or [] if isinstance(row, dict) and row.get("id")]
insights_error = None
metrics: dict[str, Any] = {}
try:
report = await marketing_get(
db,
conn,
account_id,
"insights",
{
"fields": "campaign_id,spend,impressions,clicks,reach,ctr",
"date_preset": "last_30d",
"level": "campaign",
"filtering": _in_filter("campaign.id", [campaign_id]),
},
)
row = (report.get("data") or [{}])[0] if isinstance(report.get("data"), list) else {}
if isinstance(row, dict):
spend = _float(row.get("spend"))
metrics = {
"spend": money(spend, currency) if row else None,
"impressions": _int(row.get("impressions")),
"clicks": _int(row.get("clicks")),
"reach": _int(row.get("reach")),
"ctr": _float(row.get("ctr")),
}
except MetaApiError as exc:
insights_error = {"code": exc.code, "message": exc.message}
return {
"account_name": conn.selected_ad_account_name,
"currency": currency,
"can_manage": has_scope(conn, "ads_management"),
"campaign": {
"id": str(current.get("id")),
"name": current.get("name") or "Campaign",
"status": current.get("effective_status") or current.get("status") or "",
"objective": OBJECTIVE_LABELS.get(str(current.get("objective")), str(current.get("objective") or "").replace("OUTCOME_", "").title()),
"created_time": current.get("created_time"),
},
"adsets": adsets,
"metrics": metrics,
"insights_error": insights_error,
}
async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]: async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False) conn, account_id = await _selected_account(db, user, manage=False)
data = await graph_as_user( data = await marketing_get(
db, db,
conn, conn,
"GET", account_id,
f"{account_id}/campaigns", "campaigns",
{ {
"fields": "id,name,status,effective_status,objective,created_time", "fields": "id,name,status,effective_status,objective,created_time",
"limit": "50", "limit": "50",
@ -668,11 +884,11 @@ async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
insights: dict[str, dict[str, Any]] = {} insights: dict[str, dict[str, Any]] = {}
insights_error = None insights_error = None
try: try:
report = await graph_as_user( report = await marketing_get(
db, db,
conn, conn,
"GET", account_id,
f"{account_id}/insights", "insights",
{ {
"level": "campaign", "level": "campaign",
"fields": "campaign_id,spend,impressions,clicks,ctr,reach", "fields": "campaign_id,spend,impressions,clicks,ctr,reach",
@ -717,7 +933,7 @@ async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
async def _account_currency(db: Session, conn: MetaConnection, account_id: str) -> str: async def _account_currency(db: Session, conn: MetaConnection, account_id: str) -> str:
try: try:
data = await graph_as_user(db, conn, "GET", account_id, {"fields": "currency"}) data = await ads_api(db, conn, "GET", account_id, {"fields": "currency,account_status,name"})
return str(data.get("currency") or "") return str(data.get("currency") or "")
except MetaApiError: except MetaApiError:
return "" return ""
@ -741,13 +957,11 @@ async def set_campaign_status(db: Session, user: UserAccount, campaign_id: str,
if status not in {"ACTIVE", "PAUSED"}: if status not in {"ACTIVE", "PAUSED"}:
raise MetaApiError(422, "validation", "Choose pause or resume.") raise MetaApiError(422, "validation", "Choose pause or resume.")
conn, account_id = await _selected_account(db, user, manage=True) conn, account_id = await _selected_account(db, user, manage=True)
current = await graph_as_user(db, conn, "GET", campaign_id, {"fields": "id,name,account_id"}) current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name")
if not _same_account(current.get("account_id"), account_id):
raise MetaApiError(404, "not_found", "That campaign is not in the selected ad account.")
name = str(current.get("name") or "Campaign") name = str(current.get("name") or "Campaign")
action = "campaign.resume" if status == "ACTIVE" else "campaign.pause" action = "campaign.resume" if status == "ACTIVE" else "campaign.pause"
try: try:
await graph_as_user(db, conn, "POST", campaign_id, body={"status": status}) await ads_object_post(db, conn, campaign_id, {"status": status})
except MetaApiError as exc: except MetaApiError as exc:
audit(db, user, action, asset_type="campaign", asset_label=name, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code) audit(db, user, action, asset_type="campaign", asset_label=name, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise raise
@ -757,23 +971,27 @@ async def set_campaign_status(db: Session, user: UserAccount, campaign_id: str,
async def update_campaign(db: Session, user: UserAccount, campaign_id: str, name: str | None, daily_budget: float | None) -> dict[str, Any]: async def update_campaign(db: Session, user: UserAccount, campaign_id: str, name: str | None, daily_budget: float | None) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=True) conn, account_id = await _selected_account(db, user, manage=True)
current = await graph_as_user(db, conn, "GET", campaign_id, {"fields": "id,name,account_id"}) current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name")
if not _same_account(current.get("account_id"), account_id):
raise MetaApiError(404, "not_found", "That campaign is not in the selected ad account.")
if not name and daily_budget is None: if not name and daily_budget is None:
raise MetaApiError(422, "validation", "Add a name or daily budget to update.") raise MetaApiError(422, "validation", "Add a name or daily budget to update.")
label = name or str(current.get("name") or "Campaign") label = name or str(current.get("name") or "Campaign")
try: try:
if name: if name:
await graph_as_user(db, conn, "POST", campaign_id, body={"name": name[:120]}) await ads_object_post(db, conn, campaign_id, {"name": name[:120]})
if daily_budget is not None: if daily_budget is not None:
currency = await _account_currency(db, conn, account_id) currency = await _account_currency(db, conn, account_id)
adsets = await graph_as_user(db, conn, "GET", f"{campaign_id}/adsets", {"fields": "id,name", "limit": "5"}) adsets = await marketing_get(
db,
conn,
account_id,
"adsets",
{"fields": "id,name", "filtering": _equal_filter("campaign.id", campaign_id), "limit": "5"},
)
rows = [row for row in adsets.get("data") or [] if isinstance(row, dict) and row.get("id")] rows = [row for row in adsets.get("data") or [] if isinstance(row, dict) and row.get("id")]
if len(rows) != 1: if len(rows) != 1:
raise MetaApiError(422, "feature_unavailable", "Budget can be edited here only when the campaign has one ad set.") raise MetaApiError(422, "feature_unavailable", "Budget can be edited here only when the campaign has one ad set.")
minor = str(int(round(daily_budget * currency_offset(currency)))) minor = str(int(round(daily_budget * currency_offset(currency))))
await graph_as_user(db, conn, "POST", str(rows[0]["id"]), body={"daily_budget": minor}) await ads_object_post(db, conn, str(rows[0]["id"]), {"daily_budget": minor})
except MetaApiError as exc: except MetaApiError as exc:
audit(db, user, "campaign.edit", asset_type="campaign", asset_label=label, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code) audit(db, user, "campaign.edit", asset_type="campaign", asset_label=label, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise raise
@ -820,12 +1038,12 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
status = "ACTIVE" if activate else "PAUSED" status = "ACTIVE" if activate else "PAUSED"
created_campaign = "" created_campaign = ""
try: try:
campaign = await graph_as_user( campaign = await marketing_post(
db, db,
conn, conn,
"POST", account_id,
f"{account_id}/campaigns", "campaigns",
body={ {
"name": name, "name": name,
"objective": objective, "objective": objective,
"status": "PAUSED", "status": "PAUSED",
@ -855,21 +1073,21 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
adset_body["destination_type"] = "WHATSAPP" adset_body["destination_type"] = "WHATSAPP"
elif destination == "website": elif destination == "website":
adset_body["destination_type"] = "WEBSITE" adset_body["destination_type"] = "WEBSITE"
adset = await graph_as_user(db, conn, "POST", f"{account_id}/adsets", body=adset_body) adset = await marketing_post(db, conn, account_id, "adsets", adset_body)
story = _story_spec(page_id, destination, message, headline, link, cta, page_link) story = _story_spec(page_id, destination, message, headline, link, cta, page_link)
creative = await graph_as_user( creative = await marketing_post(
db, db,
conn, conn,
"POST", account_id,
f"{account_id}/adcreatives", "adcreatives",
body={"name": f"{name} creative", "object_story_spec": story}, {"name": f"{name} creative", "object_story_spec": story},
) )
await graph_as_user( await marketing_post(
db, db,
conn, conn,
"POST", account_id,
f"{account_id}/ads", "ads",
body={ {
"name": f"{name} ad", "name": f"{name} ad",
"adset_id": adset.get("id"), "adset_id": adset.get("id"),
"creative": {"creative_id": creative.get("id")}, "creative": {"creative_id": creative.get("id")},
@ -877,8 +1095,8 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
}, },
) )
if activate and created_campaign: if activate and created_campaign:
await graph_as_user(db, conn, "POST", created_campaign, body={"status": "ACTIVE"}) await ads_object_post(db, conn, created_campaign, {"status": "ACTIVE"})
await graph_as_user(db, conn, "POST", str(adset.get("id")), body={"status": "ACTIVE"}) await ads_object_post(db, conn, str(adset.get("id")), {"status": "ACTIVE"})
except MetaApiError as exc: except MetaApiError as exc:
detail = exc.message detail = exc.message
if created_campaign: if created_campaign:
@ -928,13 +1146,13 @@ def _story_spec(page_id: str, destination: str, message: str, headline: str, lin
async def list_ads(db: Session, user: UserAccount) -> dict[str, Any]: async def list_ads(db: Session, user: UserAccount) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False) conn, account_id = await _selected_account(db, user, manage=False)
data = await graph_as_user( data = await marketing_get(
db, db,
conn, conn,
"GET", account_id,
f"{account_id}/ads", "ads",
{ {
"fields": "id,name,status,effective_status,creative{title,body,image_url,thumbnail_url,call_to_action_type,object_story_spec},campaign{name}", "fields": "id,name,status,effective_status,creative{title,body,image_url,thumbnail_url,call_to_action_type,object_story_spec},campaign{name},adset{name}",
"limit": "50", "limit": "50",
}, },
) )
@ -952,10 +1170,12 @@ def _ad_summary(row: dict[str, Any]) -> dict[str, Any]:
video = story.get("video_data") if isinstance(story.get("video_data"), dict) else {} video = story.get("video_data") if isinstance(story.get("video_data"), dict) else {}
cta = link_data.get("call_to_action") if isinstance(link_data.get("call_to_action"), dict) else {} cta = link_data.get("call_to_action") if isinstance(link_data.get("call_to_action"), dict) else {}
campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {} campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {}
adset = row.get("adset") if isinstance(row.get("adset"), dict) else {}
return { return {
"id": str(row.get("id")), "id": str(row.get("id")),
"name": row.get("name") or "Ad", "name": row.get("name") or "Ad",
"campaign_name": campaign.get("name") or "", "campaign_name": campaign.get("name") or "",
"adset_name": adset.get("name") or "",
"status": row.get("effective_status") or row.get("status") or "", "status": row.get("effective_status") or row.get("status") or "",
"headline": link_data.get("name") or video.get("title") or creative.get("title") or "", "headline": link_data.get("name") or video.get("title") or creative.get("title") or "",
"message": link_data.get("message") or video.get("message") or creative.get("body") or "", "message": link_data.get("message") or video.get("message") or creative.get("body") or "",
@ -968,19 +1188,18 @@ def _ad_summary(row: dict[str, Any]) -> dict[str, Any]:
async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[str, Any]) -> dict[str, Any]: async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[str, Any]) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=True) conn, account_id = await _selected_account(db, user, manage=True)
current = await graph_as_user( current = await _account_object(
db, db,
conn, conn,
"GET", account_id,
"ads",
ad_id, ad_id,
{"fields": "id,name,account_id,creative{object_story_spec}"}, "id,name,creative{object_story_spec}",
) )
if not _same_account(current.get("account_id"), account_id):
raise MetaApiError(404, "not_found", "That ad is not in the selected ad account.")
label = str(payload.get("name") or current.get("name") or "Ad") label = str(payload.get("name") or current.get("name") or "Ad")
try: try:
if payload.get("name"): if payload.get("name"):
await graph_as_user(db, conn, "POST", ad_id, body={"name": str(payload["name"])[:120]}) await ads_object_post(db, conn, ad_id, {"name": str(payload["name"])[:120]})
creative_fields = ("message", "headline", "call_to_action", "link") creative_fields = ("message", "headline", "call_to_action", "link")
if any(payload.get(field) for field in creative_fields): if any(payload.get(field) for field in creative_fields):
creative = current.get("creative") if isinstance(current.get("creative"), dict) else {} creative = current.get("creative") if isinstance(current.get("creative"), dict) else {}
@ -995,11 +1214,11 @@ async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[st
link = str(payload.get("link") or "") link = str(payload.get("link") or "")
if link and not link.startswith(("http://", "https://")): if link and not link.startswith(("http://", "https://")):
raise MetaApiError(422, "validation", "Creative links need to start with http or https.") raise MetaApiError(422, "validation", "Creative links need to start with http or https.")
replacement = await graph_as_user( replacement = await marketing_post(
db, db,
conn, conn,
"POST", account_id,
f"{account_id}/adcreatives", "adcreatives",
body={ body={
"name": f"{label} creative", "name": f"{label} creative",
"object_story_spec": _story_spec( "object_story_spec": _story_spec(
@ -1013,7 +1232,7 @@ async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[st
), ),
}, },
) )
await graph_as_user(db, conn, "POST", ad_id, body={"creative": {"creative_id": replacement.get("id")}}) await ads_object_post(db, conn, ad_id, {"creative": {"creative_id": replacement.get("id")}})
except MetaApiError as exc: except MetaApiError as exc:
audit(db, user, "ad.edit", asset_type="ad", asset_label=label, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code) audit(db, user, "ad.edit", asset_type="ad", asset_label=label, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise raise
@ -1025,13 +1244,11 @@ async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str)
if status not in {"ACTIVE", "PAUSED"}: if status not in {"ACTIVE", "PAUSED"}:
raise MetaApiError(422, "validation", "Choose pause or resume.") raise MetaApiError(422, "validation", "Choose pause or resume.")
conn, account_id = await _selected_account(db, user, manage=True) conn, account_id = await _selected_account(db, user, manage=True)
current = await graph_as_user(db, conn, "GET", ad_id, {"fields": "id,name,account_id"}) current = await _account_object(db, conn, account_id, "ads", ad_id, "id,name")
if not _same_account(current.get("account_id"), account_id):
raise MetaApiError(404, "not_found", "That ad is not in the selected ad account.")
name = str(current.get("name") or "Ad") name = str(current.get("name") or "Ad")
action = "ad.resume" if status == "ACTIVE" else "ad.pause" action = "ad.resume" if status == "ACTIVE" else "ad.pause"
try: try:
await graph_as_user(db, conn, "POST", ad_id, body={"status": status}) await ads_object_post(db, conn, ad_id, {"status": status})
except MetaApiError as exc: except MetaApiError as exc:
audit(db, user, action, asset_type="ad", asset_label=name, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code) audit(db, user, action, asset_type="ad", asset_label=name, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise raise
@ -1039,23 +1256,23 @@ async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str)
return {"name": name, "status": status} return {"name": name, "status": status}
async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[str, Any]: async def analytics(db: Session, user: UserAccount, date_preset: str, breakdowns: bool = True) -> dict[str, Any]:
if date_preset not in {"last_7d", "last_14d", "last_30d", "last_90d", "maximum"}: if date_preset not in {"last_7d", "last_14d", "last_30d", "last_90d", "maximum"}:
raise MetaApiError(422, "validation", "Choose a supported date range.") raise MetaApiError(422, "validation", "Choose a supported date range.")
conn, account_id = await _selected_account(db, user, manage=False) conn, account_id = await _selected_account(db, user, manage=False)
currency = await _account_currency(db, conn, account_id) currency = await _account_currency(db, conn, account_id)
summary = await graph_as_user( summary = await marketing_get(
db, db,
conn, conn,
"GET", account_id,
f"{account_id}/insights", "insights",
{"fields": "spend,impressions,reach,clicks,ctr,cpc,cpm", "date_preset": date_preset}, {"fields": "spend,impressions,reach,clicks,ctr,cpc,cpm", "date_preset": date_preset},
) )
daily = await graph_as_user( daily = await marketing_get(
db, db,
conn, conn,
"GET", account_id,
f"{account_id}/insights", "insights",
{ {
"fields": "spend,impressions,clicks,date_start", "fields": "spend,impressions,clicks,date_start",
"date_preset": date_preset, "date_preset": date_preset,
@ -1063,6 +1280,34 @@ async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[st
"limit": "100", "limit": "100",
}, },
) )
campaign_rows: list[dict[str, Any]] = []
adset_rows: list[dict[str, Any]] = []
ad_rows: list[dict[str, Any]] = []
if breakdowns:
campaign_rows = await _insights_rows(
db,
conn,
account_id,
date_preset,
"campaign",
"campaign_id,campaign_name,spend,impressions,clicks,ctr,reach",
)
adset_rows = await _insights_rows(
db,
conn,
account_id,
date_preset,
"adset",
"adset_id,adset_name,spend,impressions,clicks,ctr",
)
ad_rows = await _insights_rows(
db,
conn,
account_id,
date_preset,
"ad",
"ad_id,ad_name,spend,impressions,clicks,ctr",
)
row = (summary.get("data") or [{}])[0] if isinstance(summary.get("data"), list) else {} row = (summary.get("data") or [{}])[0] if isinstance(summary.get("data"), list) else {}
if not isinstance(row, dict): if not isinstance(row, dict):
row = {} row = {}
@ -1094,6 +1339,43 @@ async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[st
"cpm": money(_float(row.get("cpm")), currency) if row.get("cpm") is not None else None, "cpm": money(_float(row.get("cpm")), currency) if row.get("cpm") is not None else None,
}, },
"series": series, "series": series,
"campaigns": [_insight_line(item, "campaign", currency) for item in campaign_rows],
"adsets": [_insight_line(item, "adset", currency) for item in adset_rows],
"ads": [_insight_line(item, "ad", currency) for item in ad_rows],
}
async def _insights_rows(
db: Session,
conn: MetaConnection,
account_id: str,
date_preset: str,
level: str,
fields: str,
) -> list[dict[str, Any]]:
try:
payload = await marketing_get(
db,
conn,
account_id,
"insights",
{"fields": fields, "date_preset": date_preset, "level": level, "limit": "50"},
)
except MetaApiError:
return []
return [row for row in payload.get("data") or [] if isinstance(row, dict)]
def _insight_line(row: dict[str, Any], level: str, currency: str) -> dict[str, Any]:
name = row.get(f"{level}_name") or row.get("campaign_name") or "Untitled"
spend = _float(row.get("spend"))
return {
"name": name,
"spend": money(spend, currency),
"impressions": _int(row.get("impressions")),
"clicks": _int(row.get("clicks")),
"ctr": _float(row.get("ctr")),
"reach": _int(row.get("reach")),
} }
@ -1134,7 +1416,7 @@ async def overview(db: Session, user: UserAccount) -> dict[str, Any]:
return result return result
result["businesses"] = await _section(lambda: list_businesses(db, user), "businesses") result["businesses"] = await _section(lambda: list_businesses(db, user), "businesses")
result["pages"] = await _section(lambda: list_pages(db, user), "pages") result["pages"] = await _section(lambda: list_pages(db, user), "pages")
if has_scope(conn, "ads_read") or has_scope(conn, "business_management"): if has_scope(conn, "ads_read") or has_scope(conn, "ads_management"):
result["ad_accounts"] = await _section(lambda: list_ad_accounts(db, user), "accounts") result["ad_accounts"] = await _section(lambda: list_ad_accounts(db, user), "accounts")
else: else:
result["ad_accounts"] = { result["ad_accounts"] = {
@ -1145,7 +1427,7 @@ async def overview(db: Session, user: UserAccount) -> dict[str, Any]:
} }
if has_scope(conn, "ads_read") and conn.selected_ad_account_id: if has_scope(conn, "ads_read") and conn.selected_ad_account_id:
try: try:
report = await analytics(db, user, "last_30d") report = await analytics(db, user, "last_30d", breakdowns=False)
result["performance"] = {"state": "ok", "message": "", "report": report} result["performance"] = {"state": "ok", "message": "", "report": report}
except MetaApiError as exc: except MetaApiError as exc:
result["performance"] = {"state": exc.code, "message": exc.message, "permission": exc.permission, "report": None} result["performance"] = {"state": exc.code, "message": exc.message, "permission": exc.permission, "report": None}
@ -1211,6 +1493,7 @@ def settings_view(db: Session, user: UserAccount) -> dict[str, Any]:
debug = { debug = {
"app_configured": settings.meta_oauth_configured, "app_configured": settings.meta_oauth_configured,
"redirect_configured": bool(settings.meta_oauth_redirect_uri_resolved), "redirect_configured": bool(settings.meta_oauth_redirect_uri_resolved),
"oauth_redirect_uri": settings.meta_oauth_redirect_uri_resolved,
"login_config_set": bool(settings.meta_login_config_id), "login_config_set": bool(settings.meta_login_config_id),
"granted_scopes": sorted(scopes_of(conn)) if conn else [], "granted_scopes": sorted(scopes_of(conn)) if conn else [],
"last_error": conn.last_error if conn else "", "last_error": conn.last_error if conn else "",

View file

@ -13,11 +13,14 @@ from .meta_service import (
analytics, analytics,
authorization_url, authorization_url,
disconnect, disconnect,
connect_with_user_token,
finish_oauth, finish_oauth,
http_meta, http_meta,
list_ad_accounts, list_ad_accounts,
list_ads, list_ads,
list_adsets,
list_businesses, list_businesses,
campaign_detail,
list_campaigns, list_campaigns,
list_pages, list_pages,
overview, overview,
@ -43,6 +46,10 @@ class Confirm(BaseModel):
confirm: bool = False confirm: bool = False
class ManualToken(BaseModel):
access_token: str = Field(min_length=1, max_length=4096)
class IdBody(Confirm): class IdBody(Confirm):
id: str = Field(min_length=1, max_length=64) id: str = Field(min_length=1, max_length=64)
@ -118,6 +125,14 @@ async def oauth_callback(code: str = "", state: str = "", error: str = "", db: S
return _redirect("connected=1") return _redirect("connected=1")
@router.post("/token")
async def save_token(body: ManualToken, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await connect_with_user_token(db, user, body.access_token)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/disconnect") @router.post("/disconnect")
def disconnect_meta(body: Confirm, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict: def disconnect_meta(body: Confirm, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body) _confirmed(body)
@ -191,6 +206,26 @@ async def campaigns(user: UserAccount = Depends(current_user), db: Session = Dep
raise http_meta(exc) from exc raise http_meta(exc) from exc
@router.get("/campaigns/{campaign_id}")
async def campaign(campaign_id: str, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await campaign_detail(db, user, campaign_id)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/adsets")
async def adsets(
campaign_id: str | None = None,
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
try:
return await list_adsets(db, user, campaign_id or None)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/campaigns") @router.post("/campaigns")
async def create_campaign(body: CampaignPublish, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict: async def create_campaign(body: CampaignPublish, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body) _confirmed(body)

View file

@ -17,6 +17,7 @@ from .whatsapp_onboarding import (
account_detail, account_detail,
account_logs, account_logs,
complete_signup, complete_signup,
connect_with_token,
customer_token_for_phone, customer_token_for_phone,
developer_config, developer_config,
disconnect_account, disconnect_account,
@ -174,6 +175,12 @@ class RegisterPhoneRequest(BaseModel):
pin: str | None = Field(default=None, max_length=6) pin: str | None = Field(default=None, max_length=6)
class ManualTokenRequest(BaseModel):
access_token: str = Field(min_length=1, max_length=4096)
waba_id: str = Field(min_length=1, max_length=64)
phone_number_id: str | None = Field(default=None, max_length=64)
class SignupCompleteRequest(BaseModel): class SignupCompleteRequest(BaseModel):
event: str = Field(min_length=1, max_length=80) event: str = Field(min_length=1, max_length=80)
code: str | None = Field(default=None, max_length=2048) code: str | None = Field(default=None, max_length=2048)
@ -208,6 +215,21 @@ async def whatsapp_signup_complete(
return await complete_signup(db, user, payload.model_dump()) return await complete_signup(db, user, payload.model_dump())
@router.post("/connections/token")
async def whatsapp_connect_token(
payload: ManualTokenRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await connect_with_token(
db,
user,
payload.access_token,
payload.waba_id,
payload.phone_number_id,
)
@router.get("/accounts/{account_id}") @router.get("/accounts/{account_id}")
def whatsapp_account_detail( def whatsapp_account_detail(
account_id: str, account_id: str,

View file

@ -8,6 +8,7 @@ addressed by another tenant.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import secrets import secrets
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@ -101,11 +102,13 @@ def _quality_label(value: str | None) -> str | None:
def _graph_error_message(data: Any, fallback: str) -> str: def _graph_error_message(data: Any, fallback: str) -> str:
message = fallback
if isinstance(data, dict): if isinstance(data, dict):
error = data.get("error") error = data.get("error")
if isinstance(error, dict) and error.get("message"): if isinstance(error, dict) and error.get("message"):
return str(error["message"])[:500] message = str(error["message"])
return fallback message = re.sub(r"(access_token|token|secret)=([^&\s]+)", r"\1=redacted", message, flags=re.I)
return message[:500]
async def _graph( async def _graph(
@ -607,6 +610,78 @@ async def complete_signup(db: Session, user: UserAccount, payload: dict[str, Any
return {"status": overall, "message": message, "accounts": [serialize_account(account) for account in accounts]} return {"status": overall, "message": message, "accounts": [serialize_account(account) for account in accounts]}
_META_ID = re.compile(r"^\d{6,32}$")
def _clean_token(value: str) -> str:
token = "".join(str(value or "").split())
if token.lower().startswith("bearer"):
token = token[6:]
return token
async def connect_with_token(
db: Session,
user: UserAccount,
access_token: str,
waba_id: str,
phone_number_id: str | None = None,
) -> dict[str, Any]:
"""Store a customer-supplied WhatsApp token and provision that account.
This is the non-embedded alternative to Embedded Signup. The token is
kept on the connection row and is not returned to the browser.
"""
token = _clean_token(access_token)
account_id = "".join(str(waba_id or "").split())
phone_id = "".join(str(phone_number_id or "").split()) or None
if len(token) < 20:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Paste the access token from Meta.")
if not _META_ID.match(account_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Enter the WhatsApp Business account ID from Meta. It is a number.",
)
if phone_id and not _META_ID.match(phone_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Phone number ID should be the number Meta shows for that phone.",
)
connection = WhatsAppMetaConnection(
user_id=user.id,
status="pending",
granted_scopes="manual",
access_token=token,
)
db.add(connection)
db.flush()
_audit(
db,
user,
"token_saved",
"Customer saved a WhatsApp access token",
connection_id=connection.id,
waba_id=account_id,
)
account = await _provision_waba(db, user, connection, token, account_id, phone_id)
db.commit()
db.refresh(account)
if account.status == "connected":
overall = "connected"
message = "WhatsApp Business is connected."
elif account.status == "incomplete":
overall = "incomplete"
message = account.last_error or "The account was saved, but the phone number still needs verification."
elif account.status == "expired":
overall = "expired"
message = account.last_error or "Meta did not accept this access token."
else:
overall = "failed"
message = account.last_error or "Urmi could not finish WhatsApp setup."
return {"status": overall, "message": message, "accounts": [serialize_account(account)]}
async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id) account = _owned_account(db, user, account_id)
connection = account.connection connection = account.connection

View file

@ -357,7 +357,7 @@ Login → grant `ads_management` → **Campaigns → Create Campaign** (or pause
| **Official purpose** | Higher Marketing API rate limits, unlimited ad accounts, higher system-user limits, Business Manager API access. | | **Official purpose** | Higher Marketing API rate limits, unlimited ad accounts, higher system-user limits, Business Manager API access. |
| **Minimum** | `ads_read` **or** `ads_management` | | **Minimum** | `ads_read` **or** `ads_management` |
| **Allowed usage** | Scale marketing ops; read reports (`ads_read`); read and manage ads (`ads_management`); pull reports for some clients and manage ads for others (both permissions). Aggregated de-identified analytics to improve the app is also listed. | | **Allowed usage** | Scale marketing ops; read reports (`ads_read`); read and manage ads (`ads_management`); pull reports for some clients and manage ads for others (both permissions). Aggregated de-identified analytics to improve the app is also listed. |
| **Tiers** | Adding Marketing API product → **Limited** (dev). **Full** needs App Review plus, to **maintain/upgrade**: ~**500 successful Marketing API calls in 15 days** and **&lt;15% errors** on last 500 calls. | | **Tiers** | Adding Marketing API product → **Limited** (dev). **Full** needs App Review plus, to **maintain/upgrade**: ~**500 successful Marketing API calls in 15 days** and **&lt;15% errors** on last 500 calls. Only live **`/act_{ad-account-id}/…`** calls count (insights, campaigns, ad sets, ads). Standard Graph (`/me`, `/me/accounts`, `/me/businesses`, Page posts) does **not** count. |
| **Test call** | Any successful Marketing API call (`/insights`, `/campaigns`, etc.). For Full tier, volume matters after approval. | | **Test call** | Any successful Marketing API call (`/insights`, `/campaigns`, etc.). For Full tier, volume matters after approval. |
**Relation to “Ads Management Standard Access”** **Relation to “Ads Management Standard Access”**

View file

@ -24,6 +24,7 @@ import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
import { MetaPagesPage } from './features/meta/PagesPage' import { MetaPagesPage } from './features/meta/PagesPage'
import { MetaAdAccountsPage } from './features/meta/AdAccountsPage' import { MetaAdAccountsPage } from './features/meta/AdAccountsPage'
import { MetaCampaignsPage } from './features/meta/CampaignsPage' import { MetaCampaignsPage } from './features/meta/CampaignsPage'
import { MetaCampaignDetailPage } from './features/meta/CampaignDetailPage'
import { MetaCampaignBuilderPage } from './features/meta/CampaignBuilderPage' import { MetaCampaignBuilderPage } from './features/meta/CampaignBuilderPage'
import { MetaAdsPage } from './features/meta/AdsPage' import { MetaAdsPage } from './features/meta/AdsPage'
import { MetaAnalyticsPage } from './features/meta/AnalyticsPage' import { MetaAnalyticsPage } from './features/meta/AnalyticsPage'
@ -43,9 +44,9 @@ import { PrivacyPolicyPage } from './features/legal/PrivacyPolicyPage'
function Placeholder() { function Placeholder() {
return ( return (
<section className="placeholder"> <section className="placeholder">
<span className="eyebrow">Urmi workspace</span> <span className="eyebrow">Coming soon</span>
<h1>Module workspace</h1> <h1>This area is on the way</h1>
<p>This product area is ready for the next iteration.</p> <p>We’ll open it here when it’s ready. The rest of your workspace is unchanged.</p>
</section> </section>
) )
} }
@ -78,6 +79,7 @@ export default function App() {
<Route path="meta/ad-accounts" element={<MetaAdAccountsPage />} /> <Route path="meta/ad-accounts" element={<MetaAdAccountsPage />} />
<Route path="meta/campaigns" element={<MetaCampaignsPage />} /> <Route path="meta/campaigns" element={<MetaCampaignsPage />} />
<Route path="meta/campaigns/new" element={<MetaCampaignBuilderPage />} /> <Route path="meta/campaigns/new" element={<MetaCampaignBuilderPage />} />
<Route path="meta/campaigns/:campaignId" element={<MetaCampaignDetailPage />} />
<Route path="meta/ads" element={<MetaAdsPage />} /> <Route path="meta/ads" element={<MetaAdsPage />} />
<Route path="meta/analytics" element={<MetaAnalyticsPage />} /> <Route path="meta/analytics" element={<MetaAnalyticsPage />} />
<Route path="meta/settings" element={<MetaSettingsPage />} /> <Route path="meta/settings" element={<MetaSettingsPage />} />

View file

@ -13,53 +13,53 @@ import { useCompliance } from '../features/compliance/compliance-context'
import { COMPLIANCE_ONLY_PATHS } from '../features/compliance/compliance-context' import { COMPLIANCE_ONLY_PATHS } from '../features/compliance/compliance-context'
const groups = [ const groups = [
{ label: 'Compliance', links: [ { label: 'Get ready', links: [
{ label: 'KYC', path: '/console/kyc', icon: ShieldCheck }, { label: 'Business KYC', path: '/console/kyc', icon: ShieldCheck },
{ label: 'DLT', path: '/console/dlt', icon: FileCheck2 }, { label: 'DLT registration', path: '/console/dlt', icon: FileCheck2 },
{ label: 'Message settings', path: '/console/message-settings', icon: SlidersHorizontal }, { label: 'Senders & templates', path: '/console/message-settings', icon: SlidersHorizontal },
]}, ]},
{ label: 'Workspace', links: [ { label: 'Home', links: [
{ label: 'Overview', path: '/console', icon: LayoutDashboard }, { label: 'Overview', path: '/console', icon: LayoutDashboard },
{ label: 'Activity', path: '/console/activity', icon: Activity }, { label: 'Activity', path: '/console/activity', icon: Activity },
]}, ]},
{ label: 'Agent', links: [ { label: 'Products', links: [
{ label: 'UrmiAI', path: '/console/agent/urmiai', icon: Bot }, { label: 'WhatsApp messages', path: '/console/agent/urmiai', icon: Bot },
{ label: 'UrmIOT', path: '/console/agent/urmiot', icon: Cpu }, { label: 'Device alerts', path: '/console/agent/urmiot', icon: Cpu },
{ label: 'UrmiOTP', path: '/console/agent/urmiotp', icon: KeyRound }, { label: 'Verification codes', path: '/console/agent/urmiotp', icon: KeyRound },
{ label: 'Urmiport', path: '/console/agent/urmiport', icon: FileText }, { label: 'Shared reports', path: '/console/agent/urmiport', icon: FileText },
]}, ]},
{ label: 'Communicate', links: [ { label: 'Messages', links: [
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText }, { label: 'Send a message', path: '/console/messaging', icon: MessageSquareText },
{ label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle }, { label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle },
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap }, { label: 'Campaigns', path: '/console/campaigns', icon: Zap },
{ label: 'Templates', path: '/console/templates', icon: BookOpen }, { label: 'Templates', path: '/console/templates', icon: BookOpen },
]}, ]},
{ label: 'Meta', links: [ { label: 'Advertising', links: [
{ label: 'Overview', path: '/console/meta', icon: LayoutDashboard, end: true }, { label: 'Overview', path: '/console/meta', icon: LayoutDashboard, end: true },
{ label: 'Connections', path: '/console/meta/connections', icon: Link2 }, { label: 'Facebook connection', path: '/console/meta/connections', icon: Link2 },
{ label: 'Facebook Pages', path: '/console/meta/pages', icon: Globe }, { label: 'Facebook Pages', path: '/console/meta/pages', icon: Globe },
{ label: 'Ad Accounts', path: '/console/meta/ad-accounts', icon: CircleDollarSign }, { label: 'Ad accounts', path: '/console/meta/ad-accounts', icon: CircleDollarSign },
{ label: 'Campaigns', path: '/console/meta/campaigns', icon: Megaphone }, { label: 'Ad campaigns', path: '/console/meta/campaigns', icon: Megaphone },
{ label: 'Ads & Creatives', path: '/console/meta/ads', icon: Image }, { label: 'Ads', path: '/console/meta/ads', icon: Image },
{ label: 'Analytics', path: '/console/meta/analytics', icon: BarChart3 }, { label: 'Ad results', path: '/console/meta/analytics', icon: BarChart3 },
{ label: 'Meta Settings', path: '/console/meta/settings', icon: Cog }, { label: 'Ad settings', path: '/console/meta/settings', icon: Cog },
]}, ]},
{ label: 'Numbers', links: [ { label: 'Phone', links: [
{ label: 'Voice', path: '/console/voice', icon: Phone }, { label: 'Calls', path: '/console/voice', icon: Phone },
{ label: 'Buy Numbers', path: '/console/numbers/buy', icon: Search }, { label: 'Get a number', path: '/console/numbers/buy', icon: Search },
{ label: 'My Numbers', path: '/console/numbers', icon: Network, end: true }, { label: 'Your numbers', path: '/console/numbers', icon: Network, end: true },
]}, ]},
{ label: 'Develop', links: [ { label: 'Connections', links: [
{ label: 'Webhooks & Sockets', path: '/console/webhooks', icon: Webhook }, { label: 'Delivery updates', path: '/console/webhooks', icon: Webhook },
{ label: 'API Keys', path: '/console/api-keys', icon: KeyRound }, { label: 'Access keys', path: '/console/api-keys', icon: KeyRound },
{ label: 'API Explorer', path: '/console/api-explorer', icon: Code2 }, { label: 'Try a request', path: '/console/api-explorer', icon: Code2 },
]}, ]},
{ label: 'Test & observe', links: [ { label: 'Insights', links: [
{ label: 'Testing Center', path: '/console/testing', icon: TestTube2 }, { label: 'Send a test', path: '/console/testing', icon: TestTube2 },
{ label: 'Logs', path: '/console/logs', icon: Boxes }, { label: 'Activity history', path: '/console/logs', icon: Boxes },
{ label: 'Analytics', path: '/console/analytics', icon: Gauge }, { label: 'Performance', path: '/console/analytics', icon: Gauge },
]}, ]},
{ label: 'Manage', links: [ { label: 'Account', links: [
{ label: 'Billing', path: '/console/billing', icon: CreditCard }, { label: 'Billing', path: '/console/billing', icon: CreditCard },
{ label: 'Team', path: '/console/team', icon: Users }, { label: 'Team', path: '/console/team', icon: Users },
{ label: 'Settings', path: '/console/settings', icon: Settings }, { label: 'Settings', path: '/console/settings', icon: Settings },
@ -110,9 +110,9 @@ function AppShellContent() {
</nav> </nav>
{unlocked && ( {unlocked && (
<div className="sidebar-plan"> <div className="sidebar-plan">
<div><CircleDollarSign size={17} /><span>Growth plan</span></div> <div><CircleDollarSign size={17} /><span>Account balance</span></div>
<strong>₹24,820.00</strong> <strong>₹24,820.00</strong>
<small>Available balance</small> <small>Available to send</small>
<button>Add funds</button> <button>Add funds</button>
</div> </div>
)} )}
@ -124,8 +124,8 @@ function AppShellContent() {
<header className="topbar"> <header className="topbar">
<button className="icon-button menu-button" onClick={() => setOpen(true)}><Menu size={20} /></button> <button className="icon-button menu-button" onClick={() => setOpen(true)}><Menu size={20} /></button>
<button className="selector">Urmi Technologies <ChevronDown size={14} /></button> <button className="selector">Urmi Technologies <ChevronDown size={14} /></button>
<button className="selector hide-mobile">Production API <ChevronDown size={14} /></button> <button className="selector hide-mobile">Live workspace <ChevronDown size={14} /></button>
<span className="environment"><i /> Production</span> <span className="environment"><i /> Live</span>
<label className="global-search"> <label className="global-search">
<Search size={16} /> <Search size={16} />
<input aria-label="Global search" placeholder="Search numbers, calls, messages..." /> <input aria-label="Global search" placeholder="Search numbers, calls, messages..." />

View file

@ -19,7 +19,7 @@ export function AgentTemplateDocsPage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [slug]) }, [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> if (!docs) return <section className="panel"><p className="auth-error">{error}</p></section>
const requestExample = JSON.stringify(docs.api.example, null, 2) const requestExample = JSON.stringify(docs.api.example, null, 2)
@ -42,19 +42,19 @@ export function AgentTemplateDocsPage() {
<ArrowLeft size={14} /> Back to templates <ArrowLeft size={14} /> Back to templates
</button> </button>
<div className="account-heading"> <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> <h1>{docs.template.name}</h1>
<p>{docs.template.description}</p> <p>{docs.template.description}</p>
</div> </div>
<section className="panel otp-api-overview"> <section className="panel otp-api-overview">
<div className="panel-heading"> <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> <span className="explorer-method">{docs.api.method}</span>
</div> </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"> <div className="explorer-field-row">
<span>Authentication</span> <span>Access</span>
<code>{docs.api.authentication.header}: {docs.api.authentication.format}</code> <code>{docs.api.authentication.header}: {docs.api.authentication.format}</code>
</div> </div>
<div className="otp-parameter-list"> <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> <button className="secondary-button" onClick={copyExample}><Copy size={13} /> {copied ? 'Copied' : 'Copy'}</button>
</div> </div>
<pre className="explorer-code">{requestExample}</pre> <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> <pre className="explorer-code">{curlExample}</pre>
</section> </section>
<section className="panel agent-template-docs-preview"> <section className="panel agent-template-docs-preview">
<div className="panel-heading"> <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>
<div className="agent-template-preview card"> <div className="agent-template-preview card">
{docs.template.header_text && <strong>{docs.template.header_text}</strong>} {docs.template.header_text && <strong>{docs.template.header_text}</strong>}
@ -84,7 +84,7 @@ export function AgentTemplateDocsPage() {
<ul className="agent-template-notes"> <ul className="agent-template-notes">
{docs.notes.map((note) => <li key={note}>{note}</li>)} {docs.notes.map((note) => <li key={note}>{note}</li>)}
</ul> </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> </section>
</div> </div>
) )

View file

@ -47,7 +47,7 @@ function TestModal({
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button> <button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
</div> </div>
<p className="agent-template-test-note"> <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> </p>
<div className="otp-project-form"> <div className="otp-project-form">
<label> <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 ( return (
<div className="agent-templates-page"> <div className="agent-templates-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Agent · WhatsApp</span> <span className="eyebrow">Products</span>
<h1><Bot size={24} /> UrmiAI agent messages</h1> <h1><Bot size={24} /> WhatsApp messages</h1>
<p> <p>
Subscribe to approved WhatsApp agent templates. Urmi authenticates your API key and sends messages Turn on the WhatsApp templates you want to send. Urmi delivers them from the Urmi business number once your access key is accepted.
from the Urmi AI business number on behalf of your agents.
</p> </p>
</div> </div>
@ -143,7 +142,7 @@ export function AgentTemplatesPage() {
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>Available templates</h2> <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> </div>
<Code2 size={19} /> <Code2 size={19} />
</div> </div>
@ -169,7 +168,7 @@ export function AgentTemplatesPage() {
<div className="agent-template-row" key={template.id}> <div className="agent-template-row" key={template.id}>
<div> <div>
<strong>{template.name}</strong> <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> <small>{template.description}</small>
</div> </div>
<div className="agent-template-preview"> <div className="agent-template-preview">
@ -188,7 +187,7 @@ export function AgentTemplatesPage() {
</div> </div>
<div> <div>
<span className={`doc-status ${template.subscribed ? 'approved' : 'pending'}`}> <span className={`doc-status ${template.subscribed ? 'approved' : 'pending'}`}>
{template.subscribed ? 'Subscribed' : 'Not subscribed'} {template.subscribed ? 'On' : 'Off'}
</span> </span>
{template.subscribed && status && !status.allowed && status.retry_after && ( {template.subscribed && status && !status.allowed && status.retry_after && (
<small>Test again after {new Date(status.retry_after).toLocaleString()}</small> <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'} className={template.subscribed ? 'secondary-button' : 'primary-button'}
onClick={() => toggleSubscribe(template)} onClick={() => toggleSubscribe(template)}
> >
{template.subscribed ? 'Unsubscribe' : 'Subscribe'} {template.subscribed ? 'Turn off' : 'Turn on'}
</button> </button>
{template.subscribed && ( {template.subscribed && (
<> <>
@ -208,13 +207,13 @@ export function AgentTemplatesPage() {
disabled={!canTest} disabled={!canTest}
onClick={() => setTestTemplate(template)} onClick={() => setTestTemplate(template)}
> >
<Play size={14} /> Test API <Play size={14} /> Send a test
</button> </button>
<button <button
className="secondary-button" className="secondary-button"
onClick={() => navigate(`/console/agent/urmiai/templates/${template.slug}/docs`)} onClick={() => navigate(`/console/agent/urmiai/templates/${template.slug}/docs`)}
> >
View docs <ArrowRight size={14} /> How to send <ArrowRight size={14} />
</button> </button>
</> </>
)} )}
@ -231,16 +230,16 @@ export function AgentTemplatesPage() {
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>How it works</h2> <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>
</div> </div>
<ol className="relay-steps"> <ol className="relay-steps">
<li>Subscribe to a template in this console.</li> <li>Turn on a template on this page.</li>
<li>Create an API key under Develop → API Keys.</li> <li>Create an access key under Connections → Access keys.</li>
<li>Call <code>POST /api/v1/agent/messages/send</code> with your Urmi API key.</li> <li>Your system sends the message with that key.</li>
<li>Urmi validates your subscription and relays the message to Meta WhatsApp Cloud API.</li> <li>Urmi checks the template and delivers it on WhatsApp.</li>
</ol> </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> </section>
{testTemplate && ( {testTemplate && (

View file

@ -18,7 +18,7 @@ export function UrmiOtpDocsPage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [projectId]) }, [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> if (!docs) return <section className="panel"><p className="auth-error">{error}</p></section>
const requestExample = JSON.stringify(docs.api.example, null, 2) const requestExample = JSON.stringify(docs.api.example, null, 2)
@ -35,8 +35,8 @@ export function UrmiOtpDocsPage() {
<ArrowLeft size={14} /> Back to projects <ArrowLeft size={14} /> Back to projects
</button> </button>
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">UrmiOTP · {docs.project.project_name}</span> <span className="eyebrow">Verification codes · {docs.project.project_name}</span>
<h1>API documentation</h1> <h1>How to send a code</h1>
<p className="otp-message-label"> <p className="otp-message-label">
Message label: <strong>{docs.project.message_label}</strong> Message label: <strong>{docs.project.message_label}</strong>
<span> — your product identification in each message. URMI branding remains unchanged.</span> <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"> <section className="panel otp-api-overview">
<div className="panel-heading"> <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> <span className="explorer-method">{docs.api.method}</span>
</div> </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"> <div className="otp-api-notice">
<Code2 size={16} /> <Code2 size={16} />
<span> <span>
SMS delivery uses the DLT-approved URMI OTP template. <code>template_type</code> is for Customers receive the approved verification text. The type you choose only groups the request. The message they see matches the preview.
categorization; the sent text matches <code>message_preview</code> in the API response.
</span> </span>
</div> </div>
<div className="otp-parameter-list"> <div className="otp-parameter-list">

View file

@ -168,9 +168,9 @@ export function UrmiOtpPage() {
<div className="urmiotp-page"> <div className="urmiotp-page">
<div className="account-heading otp-page-heading"> <div className="account-heading otp-page-heading">
<div> <div>
<span className="eyebrow">Agent · Unified OTP</span> <span className="eyebrow">Products</span>
<h1>UrmiOTP</h1> <h1>Verification codes</h1>
<p>One verification platform and a small set of generic templates for every app and OTP journey.</p> <p>Register each product that sends login and security codes. Approved projects can send from a shared set of templates.</p>
</div> </div>
<button className="primary-button" onClick={() => setModalOpen(true)}><Plus size={15} /> Add a project</button> <button className="primary-button" onClick={() => setModalOpen(true)}><Plus size={15} /> Add a project</button>
</div> </div>
@ -179,7 +179,7 @@ export function UrmiOtpPage() {
<section className="panel otp-projects-panel"> <section className="panel otp-projects-panel">
<div className="panel-heading"> <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} /> <Smartphone size={19} />
</div> </div>
{projects.length === 0 ? ( {projects.length === 0 ? (
@ -212,10 +212,10 @@ export function UrmiOtpPage() {
className="secondary-button" className="secondary-button"
onClick={() => navigate(`/console/agent/urmiotp/projects/${project.id}/docs`)} onClick={() => navigate(`/console/agent/urmiotp/projects/${project.id}/docs`)}
> >
API documentation <ArrowRight size={14} /> How to send codes <ArrowRight size={14} />
</button> </button>
) : project.status === 'pending' ? ( ) : 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> <button className="secondary-button" onClick={() => setModalOpen(true)}>Register another</button>
)} )}

View file

@ -59,18 +59,18 @@ function GroupModal({
</div> </div>
<div className="otp-project-form"> <div className="otp-project-form">
<label> <label>
<span>Report group</span> <span>Group name</span>
<input <input
value={reportGroup} value={reportGroup}
onChange={(event) => setReportGroup(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} onChange={(event) => setReportGroup(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="acme-agent-runs" placeholder="northwind-reports"
required 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>
<label> <label>
<span>Display name (optional)</span> <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> </label>
<fieldset> <fieldset>
<legend>Default access</legend> <legend>Default access</legend>
@ -84,9 +84,9 @@ function GroupModal({
</label> </label>
)} )}
<fieldset> <fieldset>
<legend>Report ID mode</legend> <legend>How reports are named</legend>
<label><input type="radio" checked={idMode === 'auto'} onChange={() => setIdMode('auto')} /> Auto-generate postfix</label> <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')} /> Require report_id in API</label> <label><input type="radio" checked={idMode === 'manual'} onChange={() => setIdMode('manual')} /> You choose the name when you publish</label>
</fieldset> </fieldset>
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
</div> </div>
@ -136,9 +136,9 @@ export function UrmiportPage() {
<div className="urmiport-page"> <div className="urmiport-page">
<header className="page-header"> <header className="page-header">
<div> <div>
<span className="eyebrow">Agent</span> <span className="eyebrow">Products</span>
<h1><FileText size={24} /> Urmiport</h1> <h1><FileText size={24} /> Shared reports</h1>
<p>Host HTML reports at urmiai.com/reports/{'{report_group}'}/{'{report_id}'} — no Urmi branding on the public page.</p> <p>Publish a page for each report and share the link. The public page does not show Urmi branding.</p>
</div> </div>
<button className="primary-button" onClick={() => setShowModal(true)}> <button className="primary-button" onClick={() => setShowModal(true)}>
<Plus size={16} /> New report group <Plus size={16} /> New report group
@ -151,7 +151,7 @@ export function UrmiportPage() {
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>Report groups</h2> <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>
</div> </div>
{loading ? ( {loading ? (
@ -163,9 +163,9 @@ export function UrmiportPage() {
<div className="urmiport-group-table-head"> <div className="urmiport-group-table-head">
<span>Group</span> <span>Group</span>
<span>Access</span> <span>Access</span>
<span>ID mode</span> <span>Naming</span>
<span>Reports</span> <span>Reports</span>
<span>URL pattern</span> <span>Link</span>
</div> </div>
{groups.map((group) => ( {groups.map((group) => (
<button <button
@ -193,17 +193,17 @@ export function UrmiportPage() {
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>Reports in {selectedGroup}</h2> <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>
</div> </div>
{reportsLoading ? ( {reportsLoading ? (
<p className="panel-empty">Loading reports…</p> <p className="panel-empty">Loading reports…</p>
) : !reports.length ? ( ) : !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">
<div className="urmiport-report-table-head"> <div className="urmiport-report-table-head">
<span>Report ID</span> <span>Name</span>
<span>Access</span> <span>Access</span>
<span>Updated</span> <span>Updated</span>
<span>URL</span> <span>URL</span>
@ -227,16 +227,16 @@ export function UrmiportPage() {
<section className="panel urmiport-docs-panel"> <section className="panel urmiport-docs-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2><Code2 size={18} /> API documentation</h2> <h2><Code2 size={18} /> Publish from your systems</h2>
<p>{docs.summary}</p> <p>{docs.summary}</p>
</div> </div>
<span className="explorer-method">{docs.method}</span> <span className="explorer-method">{docs.method}</span>
</div> </div>
<div className="urmiport-docs-grid"> <div className="urmiport-docs-grid">
<div> <div>
<h3>Endpoint</h3> <h3>Where to publish</h3>
<code>POST /api/v1/urmiport/reports</code> <code>POST /api/v1/urmiport/reports</code>
<h3>Authentication</h3> <h3>Access</h3>
<p><code>{docs.authentication.header}: {docs.authentication.format}</code></p> <p><code>{docs.authentication.header}: {docs.authentication.format}</code></p>
<h3>Request body</h3> <h3>Request body</h3>
<ul className="agent-template-notes"> <ul className="agent-template-notes">
@ -255,15 +255,15 @@ export function UrmiportPage() {
<div> <div>
<h3>Example request</h3> <h3>Example request</h3>
<pre className="explorer-code">{JSON.stringify({ <pre className="explorer-code">{JSON.stringify({
report_group: 'acme-agent-runs', report_group: 'northwind',
report_id: 'run-abc123', report_id: 'april-summary',
body: '<html><body><h1>Agent run complete</h1></body></html>', body: '<html><body><h1>April summary</h1></body></html>',
access: 'public', access: 'public',
}, null, 2)}</pre> }, null, 2)}</pre>
<h3>Example response</h3> <h3>Example response</h3>
<pre className="explorer-code">{JSON.stringify(docs.responses['200']?.example ?? {}, null, 2)}</pre> <pre className="explorer-code">{JSON.stringify(docs.responses['200']?.example ?? {}, null, 2)}</pre>
<Link to="/console/api-explorer" className="text-button"> <Link to="/console/api-explorer" className="text-button">
Try in API Explorer <ArrowRight size={14} /> Try a live request <ArrowRight size={14} />
</Link> </Link>
</div> </div>
</div> </div>

View file

@ -24,27 +24,27 @@ const PERIODS: Array<{ value: AnalyticsPeriod; label: string; hint: string }> =
] ]
const SERVICES: Array<{ value: AnalyticsService; label: string; hint: string }> = [ const SERVICES: Array<{ value: AnalyticsService; label: string; hint: string }> = [
{ value: 'sms', label: 'SMS', hint: 'DLT template messaging' }, { value: 'sms', label: 'Text messages', hint: 'Messages sent from approved templates' },
{ value: 'inbound', label: 'Inbound', hint: 'Calls to your Urmi numbers' }, { value: 'inbound', label: 'Calls received', hint: 'Calls to your numbers' },
{ value: 'outbound', label: 'Outbound', hint: 'Calls placed from your numbers' }, { value: 'outbound', label: 'Calls placed', hint: 'Calls you placed' },
{ value: 'otp', label: 'OTP', hint: 'UrmiOTP verification messages' }, { value: 'otp', label: 'Verification codes', hint: 'Codes sent to customers' },
] ]
const GROUP_BY: Record<AnalyticsService, Array<{ value: AnalyticsGroupBy; label: string; hint: string }>> = { const GROUP_BY: Record<AnalyticsService, Array<{ value: AnalyticsGroupBy; label: string; hint: string }>> = {
sms: [ sms: [
{ value: 'dlt_sender', label: 'DLT sender', hint: 'Approved sender header' }, { value: 'dlt_sender', label: 'Sender ID', hint: 'Approved sender name' },
{ value: 'dlt_template', label: 'DLT template', hint: 'Template name' }, { value: 'dlt_template', label: 'Template', hint: 'Message template' },
], ],
inbound: [ 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: [ outbound: [
{ value: 'socket', label: 'Voice socket name', hint: 'Workspace name linked to a number' }, { value: 'socket', label: 'Call connection', hint: 'The name linked to a number' },
{ value: 'did', label: 'DID number', hint: 'Which Urmi number placed the call' }, { value: 'did', label: 'Phone number', hint: 'Which of your numbers placed the call' },
], ],
otp: [ otp: [
{ value: 'project', label: 'OTP project', hint: 'UrmiOTP project name' }, { value: 'project', label: 'Project', hint: 'Verification project name' },
{ value: 'otp_type', label: 'OTP type', hint: 'Verification template type' }, { value: 'otp_type', label: 'Code type', hint: 'The kind of verification' },
], ],
} }
@ -183,9 +183,9 @@ export function AnalyticsPage() {
return ( return (
<div className="analytics-page"> <div className="analytics-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Test & observe</span> <span className="eyebrow">Insights</span>
<h1>Analytics</h1> <h1>Performance</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> <p>Choose messages, calls, or verification codes, then see the results by sender, number, or project.</p>
</div> </div>
<section className="panel analytics-toolbar"> <section className="panel analytics-toolbar">
@ -239,28 +239,28 @@ export function AnalyticsPage() {
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
{loading ? ( {loading ? (
<p className="explorer-loading">Loading analytics…</p> <p className="explorer-loading">Loading performance…</p>
) : data && ( ) : data && (
<> <>
<section className="analytics-summary-grid"> <section className="analytics-summary-grid">
<SummaryCard label="Total SMS" value={data.totals.sms} tone="sms" /> <SummaryCard label="Text messages" value={data.totals.sms} tone="sms" />
<SummaryCard label="Total inbound" value={data.totals.inbound} tone="inbound" /> <SummaryCard label="Calls received" value={data.totals.inbound} tone="inbound" />
<SummaryCard label="Total outbound" value={data.totals.outbound} tone="outbound" /> <SummaryCard label="Calls placed" value={data.totals.outbound} tone="outbound" />
<SummaryCard label="Total OTPs" value={data.totals.otp} tone="otp" /> <SummaryCard label="Verification codes" value={data.totals.otp} tone="otp" />
</section> </section>
<div className="analytics-grid"> <div className="analytics-grid">
<section className="panel chart-panel analytics-chart-panel"> <section className="panel chart-panel analytics-chart-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>All services over time</h2> <h2>Everything over time</h2>
<p>Grouped bar chart for SMS, inbound, outbound, and OTP volumes</p> <p>Texts, calls, and verification codes in the period you selected</p>
</div> </div>
<div className="legend"> <div className="legend">
<span><i style={{ background: SERVICE_COLORS.sms }} /> SMS</span> <span><i style={{ background: SERVICE_COLORS.sms }} /> Texts</span>
<span><i style={{ background: SERVICE_COLORS.inbound }} /> Inbound</span> <span><i style={{ background: SERVICE_COLORS.inbound }} /> Received</span>
<span><i style={{ background: SERVICE_COLORS.outbound }} /> Outbound</span> <span><i style={{ background: SERVICE_COLORS.outbound }} /> Placed</span>
<span><i style={{ background: SERVICE_COLORS.otp }} /> OTP</span> <span><i style={{ background: SERVICE_COLORS.otp }} /> Codes</span>
</div> </div>
</div> </div>
<div className="chart-wrap analytics-chart-wrap"> <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} /> <YAxis allowDecimals={false} axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} width={38} />
<Tooltip content={<UsageTooltip />} /> <Tooltip content={<UsageTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} /> <Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="sms" name="SMS" fill={SERVICE_COLORS.sms} 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="Inbound" fill={SERVICE_COLORS.inbound} 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="Outbound" fill={SERVICE_COLORS.outbound} 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="OTP" fill={SERVICE_COLORS.otp} 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> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (

View file

@ -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> <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>
<section className="panel account-password"> <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}> <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>} {user?.has_password && <label><span>Current password</span><input required type="password" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} placeholder="Your current password" /></label>}
<label><span>New password</span><input required minLength={10} type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="At least 10 characters" /></label> <label><span>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>

View file

@ -46,12 +46,12 @@ export function AuthPage({ initialMode }: { initialMode: 'login' | 'register' })
<div className="auth-visual"> <div className="auth-visual">
<Link to="/" className="auth-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link> <Link to="/" className="auth-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link>
<div className="auth-message"> <div className="auth-message">
<span>SMS FOR AI AGENTS</span> <span>CUSTOMER MESSAGING</span>
<h1>From agent decision to human action.</h1> <h1>Messages your customers actually receive.</h1>
<p>Secure messaging infrastructure with delivery, compliance, and observability built in.</p> <p>Text, WhatsApp, verification codes, and calls in one workspace, with the approvals Indian businesses need.</p>
<div><span><Check size={15} /> Unified agent messaging API</span><span><Check size={15} /> India-ready compliance</span><span><Check size={15} /> Complete event visibility</span></div> <div><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> </div>
<small>Build → Test → Deploy → Observe</small> <small>Set up · Approve · Send · Review</small>
</div> </div>
<main className="auth-main"> <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> <button className={mode === 'register' ? 'active' : ''} onClick={() => switchMode('register')}>Create account</button>
</div> </div>
<div className="auth-heading"> <div className="auth-heading">
<h2>{mode === 'login' ? 'Welcome back' : 'Start building with Urmi'}</h2> <h2>{mode === 'login' ? 'Welcome back' : 'Create your workspace'}</h2>
<p>{mode === 'login' ? 'Access your agent messaging workspace.' : 'Create your workspace and send your first test message.'}</p> <p>{mode === 'login' ? 'Sign in to messages, calls, and your account.' : 'Set up Urmi and send your first message.'}</p>
</div> </div>
<a className="google-button" href={authApi.googleUrl}> <a className="google-button" href={authApi.googleUrl}>

View file

@ -88,7 +88,7 @@ export function CampaignDetailPage() {
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Campaign · {campaign.name}</span> <span className="eyebrow">Campaign · {campaign.name}</span>
<h1>CSV uploads</h1> <h1>Recipient files</h1>
<p> <p>
{campaign.sender_id} · {campaign.template_name} · number column <code>{campaign.number_column}</code> {campaign.sender_id} · {campaign.template_name} · number column <code>{campaign.number_column}</code>
{campaign.variable_columns.length > 0 && ( {campaign.variable_columns.length > 0 && (
@ -100,7 +100,7 @@ export function CampaignDetailPage() {
<section className="panel campaign-upload-panel"> <section className="panel campaign-upload-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <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> <p>File is validated against <strong>{campaign.template_name}</strong> before it is stored.</p>
</div> </div>
<Upload size={19} /> <Upload size={19} />
@ -129,7 +129,7 @@ export function CampaignDetailPage() {
<section className="panel campaigns-table-panel"> <section className="panel campaigns-table-panel">
<div className="panel-heading"> <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> </div>
{uploads.length === 0 ? ( {uploads.length === 0 ? (

View file

@ -262,44 +262,44 @@ export function CampaignsPage() {
return ( return (
<div className="campaigns-page"> <div className="campaigns-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Communicate</span> <span className="eyebrow">Messages</span>
<h1>Campaigns</h1> <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> </div>
<section className="panel campaigns-create-panel"> <section className="panel campaigns-create-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>Create automation</h2> <h2>New campaign</h2>
<p>Map CSV columns to your template, then upload recipient files.</p> <p>Match the columns in your file to the template, then upload recipients.</p>
</div> </div>
<button className="primary-button" onClick={() => setModalOpen(true)}> <button className="primary-button" onClick={() => setModalOpen(true)}>
<Plus size={15} /> Create campaign <Plus size={15} /> Create campaign
</button> </button>
</div> </div>
<p className="campaigns-note"> <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> </p>
</section> </section>
<section className="panel campaigns-table-panel"> <section className="panel campaigns-table-panel">
<div className="panel-heading"> <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} /> <Zap size={19} />
</div> </div>
{campaigns.length === 0 ? ( {campaigns.length === 0 ? (
<div className="campaigns-empty"> <div className="campaigns-empty">
<strong>No campaigns yet</strong> <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>
) : ( ) : (
<div className="campaigns-table"> <div className="campaigns-table">
<div className="campaigns-table-head"> <div className="campaigns-table-head">
<span>Campaign</span> <span>Campaign</span>
<span>Sender · Template</span> <span>Sender · Template</span>
<span>Run mode</span> <span>When to send</span>
<span>CSV files</span> <span>Files</span>
<span /> <span />
</div> </div>
{campaigns.map((campaign) => ( {campaigns.map((campaign) => (
@ -328,7 +328,7 @@ export function CampaignsPage() {
disabled={runningId === campaign.id || campaign.csv_upload_count === 0} disabled={runningId === campaign.id || campaign.csv_upload_count === 0}
onClick={() => runAutomation(campaign.id)} onClick={() => runAutomation(campaign.id)}
> >
<Play size={14} /> {runningId === campaign.id ? 'Running…' : 'Run automation'} <Play size={14} /> {runningId === campaign.id ? 'Running…' : 'Run campaign'}
</button> </button>
</div> </div>
</div> </div>

View file

@ -36,7 +36,7 @@ export function ComplianceGate() {
const { status, loading } = useCompliance() const { status, loading } = useCompliance()
const location = useLocation() 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 unlocked = status?.console_unlocked ?? false
const allowed = COMPLIANCE_ONLY_PATHS.includes(location.pathname) || location.pathname === '/console/account' const allowed = COMPLIANCE_ONLY_PATHS.includes(location.pathname) || location.pathname === '/console/account'

View file

@ -139,15 +139,15 @@ export function DltPage() {
return ( return (
<div className="kyc-page"> <div className="kyc-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Compliance</span> <span className="eyebrow">Get ready</span>
<h1>DLT registration</h1> <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> </div>
{dlt?.completed && ( {dlt?.completed && (
<div className="kyc-complete-banner"> <div className="kyc-complete-banner">
<CheckCircle2 size={22} /> <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> </div>
)} )}
@ -155,15 +155,15 @@ export function DltPage() {
<div className="compliance-lock-banner"> <div className="compliance-lock-banner">
<FileCheck2 size={20} /> <FileCheck2 size={20} />
<div> <div>
<strong>Complete DLT to unlock the workspace</strong> <strong>Finish DLT to open the rest of your workspace</strong>
<span>Until DLT is approved, only KYC and DLT are available in the console.</span> <span>Until this is approved, only Business KYC and DLT registration are available.</span>
</div> </div>
</div> </div>
)} )}
<section className="panel kyc-documents"> <section className="panel kyc-documents">
<div className="panel-heading"> <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} /> <Upload size={20} />
</div> </div>
{dlt?.documents.map((doc) => ( {dlt?.documents.map((doc) => (

View file

@ -113,21 +113,21 @@ export function KycPage() {
return ( return (
<div className="kyc-page"> <div className="kyc-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Compliance</span> <span className="eyebrow">Get ready</span>
<h1>Business KYC</h1> <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> </div>
{kyc?.completed && ( {kyc?.completed && (
<div className="kyc-complete-banner"> <div className="kyc-complete-banner">
<CheckCircle2 size={22} /> <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>
)} )}
<div className="kyc-grid"> <div className="kyc-grid">
<section className="panel"> <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}> <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 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> <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>
<section className="panel"> <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> <button type="button" className="secondary-button full-width" onClick={downloadTemplate}><Download size={15} /> Download KYC Form - Pvt Ltd Client.docx</button>
</section> </section>
</div> </div>
@ -162,7 +162,7 @@ export function KycPage() {
</div> </div>
))} ))}
{kyc?.status !== 'approved' && ( {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> </section>
</div> </div>

View file

@ -1,5 +1,5 @@
import { import {
ArrowRight, Check, ChevronRight, Clock3, Code2, MessageSquareText, Activity, ArrowRight, Check, ChevronRight, Clock3, MessageSquareText,
Phone, Plus, Send, ShieldCheck, Sparkles, TrendingUp, TriangleAlert, Phone, Plus, Send, ShieldCheck, Sparkles, TrendingUp, TriangleAlert,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
@ -29,6 +29,13 @@ function formatCount(value: number) {
return value.toLocaleString('en-IN') 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) { function formatRelativeTime(value: string) {
const diffMs = Date.now() - new Date(value).getTime() const diffMs = Date.now() - new Date(value).getTime()
const minutes = Math.max(1, Math.round(diffMs / 60_000)) const minutes = Math.max(1, Math.round(diffMs / 60_000))
@ -54,10 +61,10 @@ export function Dashboard() {
time: string time: string
tone: string tone: string
}>>([ }>>([
{ icon: Phone, title: 'Outbound call completed', detail: '+91 98765 43210 · 02:14', time: '2 min ago', tone: 'blue' }, { icon: Phone, title: 'Call completed', detail: '+91 98765 43210 · 2 min 14 sec', time: '2 min ago', tone: 'blue' },
{ icon: MessageSquareText, title: 'Message delivered', detail: 'msg_8f7g2h1 · +91 99876 54321', time: '6 min ago', tone: 'cyan' }, { icon: MessageSquareText, title: 'Text message delivered', detail: 'Order update · +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: ShieldCheck, title: 'Message template approved', detail: 'Login code · 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: TriangleAlert, title: 'Delivery update retried', detail: 'Checkout alerts · second try', time: '31 min ago', tone: 'amber' },
]) ])
useEffect(() => { useEffect(() => {
@ -83,10 +90,10 @@ export function Dashboard() {
}, []) }, [])
const stats = useMemo(() => [ const stats = useMemo(() => [
{ label: 'Voice calls', value: formatCount(metrics.voice_calls), change: '+8.4%', hint: 'vs. last week', icon: Phone, tone: 'blue' }, { label: '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: 'Texts 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: 'Delivered', 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: 'Activity', value: formatCount(metrics.api_requests), change: '+5.8%', hint: 'vs. last week', icon: Activity, tone: 'amber' },
], [metrics]) ], [metrics])
const firstName = user?.name.split(' ')[0] ?? 'there' const firstName = user?.name.split(' ')[0] ?? 'there'
@ -96,8 +103,8 @@ export function Dashboard() {
<div className="welcome-row"> <div className="welcome-row">
<div> <div>
<span className="eyebrow">{new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long' })}</span> <span className="eyebrow">{new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long' })}</span>
<h1>Good morning, {firstName} <span>👋</span></h1> <h1>{greeting()}, {firstName} <span>👋</span></h1>
<p>Here’s what’s happening across your communications today.</p> <p>A snapshot of calls, messages, and delivery today.</p>
</div> </div>
<div className="welcome-actions"> <div className="welcome-actions">
<button className="secondary-button"><Clock3 size={16} /> Last 7 days</button> <button className="secondary-button"><Clock3 size={16} /> Last 7 days</button>
@ -125,8 +132,8 @@ export function Dashboard() {
<div className="dashboard-grid"> <div className="dashboard-grid">
<section className="panel chart-panel"> <section className="panel chart-panel">
<div className="panel-heading"> <div className="panel-heading">
<div><h2>Communication overview</h2><p>Traffic across your active products</p></div> <div><h2>Calls and texts</h2><p>The last seven days</p></div>
<div className="legend"><span><i className="calls-dot" /> Voice calls</span><span><i className="messages-dot" /> SMS sent</span></div> <div className="legend"><span><i className="calls-dot" /> Calls</span><span><i className="messages-dot" /> Texts</span></div>
</div> </div>
<div className="chart-wrap"> <div className="chart-wrap">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@ -147,15 +154,15 @@ export function Dashboard() {
</section> </section>
<section className="panel insights-panel"> <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="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>Delivery rate improved 2.1%</strong><p>Compared to the previous 7 days</p></div></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>Peak calling hour: 10–11 AM</strong><p>Average wait time is trending down</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 DLT templates need attention</strong><p>Review and update before expiry</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> <button className="text-button">View all insights <ChevronRight size={15} /></button>
</section> </section>
<section className="panel activity-panel"> <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"> <div className="activity-list">
{activities.map(({ icon: Icon, title, detail, time, tone }) => ( {activities.map(({ icon: Icon, title, detail, time, tone }) => (
<div className="activity-item" key={`${title}-${detail}`}> <div className="activity-item" key={`${title}-${detail}`}>
@ -168,16 +175,16 @@ export function Dashboard() {
</section> </section>
<section className="panel compliance-panel"> <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'], ['Business KYC', 'Approved'],
['DLT principal entity', 'Approved'], ['DLT registration', 'Approved'],
['Headers', '3 approved'], ['Sender IDs', '3 approved'],
['Content templates', '12 approved'], ['Message templates', '12 approved'],
].map(([name, status]) => ( ].map(([name, status]) => (
<div className="compliance-row" key={name}><span><Check size={14} /> {name}</span><strong>{status}</strong></div> <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> </section>
</div> </div>
</div> </div>

View file

@ -43,7 +43,7 @@ function ExplorerGroup({ title, children }: { title: string; children: ReactNode
function RelayCallout({ principle, latency }: { principle: string; latency: string }) { function RelayCallout({ principle, latency }: { principle: string; latency: string }) {
return ( return (
<div className="relay-callout"> <div className="relay-callout">
<strong>Transparent relay — minimum latency</strong> <strong>Calls go through as they happen</strong>
<p>{principle}</p> <p>{principle}</p>
<p>{latency}</p> <p>{latency}</p>
</div> </div>
@ -74,8 +74,8 @@ function normalizeVoiceDocs(raw: Partial<VoiceApiDocs> | null): VoiceApiDocs | n
response_example: raw.response_example ?? { id: '…', status: 'initiated' }, response_example: raw.response_example ?? { id: '…', status: 'initiated' },
relay: raw.relay ?? DEFAULT_RELAY, relay: raw.relay ?? DEFAULT_RELAY,
outbound_flow: raw.outbound_flow ?? [ outbound_flow: raw.outbound_flow ?? [
'Link your number to a voice socket under Webhooks & Sockets.', 'Connect the number for live call audio under Delivery updates.',
'POST /api/v1/voice/calls with from, to, and bridge_mode upstream.', 'Place the call from your system with the from and to numbers.',
], ],
endpoints: raw.endpoints ?? [ endpoints: raw.endpoints ?? [
{ method: 'POST', path: '/api/v1/voice/calls', description: 'Place an outbound call' }, { 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 { function normalizeIntegrationDocs(raw: Partial<IntegrationsDocs> | null): IntegrationsDocs | null {
if (!raw) return null if (!raw) return null
return { return {
title: raw.title ?? 'Webhooks & Sockets', title: raw.title ?? 'Delivery updates',
summary: raw.summary ?? 'Connect each Urmi number to your application.', summary: raw.summary ?? 'Tell Urmi where to send call audio and text updates for each number.',
relay: raw.relay ?? DEFAULT_RELAY, relay: raw.relay ?? DEFAULT_RELAY,
socket: raw.socket ?? { socket: raw.socket ?? {
title: 'Voice sockets', title: 'Live call audio',
description: 'Link a WebSocket endpoint to a number for live call audio.', description: 'Connect a secure address so call audio can reach your systems.',
events: ['connected', 'start', 'media', 'end'], events: ['connected', 'start', 'media', 'end'],
authentication: { authentication: {
headers: ['X-Urmi-Endpoint-Id', 'X-Urmi-Timestamp', 'X-Urmi-Signature'], 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 ?? { webhook: raw.webhook ?? {
title: 'SMS delivery webhooks', title: 'Text delivery updates',
description: 'Receive SMS delivery status updates at your HTTPS endpoint.', description: 'Receive a secure update when a text message is delivered.',
event: 'message.delivery.updated', event: 'message.delivery.updated',
statuses: ['queued', 'sent', 'delivered', 'undelivered', 'failed'], statuses: ['queued', 'sent', 'delivered', 'undelivered', 'failed'],
authentication: { authentication: {
@ -114,7 +114,7 @@ function normalizeIntegrationDocs(raw: Partial<IntegrationsDocs> | null): Integr
api_authentication: raw.api_authentication ?? { api_authentication: raw.api_authentication ?? {
header: 'Authorization', header: 'Authorization',
format: 'Bearer urmi_live_<your_api_key>', 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 ?? [], endpoints: raw.endpoints ?? [],
} }
@ -149,7 +149,7 @@ export function ApiExplorerPage() {
const [urmiportDoc, setUrmiportDoc] = useState<UrmiportApiDoc | null>(null) const [urmiportDoc, setUrmiportDoc] = useState<UrmiportApiDoc | null>(null)
const [urmiportGroup, setUrmiportGroup] = useState('') const [urmiportGroup, setUrmiportGroup] = useState('')
const [urmiportReportId, setUrmiportReportId] = 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 [urmiportAccess, setUrmiportAccess] = useState('public')
const [urmiportPassword, setUrmiportPassword] = useState('') const [urmiportPassword, setUrmiportPassword] = useState('')
const [urmiportResponse, setUrmiportResponse] = useState<UrmiportPublishResponse | null>(null) const [urmiportResponse, setUrmiportResponse] = useState<UrmiportPublishResponse | null>(null)
@ -177,12 +177,12 @@ export function ApiExplorerPage() {
.then((results) => { .then((results) => {
const [smsResult, agentResult, urmiportResult, voiceResult, integrationResult, numbersResult] = results const [smsResult, agentResult, urmiportResult, voiceResult, integrationResult, numbersResult] = results
if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value) if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value)
if (agentResult.status === 'fulfilled') { if (agentResult.status === 'fulfilled' && agentResult.value.request_body) {
setAgentDoc(agentResult.value) setAgentDoc(agentResult.value)
setAgentTemplate(agentResult.value.request_body.template?.example ?? 'agent_run_completion') setAgentTemplate(agentResult.value.request_body.template?.example ?? 'agent_run_completion')
setAgentReportGroup(agentResult.value.request_body.report_group?.example ?? 'acme-agent-runs') 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) setUrmiportDoc(urmiportResult.value)
setUrmiportGroup(urmiportResult.value.request_body.report_group?.example ?? 'acme-agent-runs') 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') const failures = results.filter((result) => result.status === 'rejected')
if (failures.length === results.length) { 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) { } else if (failures.length > 0) {
setLoadError('Some documentation sections could not be loaded.') setLoadError('Some documentation sections could not be loaded.')
} }
@ -207,10 +207,10 @@ export function ApiExplorerPage() {
const smsCurl = useMemo(() => { const smsCurl = useMemo(() => {
if (!smsDoc) return '' if (!smsDoc) return ''
const body = JSON.stringify({ 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', template_id: templateId || '00000000-0000-0000-0000-000000000001',
to: smsTo || smsDoc.request_body.to.example, to: smsTo || smsDoc.request_body.to?.example || '',
message: message || smsDoc.request_body.message.example, message: message || smsDoc.request_body.message?.example || '',
}, null, 2) }, null, 2)
return [ return [
`curl -X POST '${API_BASE_URL}${smsDoc.path}' \\`, `curl -X POST '${API_BASE_URL}${smsDoc.path}' \\`,
@ -223,9 +223,9 @@ export function ApiExplorerPage() {
const agentCurl = useMemo(() => { const agentCurl = useMemo(() => {
if (!agentDoc) return '' if (!agentDoc) return ''
const body = JSON.stringify({ const body = JSON.stringify({
template: agentTemplate || agentDoc.request_body.template.example, template: agentTemplate || agentDoc.request_body.template?.example || '',
to: agentTo || agentDoc.request_body.to.example, to: agentTo || agentDoc.request_body.to?.example || '',
customer_name: agentCustomerName || agentDoc.request_body.customer_name.example, customer_name: agentCustomerName || agentDoc.request_body.customer_name?.example || '',
report_group: agentReportGroup || agentDoc.request_body.report_group?.example || 'acme-agent-runs', report_group: agentReportGroup || agentDoc.request_body.report_group?.example || 'acme-agent-runs',
report_id: agentReportId || agentDoc.request_body.report_id?.example, report_id: agentReportId || agentDoc.request_body.report_id?.example,
}, null, 2) }, null, 2)
@ -240,7 +240,7 @@ export function ApiExplorerPage() {
const urmiportCurl = useMemo(() => { const urmiportCurl = useMemo(() => {
if (!urmiportDoc) return '' if (!urmiportDoc) return ''
const body = JSON.stringify({ 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, report_id: urmiportReportId || urmiportDoc.request_body.report_id?.example,
body: urmiportBody, body: urmiportBody,
access: urmiportAccess, access: urmiportAccess,
@ -257,8 +257,8 @@ export function ApiExplorerPage() {
const voiceCurl = useMemo(() => { const voiceCurl = useMemo(() => {
if (!voiceDoc) return '' if (!voiceDoc) return ''
const body = JSON.stringify({ const body = JSON.stringify({
to: callTo || voiceDoc.request_body.to.example, to: callTo || voiceDoc.request_body.to?.example || '',
from: fromDid || voiceDoc.request_body.from.example, from: fromDid || voiceDoc.request_body.from?.example || '',
bridge_mode: 'upstream', bridge_mode: 'upstream',
}, null, 2) }, null, 2)
return [ return [
@ -356,7 +356,7 @@ export function ApiExplorerPage() {
if (loading) { if (loading) {
return ( return (
<div className="api-explorer-page"> <div className="api-explorer-page">
<p className="explorer-loading">Loading API explorer…</p> <p className="explorer-loading">Loading examples…</p>
</div> </div>
) )
} }
@ -364,10 +364,10 @@ export function ApiExplorerPage() {
return ( return (
<div className="api-explorer-page"> <div className="api-explorer-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Develop</span> <span className="eyebrow">Connections</span>
<h1>API Explorer</h1> <h1>Try a request</h1>
</div> </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> </div>
) )
} }
@ -375,15 +375,15 @@ export function ApiExplorerPage() {
return ( return (
<div className="api-explorer-page"> <div className="api-explorer-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Develop</span> <span className="eyebrow">Connections</span>
<h1>API Explorer</h1> <h1>Try a request</h1>
<p>Documentation and live examples for Urmi APIs. Urmi authenticates and relays traffic — we do not process payloads on our side.</p> <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> </div>
{loadError && <p className="relay-success integration-feedback">{loadError}</p>} {loadError && <p className="relay-success integration-feedback">{loadError}</p>}
{smsDoc && ( {smsDoc && (
<ExplorerGroup title="Messaging"> <ExplorerGroup title="Text messages">
<section className="panel explorer-endpoint-card"> <section className="panel explorer-endpoint-card">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
@ -393,8 +393,8 @@ export function ApiExplorerPage() {
<span className="explorer-method">{smsDoc.method}</span> <span className="explorer-method">{smsDoc.method}</span>
</div> </div>
<FieldRow label="Endpoint" value={`${API_BASE_URL}${smsDoc.path}`} /> <FieldRow label="Address" value={`${API_BASE_URL}${smsDoc.path}`} />
<FieldRow label="Auth" value={`${smsDoc.authentication.header}: ${smsDoc.authentication.format}`} /> <FieldRow label="Access" value={`${smsDoc.authentication.header}: ${smsDoc.authentication.format}`} />
<div className="explorer-section"> <div className="explorer-section">
<h4>Request body</h4> <h4>Request body</h4>
@ -424,10 +424,10 @@ export function ApiExplorerPage() {
</div> </div>
<div className="explorer-section"> <div className="explorer-section">
<h4>API key</h4> <h4>Access key</h4>
<label className="explorer-input"> <label className="explorer-input">
<strong>Bearer token</strong> <strong>Access key</strong>
<span>Paste a key from API Keys. It is sent only for this test request.</span> <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_..." /> <input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
</label> </label>
</div> </div>
@ -437,7 +437,7 @@ export function ApiExplorerPage() {
<Play size={15} /> {smsRunning ? 'Sending…' : 'Try request'} <Play size={15} /> {smsRunning ? 'Sending…' : 'Try request'}
</button> </button>
<button className="secondary-button" onClick={() => copyCurl('sms')}> <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> </button>
</div> </div>
@ -450,7 +450,7 @@ export function ApiExplorerPage() {
)} )}
<div className="explorer-section"> <div className="explorer-section">
<h4>Example cURL</h4> <h4>Example request</h4>
<pre className="explorer-code">{smsCurl}</pre> <pre className="explorer-code">{smsCurl}</pre>
</div> </div>
</section> </section>
@ -458,7 +458,7 @@ export function ApiExplorerPage() {
)} )}
{agentDoc && ( {agentDoc && (
<ExplorerGroup title="Agent messages"> <ExplorerGroup title="WhatsApp messages">
<section className="panel explorer-endpoint-card"> <section className="panel explorer-endpoint-card">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
@ -468,8 +468,8 @@ export function ApiExplorerPage() {
<span className="explorer-method">{agentDoc.method}</span> <span className="explorer-method">{agentDoc.method}</span>
</div> </div>
<FieldRow label="Endpoint" value={`${API_BASE_URL}${agentDoc.path}`} /> <FieldRow label="Address" value={`${API_BASE_URL}${agentDoc.path}`} />
<FieldRow label="Auth" value={`${agentDoc.authentication.header}: ${agentDoc.authentication.format}`} /> <FieldRow label="Access" value={`${agentDoc.authentication.header}: ${agentDoc.authentication.format}`} />
<div className="explorer-section"> <div className="explorer-section">
<h4>Request body</h4> <h4>Request body</h4>
@ -503,10 +503,10 @@ export function ApiExplorerPage() {
</div> </div>
<div className="explorer-section"> <div className="explorer-section">
<h4>API key</h4> <h4>Access key</h4>
<label className="explorer-input"> <label className="explorer-input">
<strong>Bearer token</strong> <strong>Access key</strong>
<span>Subscribe to the template in Agent → UrmiAI before testing.</span> <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_..." /> <input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
</label> </label>
</div> </div>
@ -516,7 +516,7 @@ export function ApiExplorerPage() {
<Play size={15} /> {agentRunning ? 'Sending…' : 'Try request'} <Play size={15} /> {agentRunning ? 'Sending…' : 'Try request'}
</button> </button>
<button className="secondary-button" onClick={() => copyCurl('agent')}> <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> </button>
</div> </div>
@ -529,7 +529,7 @@ export function ApiExplorerPage() {
)} )}
<div className="explorer-section"> <div className="explorer-section">
<h4>Example cURL</h4> <h4>Example request</h4>
<pre className="explorer-code">{agentCurl}</pre> <pre className="explorer-code">{agentCurl}</pre>
</div> </div>
</section> </section>
@ -537,7 +537,7 @@ export function ApiExplorerPage() {
)} )}
{urmiportDoc && ( {urmiportDoc && (
<ExplorerGroup title="Urmiport"> <ExplorerGroup title="Shared reports">
<section className="panel explorer-endpoint-card"> <section className="panel explorer-endpoint-card">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
@ -547,8 +547,8 @@ export function ApiExplorerPage() {
<span className="explorer-method">{urmiportDoc.method}</span> <span className="explorer-method">{urmiportDoc.method}</span>
</div> </div>
<FieldRow label="Endpoint" value={`${API_BASE_URL}${urmiportDoc.path}`} /> <FieldRow label="Address" value={`${API_BASE_URL}${urmiportDoc.path}`} />
<FieldRow label="Auth" value={`${urmiportDoc.authentication.header}: ${urmiportDoc.authentication.format}`} /> <FieldRow label="Access" value={`${urmiportDoc.authentication.header}: ${urmiportDoc.authentication.format}`} />
<div className="explorer-section"> <div className="explorer-section">
<h4>Request body</h4> <h4>Request body</h4>
@ -556,15 +556,15 @@ export function ApiExplorerPage() {
<div className="explorer-input"> <div className="explorer-input">
<label> <label>
<strong>report_group</strong> <strong>report_group</strong>
<span>{urmiportDoc.request_body.report_group.description}</span> <span>{urmiportDoc.request_body.report_group?.description}</span>
<input value={urmiportGroup} onChange={(e) => setUrmiportGroup(e.target.value)} placeholder={urmiportDoc.request_body.report_group.example} /> <input value={urmiportGroup} onChange={(e) => setUrmiportGroup(e.target.value)} placeholder={urmiportDoc.request_body.report_group?.example} />
</label> </label>
</div> </div>
<div className="explorer-input"> <div className="explorer-input">
<label> <label>
<strong>report_id</strong> <strong>report_id</strong>
<span>{urmiportDoc.request_body.report_id.description}</span> <span>{urmiportDoc.request_body.report_id?.description}</span>
<input value={urmiportReportId} onChange={(e) => setUrmiportReportId(e.target.value)} placeholder={urmiportDoc.request_body.report_id.example} /> <input value={urmiportReportId} onChange={(e) => setUrmiportReportId(e.target.value)} placeholder={urmiportDoc.request_body.report_id?.example} />
</label> </label>
</div> </div>
<div className="explorer-input explorer-input-wide"> <div className="explorer-input explorer-input-wide">
@ -588,8 +588,8 @@ export function ApiExplorerPage() {
<div className="explorer-input"> <div className="explorer-input">
<label> <label>
<strong>password</strong> <strong>password</strong>
<span>{urmiportDoc.request_body.password.description}</span> <span>{urmiportDoc.request_body.password?.description}</span>
<input type="password" value={urmiportPassword} onChange={(e) => setUrmiportPassword(e.target.value)} placeholder={urmiportDoc.request_body.password.example} /> <input type="password" value={urmiportPassword} onChange={(e) => setUrmiportPassword(e.target.value)} placeholder={urmiportDoc.request_body.password?.example} />
</label> </label>
</div> </div>
)} )}
@ -597,10 +597,10 @@ export function ApiExplorerPage() {
</div> </div>
<div className="explorer-section"> <div className="explorer-section">
<h4>API key</h4> <h4>Access key</h4>
<label className="explorer-input"> <label className="explorer-input">
<strong>Bearer token</strong> <strong>Access key</strong>
<span>Create the report group in Agent → Urmiport before publishing.</span> <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_..." /> <input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
</label> </label>
</div> </div>
@ -610,7 +610,7 @@ export function ApiExplorerPage() {
<Play size={15} /> {urmiportRunning ? 'Publishing…' : 'Try request'} <Play size={15} /> {urmiportRunning ? 'Publishing…' : 'Try request'}
</button> </button>
<button className="secondary-button" onClick={() => copyCurl('urmiport')}> <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> </button>
</div> </div>
@ -623,7 +623,7 @@ export function ApiExplorerPage() {
)} )}
<div className="explorer-section"> <div className="explorer-section">
<h4>Example cURL</h4> <h4>Example request</h4>
<pre className="explorer-code">{urmiportCurl}</pre> <pre className="explorer-code">{urmiportCurl}</pre>
</div> </div>
</section> </section>
@ -631,7 +631,7 @@ export function ApiExplorerPage() {
)} )}
{voiceDoc && integrationDocs && ( {voiceDoc && integrationDocs && (
<ExplorerGroup title="Numbers"> <ExplorerGroup title="Phone calls">
<RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} /> <RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} />
<section className="panel explorer-endpoint-card"> <section className="panel explorer-endpoint-card">
@ -643,8 +643,8 @@ export function ApiExplorerPage() {
<span className="explorer-method">{voiceDoc.method}</span> <span className="explorer-method">{voiceDoc.method}</span>
</div> </div>
<FieldRow label="Endpoint" value={`${API_BASE_URL}${voiceDoc.path}`} /> <FieldRow label="Address" value={`${API_BASE_URL}${voiceDoc.path}`} />
<FieldRow label="Auth" value={`${voiceDoc.authentication.header}: ${voiceDoc.authentication.format}`} /> <FieldRow label="Access" value={`${voiceDoc.authentication.header}: ${voiceDoc.authentication.format}`} />
<div className="explorer-section"> <div className="explorer-section">
<h4>How outbound calls work</h4> <h4>How outbound calls work</h4>
@ -659,7 +659,7 @@ export function ApiExplorerPage() {
<div className="explorer-input"> <div className="explorer-input">
<label> <label>
<strong>from</strong> <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)}> <select value={fromDid} onChange={(e) => setFromDid(e.target.value)}>
{!numbers.length && <option value="">No numbers assigned</option>} {!numbers.length && <option value="">No numbers assigned</option>}
{numbers.map((n) => ( {numbers.map((n) => (
@ -671,23 +671,23 @@ export function ApiExplorerPage() {
<div className="explorer-input"> <div className="explorer-input">
<label> <label>
<strong>to</strong> <strong>to</strong>
<span>{voiceDoc.request_body.to.description}</span> <span>{voiceDoc.request_body.to?.description}</span>
<input value={callTo} onChange={(e) => setCallTo(e.target.value)} placeholder={voiceDoc.request_body.to.example} /> <input value={callTo} onChange={(e) => setCallTo(e.target.value)} placeholder={voiceDoc.request_body.to?.example} />
</label> </label>
</div> </div>
</div> </div>
<p className="relay-note"> <p className="relay-note">
Link your number to a voice socket first under{' '} Connect this number for live call audio first under{' '}
<Link to="/console/webhooks">Webhooks & Sockets</Link>. Urmi relays live audio frames to that endpoint with no processing. <Link to="/console/webhooks">Delivery updates</Link>. Urmi passes the call audio through as it happens.
</p> </p>
</div> </div>
<div className="explorer-actions"> <div className="explorer-actions">
<button className="primary-button" onClick={runVoiceCall} disabled={voiceRunning || !apiKey.trim() || !fromDid}> <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>
<button className="secondary-button" onClick={() => copyCurl('voice')}> <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> </button>
</div> </div>
@ -700,12 +700,12 @@ export function ApiExplorerPage() {
)} )}
<div className="explorer-section"> <div className="explorer-section">
<h4>Example cURL</h4> <h4>Example request</h4>
<pre className="explorer-code">{voiceCurl}</pre> <pre className="explorer-code">{voiceCurl}</pre>
</div> </div>
<div className="explorer-section"> <div className="explorer-section">
<h4>Related endpoints</h4> <h4>Related actions</h4>
<ul className="relay-endpoints"> <ul className="relay-endpoints">
{voiceDoc.endpoints.map((endpoint) => ( {voiceDoc.endpoints.map((endpoint) => (
<li key={endpoint.path}> <li key={endpoint.path}>
@ -726,9 +726,9 @@ export function ApiExplorerPage() {
<Radio size={18} /> <Radio size={18} />
</div> </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"> <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>{integrationDocs.socket.authentication.signature} {integrationDocs.socket.authentication.replay_protection}</p>
<p>Headers: {integrationDocs.socket.authentication.headers.join(', ')}</p> <p>Headers: {integrationDocs.socket.authentication.headers.join(', ')}</p>
</div> </div>
@ -740,7 +740,7 @@ export function ApiExplorerPage() {
</li> </li>
))} ))}
</ul> </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>
<section className="panel explorer-endpoint-card"> <section className="panel explorer-endpoint-card">
@ -756,7 +756,7 @@ export function ApiExplorerPage() {
<FieldRow label="Statuses" value={integrationDocs.webhook.statuses.join(', ')} /> <FieldRow label="Statuses" value={integrationDocs.webhook.statuses.join(', ')} />
<div className="explorer-section"> <div className="explorer-section">
<h4>Example payload</h4> <h4>Example update</h4>
<pre className="explorer-code">{JSON.stringify(integrationDocs.webhook.example, null, 2)}</pre> <pre className="explorer-code">{JSON.stringify(integrationDocs.webhook.example, null, 2)}</pre>
<p className="relay-note">{integrationDocs.webhook.authentication.signature}</p> <p className="relay-note">{integrationDocs.webhook.authentication.signature}</p>
</div> </div>
@ -768,7 +768,7 @@ export function ApiExplorerPage() {
</li> </li>
))} ))}
</ul> </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> </section>
</ExplorerGroup> </ExplorerGroup>
)} )}
@ -776,8 +776,8 @@ export function ApiExplorerPage() {
{integrationDocs && ( {integrationDocs && (
<section className="panel explorer-endpoint-card"> <section className="panel explorer-endpoint-card">
<div className="explorer-section"> <div className="explorer-section">
<h4>Authentication</h4> <h4>Access</h4>
<FieldRow label="Header" value={`${integrationDocs.api_authentication.header}: ${integrationDocs.api_authentication.format}`} /> <FieldRow label="Sign-in" value={`${integrationDocs.api_authentication.header}: ${integrationDocs.api_authentication.format}`} />
<ul className="explorer-notes"> <ul className="explorer-notes">
<li><ShieldCheck size={14} /> {integrationDocs.api_authentication.note}</li> <li><ShieldCheck size={14} /> {integrationDocs.api_authentication.note}</li>
<li><ShieldCheck size={14} /> {integrationDocs.relay.latency}</li> <li><ShieldCheck size={14} /> {integrationDocs.relay.latency}</li>

View file

@ -32,7 +32,7 @@ function TokenRevealModal({
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button> <button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
</div> </div>
<div className="api-key-reveal-body"> <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"> <div className="api-key-token-box">
<code>{apiKey.token}</code> <code>{apiKey.token}</code>
<button type="button" className="secondary-button" onClick={copyToken}> <button type="button" className="secondary-button" onClick={copyToken}>
@ -63,7 +63,7 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
onCreated(created) onCreated(created)
onClose() onClose()
} catch (reason) { } 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 { } finally {
setSaving(false) setSaving(false)
} }
@ -73,12 +73,12 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
<div className="modal-overlay" onClick={onClose}> <div className="modal-overlay" onClick={onClose}>
<form className="modal-card api-key-modal" onClick={(event) => event.stopPropagation()} onSubmit={submit}> <form className="modal-card api-key-modal" onClick={(event) => event.stopPropagation()} onSubmit={submit}>
<div className="modal-header"> <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> <button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
</div> </div>
<label className="modal-field modal-field-stack"> <label className="modal-field modal-field-stack">
<span>Key name</span> <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> </label>
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
<div className="modal-actions"> <div className="modal-actions">
@ -99,14 +99,14 @@ export function ApiKeysPage() {
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [revealedKey, setRevealedKey] = useState<ApiKeyWithToken | null>(null) 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() { async function refresh() {
setKeys(await apiKeysApi.list()) setKeys(await apiKeysApi.list())
} }
useEffect(() => { 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) { async function regenerate(keyId: string) {
@ -116,10 +116,10 @@ export function ApiKeysPage() {
const regenerated = await apiKeysApi.regenerate(keyId) const regenerated = await apiKeysApi.regenerate(keyId)
const { token: _token, ...keyMeta } = regenerated const { token: _token, ...keyMeta } = regenerated
setKeys((current) => current.map((item) => (item.id === regenerated.id ? keyMeta : item))) setKeys((current) => current.map((item) => (item.id === regenerated.id ? keyMeta : item)))
setRevealTitle('Regenerated API key') setRevealTitle('Your new access key')
setRevealedKey(regenerated) setRevealedKey(regenerated)
} catch (reason) { } 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 { try {
await apiKeysApi.revoke(keyId) await apiKeysApi.revoke(keyId)
setKeys((current) => current.filter((item) => item.id !== keyId)) setKeys((current) => current.filter((item) => item.id !== keyId))
setMessage('API key revoked.') setMessage('Access key turned off.')
} catch (reason) { } 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) { function handleCreated(created: ApiKeyWithToken) {
const { token: _token, ...keyMeta } = created const { token: _token, ...keyMeta } = created
setKeys((current) => [keyMeta, ...current]) setKeys((current) => [keyMeta, ...current])
setRevealTitle('Your new API key') setRevealTitle('Your new access key')
setRevealedKey(created) 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 ( return (
<div className="api-keys-page"> <div className="api-keys-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Develop</span> <span className="eyebrow">Connections</span>
<h1>API Keys</h1> <h1>Access keys</h1>
<p>Create named keys for server-side integrations. Each secret is shown only once.</p> <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> </div>
<section className="panel"> <section className="panel">
<div className="panel-heading"> <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> <button className="primary-button" onClick={() => setCreateOpen(true)}><Plus size={15} /> Create key</button>
</div> </div>
{keys.length === 0 ? ( {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) => ( keys.map((item) => (
<div className="api-key-row" key={item.id}> <div className="api-key-row" key={item.id}>

View file

@ -107,14 +107,14 @@ export function WebhooksSocketsPage() {
? await integrationsApi.createSocket(payload) ? await integrationsApi.createSocket(payload)
: await integrationsApi.createWebhook(payload) : await integrationsApi.createWebhook(payload)
setRevealedSecret(created.signing_secret ?? '') setRevealedSecret(created.signing_secret ?? '')
setNotice(`${tab === 'sockets' ? 'Voice socket' : 'Delivery webhook'} created.`) setNotice(`${tab === 'sockets' ? 'Call connection saved.' : 'Delivery update saved.'}`)
await load() await load()
setName('') setName('')
setUrl('') setUrl('')
setDescription('') setDescription('')
setNumberId('') setNumberId('')
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to create endpoint') setError(reason instanceof Error ? reason.message : 'Could not save this connection.')
} finally { } finally {
setSaving(false) setSaving(false)
} }
@ -131,7 +131,7 @@ export function WebhooksSocketsPage() {
} }
async function remove(kind: Tab, id: string) { 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('') setError('')
try { try {
if (kind === 'sockets') { if (kind === 'sockets') {
@ -143,7 +143,7 @@ export function WebhooksSocketsPage() {
} }
setNotice('Endpoint deleted.') setNotice('Endpoint deleted.')
} catch (reason) { } 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) 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 emptyNumbers = numbers.length === 0
const noSocketSlots = tab === 'sockets' && selectableNumbers.length === 0 const noSocketSlots = tab === 'sockets' && selectableNumbers.length === 0
@ -172,17 +172,17 @@ export function WebhooksSocketsPage() {
return ( return (
<div className="webhooks-sockets-page"> <div className="webhooks-sockets-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Develop</span> <span className="eyebrow">Connections</span>
<h1>Webhooks & Sockets</h1> <h1>Delivery updates</h1>
<p>Connect each phone number to your application. API docs and examples live in <Link to="/console/api-explorer">API Explorer</Link>.</p> <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>
<div className="integration-tabs" role="tablist"> <div className="integration-tabs" role="tablist">
<button className={tab === 'sockets' ? 'is-active' : ''} onClick={() => switchTab('sockets')}> <button className={tab === 'sockets' ? 'is-active' : ''} onClick={() => switchTab('sockets')}>
<Radio size={16} /> Voice sockets <Radio size={16} /> Live call audio
</button> </button>
<button className={tab === 'webhooks' ? 'is-active' : ''} onClick={() => switchTab('webhooks')}> <button className={tab === 'webhooks' ? 'is-active' : ''} onClick={() => switchTab('webhooks')}>
<MessageSquareText size={16} /> SMS delivery webhooks <MessageSquareText size={16} /> Text delivery updates
</button> </button>
</div> </div>
@ -205,11 +205,11 @@ export function WebhooksSocketsPage() {
<section className="panel endpoint-list-panel"> <section className="panel endpoint-list-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>{tab === 'sockets' ? 'Voice sockets' : 'SMS delivery webhooks'}</h2> <h2>{tab === 'sockets' ? 'Live call audio' : 'Text delivery updates'}</h2>
<p> <p>
{tab === 'sockets' {tab === 'sockets'
? 'One secure WebSocket per number. Urmi relays live audio with no processing.' ? 'One secure connection per number. Call audio is passed through as it happens.'
: 'Named HTTPS endpoints for SMS delivery status updates.'} : 'A secure address that receives updates when a text is delivered.'}
</p> </p>
</div> </div>
<span className="endpoint-count">{tab === 'sockets' ? sockets.length : webhooks.length}</span> <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)}> <button className="ghost-button" onClick={() => void toggleSocket(endpoint)}>
{endpoint.enabled ? 'Pause' : 'Enable'} {endpoint.enabled ? 'Pause' : 'Enable'}
</button> </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} /> <Trash2 size={15} />
</button> </button>
</div> </div>
@ -239,7 +239,7 @@ export function WebhooksSocketsPage() {
<div className="endpoint-icon"><MessageSquareText size={16} /></div> <div className="endpoint-icon"><MessageSquareText size={16} /></div>
<div className="endpoint-main"> <div className="endpoint-main">
<div><strong>{endpoint.name}</strong><EndpointStatus enabled={endpoint.enabled} /></div> <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> <code>{endpoint.url}</code>
{endpoint.description && <small>{endpoint.description}</small>} {endpoint.description && <small>{endpoint.description}</small>}
</div> </div>
@ -250,7 +250,7 @@ export function WebhooksSocketsPage() {
<button className="ghost-button" onClick={() => void toggleWebhook(endpoint)}> <button className="ghost-button" onClick={() => void toggleWebhook(endpoint)}>
{endpoint.enabled ? 'Pause' : 'Enable'} {endpoint.enabled ? 'Pause' : 'Enable'}
</button> </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} /> <Trash2 size={15} />
</button> </button>
</div> </div>
@ -259,8 +259,8 @@ export function WebhooksSocketsPage() {
{((tab === 'sockets' && sockets.length === 0) || (tab === 'webhooks' && webhooks.length === 0)) && ( {((tab === 'sockets' && sockets.length === 0) || (tab === 'webhooks' && webhooks.length === 0)) && (
<div className="endpoint-empty"> <div className="endpoint-empty">
<strong>No {tab === 'sockets' ? 'voice sockets' : 'delivery webhooks'} yet</strong> <strong>No {tab === 'sockets' ? 'call connections' : 'delivery updates'} yet</strong>
<span>Create one to connect a number to your application.</span> <span>Add one so this number can reach your systems.</span>
</div> </div>
)} )}
</section> </section>
@ -268,7 +268,7 @@ export function WebhooksSocketsPage() {
<form className="panel endpoint-create-panel" onSubmit={createEndpoint}> <form className="panel endpoint-create-panel" onSubmit={createEndpoint}>
<div className="panel-heading"> <div className="panel-heading">
<div> <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> <p>Name it clearly and link it to one of your numbers.</p>
</div> </div>
<Plus size={18} /> <Plus size={18} />
@ -277,36 +277,36 @@ export function WebhooksSocketsPage() {
<label className="explorer-input"> <label className="explorer-input">
<strong>Name</strong> <strong>Name</strong>
<span>Used only in your Urmi workspace.</span> <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>
<label className="explorer-input"> <label className="explorer-input">
<strong>Phone number</strong> <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> <select value={numberId} onChange={(event) => setNumberId(event.target.value)} required>
<option value="">Select a number</option> <option value="">Select a number</option>
{selectableNumbers.map((number) => <option key={number.id} value={number.id}>{formatPhone(number.phone_number)}</option>)} {selectableNumbers.map((number) => <option key={number.id} value={number.id}>{formatPhone(number.phone_number)}</option>)}
</select> </select>
</label> </label>
<label className="explorer-input"> <label className="explorer-input">
<strong>{tab === 'sockets' ? 'WebSocket URL' : 'Webhook URL'}</strong> <strong>{tab === 'sockets' ? 'Call address' : 'Update address'}</strong>
<span>{tab === 'sockets' ? 'Use a public wss:// endpoint.' : 'Use a public https:// endpoint.'}</span> <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://voice.example.com/calls' : 'https://api.example.com/urmi/delivery'} required /> <input value={url} onChange={(event) => setUrl(event.target.value)} placeholder={tab === 'sockets' ? 'wss://calls.yourcompany.com/live' : 'https://yourcompany.com/urmi/delivery'} required />
</label> </label>
{tab === 'webhooks' && ( {tab === 'webhooks' && (
<label className="explorer-input"> <label className="explorer-input">
<strong>Description <small>Optional</small></strong> <strong>Description <small>Optional</small></strong>
<span>Help teammates understand where this endpoint is used.</span> <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 our production message status service" /> <textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} placeholder="Updates order status when a text is delivered" />
</label> </label>
)} )}
{(emptyNumbers || noSocketSlots) && ( {(emptyNumbers || noSocketSlots) && (
<p className="endpoint-form-note"> <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> </p>
)} )}
<button className="primary-button" type="submit" disabled={saving || emptyNumbers || noSocketSlots}> <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> </button>
</form> </form>
</div> </div>

View file

@ -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' import { Link } from 'react-router-dom'
export function LandingPage() { export function LandingPage() {
@ -8,62 +8,74 @@ export function LandingPage() {
<Link to="/" className="landing-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link> <Link to="/" className="landing-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link>
<nav> <nav>
<a href="#product">Product</a> <a href="#product">Product</a>
<a href="#developers">Developers</a> <a href="#how">How it works</a>
<a href="#security">Security</a> <a href="#security">Trust</a>
</nav> </nav>
<div className="landing-actions"> <div className="landing-actions">
<Link to="/login">Log in</Link> <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> </div>
</header> </header>
<main> <main>
<section className="hero-section"> <section className="hero-section">
<div className="hero-copy"> <div className="hero-copy">
<span className="hero-pill"><i /> SMS infrastructure for AI agents</span> <span className="hero-pill"><i /> Customer messaging for Indian businesses</span>
<h1>Give every AI agent a reliable way to <em>reach humans.</em></h1> <h1>Reach customers by text, WhatsApp, and phone.</h1>
<p>One API for transactional SMS, OTPs, alerts, and agent-driven conversations—built for delivery, compliance, and scale.</p> <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"> <div className="hero-actions">
<Link to="/register" className="hero-primary">Start building free <ArrowRight size={17} /></Link> <Link to="/register" className="hero-primary">Create your account <ArrowRight size={17} /></Link>
<a href="#developers" className="hero-secondary">View API <Braces size={17} /></a> <a href="#product" className="hero-secondary">See what you can send</a>
</div> </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>
<div className="agent-demo"> <div className="agent-demo">
<div className="demo-window"> <div className="demo-window">
<div className="demo-top"><span><i /><i /><i /></span><small>agent.ts</small><b>Connected</b></div> <div className="demo-top"><span className="thread-brand">Northwind</span><b>Delivered</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="thread-list">
<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> <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>
<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> </div>
</section> </section>
<section className="trust-strip"> <section className="trust-strip">
<span>Designed for autonomous communication</span> <span>Used by teams that talk to customers every day</span>
<div><b>AI support</b><b>Fintech</b><b>Commerce</b><b>Logistics</b><b>Healthcare</b></div> <div><b>Retail</b><b>Finance</b><b>Clinics</b><b>Logistics</b><b>Education</b></div>
</section> </section>
<section className="feature-section" id="product"> <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"> <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><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><Zap /></span><h3>Fast, observable delivery</h3><p>Track every message from API request to carrier delivery with one event timeline.</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>Compliance built in</h3><p>KYC, DLT templates, sender validation, and consent checks before messages leave.</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> </div>
</section> </section>
<section className="developer-band" id="developers"> <section className="closing-band" id="how">
<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> <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">Get your API key <ArrowRight size={16} /></Link> <Link to="/register">Create your account <ArrowRight size={16} /></Link>
</section> </section>
</main> </main>
<footer className="landing-footer"> <footer className="landing-footer">
<img src="/urmi-logo.png" alt="Urmi" /> <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> <Link to="/privacy" className="legal-footer-link">Privacy Policy</Link>
<small>© 2026 Urmi Technologies</small> <small>© 2026 Urmi Technologies</small>
</footer> </footer>

View file

@ -10,7 +10,7 @@ export function PrivacyPolicyPage() {
<Link to="/" className="landing-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link> <Link to="/" className="landing-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link>
<div className="landing-actions"> <div className="landing-actions">
<Link to="/login">Log in</Link> <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> </div>
</header> </header>

View file

@ -3,10 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { logsApi, type LogType, type LogsResponse } from '../../lib/api' import { logsApi, type LogType, type LogsResponse } from '../../lib/api'
const LOG_TYPES: Array<{ value: LogType; label: string }> = [ const LOG_TYPES: Array<{ value: LogType; label: string }> = [
{ value: 'outbound', label: 'Outbound' }, { value: 'outbound', label: 'Calls placed' },
{ value: 'inbound', label: 'Inbound' }, { value: 'inbound', label: 'Calls received' },
{ value: 'sms', label: 'SMS' }, { value: 'sms', label: 'Text messages' },
{ value: 'otp', label: 'OTP' }, { value: 'otp', label: 'Verification codes' },
] ]
const PAGE_SIZE = 10 const PAGE_SIZE = 10
@ -134,11 +134,11 @@ export function LogsPage() {
case 'sms': case 'sms':
return ['From', 'To', 'Template', 'Status', 'Time'] return ['From', 'To', 'Template', 'Status', 'Time']
case 'otp': case 'otp':
return ['To', 'OTP type', 'Project', 'Status', 'Time'] return ['To', 'Code type', 'Project', 'Status', 'Time']
case 'inbound': case 'inbound':
return ['From', 'To', 'Status', 'Time'] return ['From', 'To', 'Status', 'Time']
default: default:
return ['From', 'To', 'Socket', 'Status', 'Time'] return ['From', 'To', 'Connection', 'Status', 'Time']
} }
}, [logType]) }, [logType])
@ -148,15 +148,15 @@ export function LogsPage() {
return ( return (
<div className="logs-page"> <div className="logs-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Test & observe</span> <span className="eyebrow">Insights</span>
<h1>Logs</h1> <h1>Activity history</h1>
<p>Browse outbound and inbound calls, SMS sends, and OTP deliveries. Default view covers the last week, up to one month.</p> <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> </div>
<section className="panel logs-toolbar"> <section className="panel logs-toolbar">
<div className="logs-toolbar-row"> <div className="logs-toolbar-row">
<label className="logs-field"> <label className="logs-field">
<span>Log type</span> <span>Activity</span>
<select value={logType} onChange={(event) => changeType(event.target.value as LogType)}> <select value={logType} onChange={(event) => changeType(event.target.value as LogType)}>
{LOG_TYPES.map((item) => ( {LOG_TYPES.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option> <option key={item.value} value={item.value}>{item.label}</option>

View file

@ -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 ( return (
<div className="compose-message-page"> <div className="compose-message-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Communicate</span> <span className="eyebrow">Messages</span>
<h1>Compose New Message</h1> <h1>Send a message</h1>
<p>Send SMS using approved sender IDs and DLT templates.</p> <p>Choose an approved sender and template, then add the people who should receive it.</p>
</div> </div>
<section className="panel compose-message-card"> <section className="panel compose-message-card">
<div className="compose-card-header"> <div className="compose-card-header">
<div> <div>
<MessageSquareText size={18} /> <MessageSquareText size={18} />
<h2>Compose New Message</h2> <h2>New text message</h2>
</div> </div>
</div> </div>
<div className="compose-grid"> <div className="compose-grid">
<label className="compose-field"> <label className="compose-field">
<span>Message Channel</span> <span>Message type</span>
<div className="select-wrap"> <div className="select-wrap">
<select value={channel} onChange={(event) => handleChannelChange(event.target.value)}> <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="transactional">Transactional</option>
<option value="promotional">Promotional</option> <option value="promotional">Promotional</option>
</select> </select>
@ -220,7 +220,7 @@ export function ComposeMessage() {
</label> </label>
<label className="compose-field"> <label className="compose-field">
<span>SenderID</span> <span>Sender ID</span>
<div className="select-wrap"> <div className="select-wrap">
<select <select
value={senderConfigId} value={senderConfigId}
@ -232,7 +232,7 @@ export function ComposeMessage() {
disabled={!channel || approvedSenders.length === 0} disabled={!channel || approvedSenders.length === 0}
> >
<option value=""> <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> </option>
{approvedSenders.map((sender) => ( {approvedSenders.map((sender) => (
<option key={sender.id} value={sender.id}>{sender.sender_id}</option> <option key={sender.id} value={sender.id}>{sender.sender_id}</option>
@ -243,24 +243,24 @@ export function ComposeMessage() {
</label> </label>
<label className="compose-field compose-field-wide"> <label className="compose-field compose-field-wide">
<span>Campaign Name</span> <span>Campaign name</span>
<input value={campaignName} onChange={(event) => setCampaignName(event.target.value)} placeholder="Enter campaign name" /> <input value={campaignName} onChange={(event) => setCampaignName(event.target.value)} placeholder="For example, April order updates" />
</label> </label>
<label className="compose-field compose-field-wide"> <label className="compose-field compose-field-wide">
<span>Numbers ({recipientCount})</span> <span>Recipients ({recipientCount})</span>
<textarea <textarea
value={numbers} value={numbers}
onChange={(event) => setNumbers(event.target.value)} onChange={(event) => setNumbers(event.target.value)}
rows={5} rows={5}
placeholder="Enter recipient numbers in comma separated, e.g. 919999999999,918888888888,911234567890" placeholder="Separate numbers with commas, for example 919999999999, 918888888888"
/> />
</label> </label>
<div className="compose-message-section compose-field-wide"> <div className="compose-message-section compose-field-wide">
<div className="compose-message-toolbar"> <div className="compose-message-toolbar">
<label className="compose-field compose-template-field"> <label className="compose-field compose-template-field">
<span>Select Template</span> <span>Template</span>
<div className="select-wrap"> <div className="select-wrap">
<select <select
value={templateId} value={templateId}
@ -307,8 +307,8 @@ export function ComposeMessage() {
)} )}
<div className="compose-message-meta"> <div className="compose-message-meta">
<span>{characterCount} Characters Used</span> <span>{characterCount} characters</span>
<span>Count {messageCount}</span> <span>{messageCount} SMS {messageCount === 1 ? 'part' : 'parts'}</span>
</div> </div>
</div> </div>
</div> </div>
@ -344,13 +344,13 @@ export function ComposeMessage() {
<div className="compose-footer-actions"> <div className="compose-footer-actions">
<label className="compose-draft-toggle"> <label className="compose-draft-toggle">
<input type="checkbox" checked={saveDraft} onChange={(event) => setSaveDraft(event.target.checked)} /> <input type="checkbox" checked={saveDraft} onChange={(event) => setSaveDraft(event.target.checked)} />
Save As Draft Save as draft
</label> </label>
<button type="button" className="primary-button" onClick={handleSendNow} disabled={sending}> <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>
<button type="button" className="primary-button"> <button type="button" className="primary-button">
<CalendarClock size={15} /> Schedule For Later <CalendarClock size={15} /> Schedule for later
</button> </button>
</div> </div>
</div> </div>

View file

@ -286,26 +286,26 @@ export function MessageSettings() {
return ( return (
<div className="message-settings-page"> <div className="message-settings-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Compliance</span> <span className="eyebrow">Get ready</span>
<h1>Message settings</h1> <h1>Senders and templates</h1>
<p>Configure sender IDs and DLT templates for outbound SMS.</p> <p>Add the sender IDs and message templates you want approved before you text customers.</p>
</div> </div>
<AccordionSection <AccordionSection
title="Sender configs" title="Sender IDs"
description="Headers, numeric senders, and routing defaults." description="The names and numbers your texts are sent from."
open={senderOpen} open={senderOpen}
onToggle={() => setSenderOpen(!senderOpen)} onToggle={() => setSenderOpen(!senderOpen)}
> >
<div className="accordion-toolbar"> <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> <button className="primary-button" onClick={() => setSenderModalOpen(true)}><Plus size={15} /> Add</button>
</div> </div>
{loading ? ( {loading ? (
<p className="empty-copy">Loading sender configs…</p> <p className="empty-copy">Loading sender IDs…</p>
) : senders.length === 0 ? ( ) : 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) => ( senders.map((item) => (
<div className="compliance-row" key={item.id}> <div className="compliance-row" key={item.id}>
@ -319,20 +319,20 @@ export function MessageSettings() {
</AccordionSection> </AccordionSection>
<AccordionSection <AccordionSection
title="Template configs" title="Message templates"
description="DLT-approved message templates linked to senders." description="Approved wording linked to a sender ID."
open={templateOpen} open={templateOpen}
onToggle={() => setTemplateOpen(!templateOpen)} onToggle={() => setTemplateOpen(!templateOpen)}
> >
<div className="accordion-toolbar"> <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> <button className="primary-button" onClick={() => setTemplateModalOpen(true)}><Plus size={15} /> Add</button>
</div> </div>
{loading ? ( {loading ? (
<p className="empty-copy">Loading template configs…</p> <p className="empty-copy">Loading templates…</p>
) : templates.length === 0 ? ( ) : 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) => ( templates.map((item) => (
<div className="compliance-row" key={item.id}> <div className="compliance-row" key={item.id}>

View file

@ -49,9 +49,9 @@ export function TemplatesPage() {
return ( return (
<div className="templates-page"> <div className="templates-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Communicate</span> <span className="eyebrow">Messages</span>
<h1>Templates</h1> <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> </div>
<section className="panel templates-panel"> <section className="panel templates-panel">
@ -66,8 +66,8 @@ export function TemplatesPage() {
{templates.length === 0 ? ( {templates.length === 0 ? (
<div className="templates-empty"> <div className="templates-empty">
<strong>No templates yet</strong> <strong>No templates yet</strong>
<span>Add sender IDs and templates under Message settings, then return here to send.</span> <span>Add sender IDs and templates first, then return here to send.</span>
<Link className="secondary-button" to="/console/message-settings">Message settings</Link> <Link className="secondary-button" to="/console/message-settings">Senders and templates</Link>
</div> </div>
) : ( ) : (
<div className="templates-table"> <div className="templates-table">

View file

@ -24,10 +24,10 @@ export function MetaAdAccountsPage() {
} }
return ( 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} />} {error && <MetaState error={error} />}
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.accounts.length === 0 ? ( {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"> <div className="meta-table-wrap panel">
<table className="meta-table"> <table className="meta-table">

View file

@ -1,6 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { metaApi, type MetaAd, MetaRequestError } from './api' 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> = { const CTA_LABELS: Record<string, string> = {
LEARN_MORE: 'Learn more', LEARN_MORE: 'Learn more',
@ -12,7 +14,9 @@ const CTA_LABELS: Record<string, string> = {
} }
export function MetaAdsPage() { export function MetaAdsPage() {
const [tab, setTab] = useState<Tab>('ads')
const list = useMetaLoad('ads', () => metaApi.ads()) const list = useMetaLoad('ads', () => metaApi.ads())
const adsets = useMetaLoad(tab === 'adsets' ? 'adsets' : 'adsets-skip', () => metaApi.adsets())
const [selected, setSelected] = useState<string>('') const [selected, setSelected] = useState<string>('')
const [draft, setDraft] = useState<MetaAd | null>(null) const [draft, setDraft] = useState<MetaAd | null>(null)
const [pending, setPending] = useState<MetaAd | null>(null) const [pending, setPending] = useState<MetaAd | null>(null)
@ -57,20 +61,46 @@ export function MetaAdsPage() {
} }
return ( 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} />} {error && <MetaState error={error} />}
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.ads.length === 0 ? ( {tab === 'adsets' ? (
<MetaEmpty title="No ads returned" body="Meta did not return ads for the selected ad account." /> 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-split">
<div className="meta-table-wrap panel"> <div className="meta-table-wrap panel">
<table className="meta-table"> <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> <tbody>
{list.data?.ads.map((item) => ( {list.data?.ads.map((item) => (
<tr key={item.id} className={ad?.id === item.id ? 'is-selected' : ''} onClick={() => setSelected(item.id)}> <tr key={item.id} className={ad?.id === item.id ? 'is-selected' : ''} onClick={() => setSelected(item.id)}>
<td><strong>{item.name}</strong></td> <td><strong>{item.name}</strong></td>
<td>{item.campaign_name || '—'}</td> <td>{item.campaign_name || '—'}</td>
<td>{item.adset_name || '—'}</td>
<td><StatusPill value={item.status} /></td> <td><StatusPill value={item.status} /></td>
</tr> </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>} {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> <strong>{ad.headline || ad.name}</strong>
<p>{ad.message || 'No primary text was returned.'}</p> <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 && ( {list.data?.can_manage && (
<div className="welcome-actions"> <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={() => { 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> <button className="secondary-button" type="button" onClick={() => setPending(ad)}>{ad.status === 'ACTIVE' ? 'Pause' : 'Resume'}</button>
</div> </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> </article>
)} )}
</div> </div>
)} )}
{draft && ( {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">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">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> <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 && ( {pending && (
<ConfirmDialog <ConfirmDialog
title={pending.status === 'ACTIVE' ? `Pause ${pending.name}?` : `Resume ${pending.name}?`} 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'} confirmLabel={pending.status === 'ACTIVE' ? 'Pause ad' : 'Resume ad'}
busy={busy} busy={busy}
onCancel={() => setPending(null)} onCancel={() => setPending(null)}

View file

@ -16,8 +16,8 @@ export function MetaAnalyticsPage() {
const report = useMetaLoad(range, () => metaApi.analytics(range)) const report = useMetaLoad(range, () => metaApi.analytics(range))
return ( return (
<MetaScreen <MetaScreen
title="Analytics" title="Ad results"
subtitle={report.data?.account_name ? `Delivery for ${report.data.account_name}.` : 'Delivery for the selected ad account.'} 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={ actions={
<select className="meta-select" value={range} onChange={(event) => setRange(event.target.value)}> <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>)} {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 ? ( {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"> <div className="meta-metrics">
@ -38,7 +38,7 @@ export function MetaAnalyticsPage() {
</div> </div>
<article className="panel"> <article className="panel">
<div className="panel-heading"><h2>Spend</h2><p>{report.data.currency}</p></div> <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"> <div className="chart-wrap">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={report.data.series}> <AreaChart data={report.data.series}>
@ -52,6 +52,9 @@ export function MetaAnalyticsPage() {
</div> </div>
)} )}
</article> </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> </MetaScreen>
@ -61,3 +64,29 @@ export function MetaAnalyticsPage() {
function Metric({ label, value }: { label: string; value: string }) { function Metric({ label, value }: { label: string; value: string }) {
return <article className="stat-card meta-metric"><span>{label}</span><strong>{value}</strong></article> 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>
)
}

View file

@ -9,9 +9,9 @@ const OBJECTIVES = [
{ id: 'OUTCOME_TRAFFIC', label: 'Traffic', detail: 'Send people to a website or WhatsApp.', enabled: true }, { 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_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_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_LEADS', label: 'Leads', detail: 'Needs a lead form on your Page. Not available yet.', 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_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 on this connection.', enabled: false }, { id: 'OUTCOME_APP_PROMOTION', label: 'App promotion', detail: 'Needs a linked app. Not available yet.', enabled: false },
] ]
export function MetaCampaignBuilderPage() { export function MetaCampaignBuilderPage() {
@ -42,13 +42,13 @@ export function MetaCampaignBuilderPage() {
const destinations = useMemo(() => [ const destinations = useMemo(() => [
{ id: 'website', label: 'Website', enabled: true, detail: 'Requires a public http or https link.' }, { 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: '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]) ], [objective])
function next() { function next() {
setError(null) setError(null)
if (step === 2 && !pageId) { 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 return
} }
if (step === 3 && !(Number(budget) > 0)) { if (step === 3 && !(Number(budget) > 0)) {
@ -84,19 +84,19 @@ export function MetaCampaignBuilderPage() {
navigate('/console/meta/campaigns') navigate('/console/meta/campaigns')
} catch (err) { } catch (err) {
setConfirming(null) 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 { } finally {
setBusy(false) setBusy(false)
} }
} }
return ( 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"> <ol className="meta-steps">
{STEPS.map((label, index) => <li key={label} className={index === step ? 'is-current' : index < step ? 'is-done' : ''}>{label}</li>)} {STEPS.map((label, index) => <li key={label} className={index === step ? 'is-current' : index < step ? 'is-done' : ''}>{label}</li>)}
</ol> </ol>
{blocked && status.data && <MetaState error={new MetaRequestError('Meta has not granted Ads management for this connection.', 'permission_denied', 'ads_management')} />} {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('Select an ad account before building a campaign.', 'no_ad_account')} />} {!account && accounts.data && <MetaState error={new MetaRequestError('Choose an ad account before creating a campaign.', 'no_ad_account')} />}
{error && <MetaState error={error} />} {error && <MetaState error={error} />}
{pages.loading || accounts.loading || status.loading ? <MetaLoading /> : ( {pages.loading || accounts.loading || status.loading ? <MetaLoading /> : (
<article className="panel meta-builder"> <article className="panel meta-builder">
@ -120,7 +120,7 @@ export function MetaCampaignBuilderPage() {
))} ))}
</div> </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"> <div className="meta-choice-grid">
{pages.data?.pages.map((page) => ( {pages.data?.pages.map((page) => (
<button key={page.id} type="button" className={pageId === page.id ? 'is-selected' : ''} onClick={() => setPageId(page.id)}> <button key={page.id} type="button" className={pageId === page.id ? 'is-selected' : ''} onClick={() => setPageId(page.id)}>
@ -190,8 +190,8 @@ export function MetaCampaignBuilderPage() {
)} )}
{confirming && ( {confirming && (
<ConfirmDialog <ConfirmDialog
title="Create this campaign in Meta?" title="Create this campaign on Facebook?"
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." 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'} confirmLabel={confirming === 'ACTIVE' ? 'Publish live' : 'Create paused'}
busy={busy} busy={busy}
onCancel={() => setConfirming(null)} onCancel={() => setConfirming(null)}

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

View file

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { metaApi, type MetaCampaign, MetaRequestError } from './api' 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() { export function MetaCampaignsPage() {
const list = useMetaLoad('campaigns', () => metaApi.campaigns()) const list = useMetaLoad('campaigns', () => metaApi.campaigns())
@ -22,7 +22,7 @@ export function MetaCampaignsPage() {
setPending(null) setPending(null)
list.reload() list.reload()
} catch (err) { } 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 { } finally {
setBusy(false) setBusy(false)
} }
@ -40,7 +40,7 @@ export function MetaCampaignsPage() {
setEditing(null) setEditing(null)
list.reload() list.reload()
} catch (err) { } 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 { } finally {
setBusy(false) setBusy(false)
} }
@ -55,7 +55,7 @@ export function MetaCampaignsPage() {
{error && <MetaState error={error} />} {error && <MetaState error={error} />}
{list.data?.insights_error && <p className="meta-banner is-error">{list.data.insights_error.message}</p>} {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 ? ( {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"> <div className="meta-table-wrap panel">
<table className="meta-table"> <table className="meta-table">
@ -65,12 +65,13 @@ export function MetaCampaignsPage() {
<tr key={campaign.id}> <tr key={campaign.id}>
<td><strong>{campaign.name}</strong><small>{formatWhen(campaign.created_time)}</small></td> <td><strong>{campaign.name}</strong><small>{formatWhen(campaign.created_time)}</small></td>
<td><StatusPill value={campaign.status} /></td> <td><StatusPill value={campaign.status} /></td>
<td>{campaign.objective}</td> <td>{plainLabel(campaign.objective)}</td>
<td>{campaign.spend || '—'}</td> <td>{campaign.spend || '—'}</td>
<td>{formatCount(campaign.impressions)}</td> <td>{formatCount(campaign.impressions)}</td>
<td>{formatCount(campaign.clicks)}</td> <td>{formatCount(campaign.clicks)}</td>
<td>{campaign.ctr == null ? '—' : `${campaign.ctr}%`}</td> <td>{campaign.ctr == null ? '—' : `${campaign.ctr}%`}</td>
<td className="meta-actions"> <td className="meta-actions">
<Link to={`/console/meta/campaigns/${campaign.id}`}>View</Link>
{list.data?.can_manage && ( {list.data?.can_manage && (
<> <>
<button type="button" onClick={() => { setEditing(campaign); setName(campaign.name); setBudget(''); setError(null) }}>Edit</button> <button type="button" onClick={() => { setEditing(campaign); setName(campaign.name); setBudget(''); setError(null) }}>Edit</button>
@ -84,11 +85,11 @@ export function MetaCampaignsPage() {
</table> </table>
</div> </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 && ( {pending && (
<ConfirmDialog <ConfirmDialog
title={pending.status === 'ACTIVE' ? `Pause ${pending.name}?` : `Resume ${pending.name}?`} 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'} confirmLabel={pending.status === 'ACTIVE' ? 'Pause campaign' : 'Resume campaign'}
busy={busy} busy={busy}
onCancel={() => setPending(null)} onCancel={() => setPending(null)}
@ -98,8 +99,8 @@ export function MetaCampaignsPage() {
{editing && ( {editing && (
<ConfirmDialog <ConfirmDialog
title={`Update ${editing.name}`} 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." 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 to Meta" confirmLabel="Save campaign"
busy={busy} busy={busy}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
onConfirm={saveEdit} onConfirm={saveEdit}

View file

@ -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 { useSearchParams } from 'react-router-dom'
import { metaApi, type MetaConnection, MetaRequestError } from './api' import { metaApi, type MetaConnection, MetaRequestError } from './api'
import { MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, formatWhen, useMetaLoad } from './ui' 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', () => const pages = useMetaLoad(status.data?.capabilities.pages ? 'pages' : 'pages-skip', () =>
status.data?.capabilities.pages ? metaApi.pages() : Promise.resolve({ pages: [], selected_name: '' }), 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', () => const accounts = useMetaLoad(status.data?.capabilities.ads_read || status.data?.capabilities.ads_manage ? 'accounts' : 'accounts-skip', () =>
status.data?.capabilities.ads_read || status.data?.capabilities.business ? metaApi.adAccounts() : Promise.resolve({ accounts: [], selected_name: '' }), status.data?.capabilities.ads_read || status.data?.capabilities.ads_manage ? metaApi.adAccounts() : Promise.resolve({ accounts: [], selected_name: '' }),
) )
async function connect() { async function connect() {
@ -33,13 +34,23 @@ export function MetaConnectionsPage() {
const connection = status.data const connection = status.data
return ( return (
<MetaScreen <MetaScreen
title="Connections" title="Facebook connection"
subtitle="Authorization for this workspace only. Urmi stores the Meta token on the server and does not show it here." 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 Meta'}</button>} 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>} {params.get('error') && <p className="meta-banner is-error">{params.get('error')}</p>}
{error && <MetaState error={error} />} {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 /> : ( {status.loading || !connection ? <MetaLoading /> : (
<div className="meta-split"> <div className="meta-split">
<article className="panel"> <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 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> <p><span>Selected ad account</span><strong>{connection.selected_ad_account_name || 'Not selected'}</strong></p>
</div> </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>
<article className="panel"> <article className="panel">
<div className="panel-heading"><h2>Granted permissions</h2></div> <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"> <ul className="meta-list">
{connection.scopes.map((scope) => <li key={scope.scope}><strong>{scope.label}</strong><span>Granted</span></li>)} {connection.scopes.map((scope) => <li key={scope.scope}><strong>{scope.label}</strong><span>Granted</span></li>)}
</ul> </ul>
@ -69,7 +80,7 @@ export function MetaConnectionsPage() {
title="Business portfolio" title="Business portfolio"
loading={businesses.loading} loading={businesses.loading}
error={gatedError(connection, 'business', 'business_management', 'Business portfolio', businesses.error)} 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>)} {businesses.data?.businesses.map((item) => <li key={item.name}><strong>{item.name}</strong></li>)}
</Section> </Section>
@ -77,15 +88,15 @@ export function MetaConnectionsPage() {
title="Pages" title="Pages"
loading={pages.loading} loading={pages.loading}
error={gatedError(connection, 'pages', 'pages_show_list', 'Page list', pages.error)} 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>)} {pages.data?.pages.map((page) => <li key={page.id}><strong>{page.name}</strong><span>{page.category}</span></li>)}
</Section> </Section>
<Section <Section
title="Ad accounts" title="Ad accounts"
loading={accounts.loading} loading={accounts.loading}
error={gatedError(connection, 'ads_read', 'ads_read', 'Ads reporting', accounts.error, connection?.capabilities.business)} error={gatedError(connection, 'ads_read', 'ads_read', 'Ads reporting', accounts.error, connection?.capabilities.ads_manage)}
empty="Meta did not return any ad accounts for this login." 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>)} {accounts.data?.accounts.map((account) => <li key={account.id}><strong>{account.name}</strong><span>{account.currency} · {account.status}</span></li>)}
</Section> </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( function gatedError(
connection: MetaConnection | null | undefined, connection: MetaConnection | null | undefined,
capability: keyof MetaConnection['capabilities'], capability: keyof MetaConnection['capabilities'],
@ -103,9 +191,9 @@ function gatedError(
alsoAllowed = false, alsoAllowed = false,
) { ) {
if (!connection) return error 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) { 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 return error
} }

View file

@ -4,16 +4,16 @@ import { MetaEmpty, MetaLoading, MetaScreen, StatusPill, auditLabel, formatCount
export function MetaOverviewPage() { export function MetaOverviewPage() {
const { data, loading } = useMetaLoad('overview', () => metaApi.overview()) 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 const connection = data.connection
return ( return (
<MetaScreen <MetaScreen
title="Overview" title="Overview"
subtitle="Pages, ad accounts, campaigns, and delivery that this Meta authorization can actually see." 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 Meta</Link> : undefined} actions={!connection.connected ? <Link className="primary-button" to="/console/meta/connections">Connect Facebook</Link> : undefined}
> >
<div className="meta-card-grid"> <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>)} {data.businesses.items.map((item) => <p key={item.name}><strong>{item.name}</strong><StatusPill value={connection.connected ? 'Connected' : connection.status} /></p>)}
</AssetCard> </AssetCard>
<AssetCard title="Facebook Pages" state={data.pages.state} message={data.pages.message} empty="No Pages are available to this login."> <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"> <div className="meta-split">
<article className="panel"> <article className="panel">
<div className="panel-heading"><h2>Recent Meta activity</h2></div> <div className="panel-heading"><h2>Recent 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." /> : ( {data.activity.length === 0 ? <MetaEmpty title="No advertising activity yet" body="Connecting, publishing, pausing, and disconnecting show up here." /> : (
<ul className="meta-activity"> <ul className="meta-activity">
{data.activity.map((event) => ( {data.activity.map((event) => (
<li key={`${event.action}-${event.created_at}`}> <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> <p><span>Permissions</span><strong>{connection.scopes.map((scope) => scope.label).join(', ') || 'None granted'}</strong></p>
</div> </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> </article>
</div> </div>
@ -74,7 +74,7 @@ export function MetaOverviewPage() {
function performanceNote(state: string, message: string) { function performanceNote(state: string, message: string) {
if (state === 'ok') return 'Last 30 days' 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' return message || 'Delivery is not available'
} }

View file

@ -24,9 +24,9 @@ export function MetaPagesPage() {
} }
return ( 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 ? ( {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"> <div className="meta-table-wrap panel">
<table className="meta-table"> <table className="meta-table">
@ -59,11 +59,11 @@ export function MetaPagesPage() {
<div className="meta-kv"> <div className="meta-kv">
<p><span>Username</span><strong>{detail.data.page.username ? `@${detail.data.page.username}` : '—'}</strong></p> <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>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> </div>
<p className="meta-muted">{detail.data.engagement.note}</p> <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 ? ( {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"> <ul className="meta-posts">
{detail.data.posts.map((post) => ( {detail.data.posts.map((post) => (

View file

@ -38,8 +38,8 @@ export function MetaSettingsPage() {
const connection = view.data?.connection const connection = view.data?.connection
return ( return (
<MetaScreen <MetaScreen
title="Meta settings" title="Ad settings"
subtitle="Permissions, connection health, and disconnect for this workspace." subtitle="Connection health, permissions, and disconnect for this workspace."
actions={ actions={
<> <>
<button className="secondary-button" type="button" onClick={() => setDebug((value) => !value)}>Troubleshooting</button> <button className="secondary-button" type="button" onClick={() => setDebug((value) => !value)}>Troubleshooting</button>
@ -65,16 +65,16 @@ export function MetaSettingsPage() {
</article> </article>
<article className="panel"> <article className="panel">
<div className="panel-heading"><h2>Permissions</h2></div> <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> <ul className="meta-list">{connection.scopes.map((scope) => <li key={scope.scope}><strong>{scope.label}</strong><span>Granted</span></li>)}</ul>
)} )}
</article> </article>
</div> </div>
<div className="meta-split"> <div className="meta-split">
<article className="panel"> <article className="panel">
<div className="panel-heading"><h2>Webhooks</h2></div> <div className="panel-heading"><h2>Message updates</h2></div>
<p className="meta-muted">Urmi does not subscribe a separate ads webhook. WhatsApp message webhooks stay on the WhatsApp connection.</p> <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 webhook health appears when a WhatsApp Business account is connected." /> : ( {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"> <ul className="meta-list">
{view.data.whatsapp.map((account) => <li key={account.name}><strong>{account.name}</strong><StatusPill value={account.webhooks} /></li>)} {view.data.whatsapp.map((account) => <li key={account.name}><strong>{account.name}</strong><StatusPill value={account.webhooks} /></li>)}
</ul> </ul>
@ -82,7 +82,7 @@ export function MetaSettingsPage() {
</article> </article>
<article className="panel"> <article className="panel">
<div className="panel-heading"><h2>Audit trail</h2></div> <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"> <ul className="meta-activity">
{view.data.audit.map((event) => ( {view.data.audit.map((event) => (
<li key={`${event.action}-${event.created_at}`}> <li key={`${event.action}-${event.created_at}`}>
@ -97,13 +97,13 @@ export function MetaSettingsPage() {
</div> </div>
{debug && ( {debug && (
<aside className="panel meta-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"> <div className="meta-kv">
<p><span>App configured</span><strong>{view.data.debug.app_configured ? 'Yes' : 'No'}</strong></p> <p><span>Facebook app ready</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>Return address</span><strong>{view.data.debug.oauth_redirect_uri || '—'}</strong></p>
<p><span>Login config</span><strong>{view.data.debug.login_config_set ? 'Set' : 'Not set'}</strong></p> <p><span>Sign-in setup</span><strong>{view.data.debug.login_config_set ? 'Ready' : 'Not set'}</strong></p>
<p><span>Granted scopes</span><strong>{view.data.debug.granted_scopes.join(', ') || 'None'}</strong></p> <p><span>Permissions on file</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>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>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>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> <p><span>Page reference</span><strong>{view.data.debug.page_ref || '—'}</strong></p>
@ -115,9 +115,9 @@ export function MetaSettingsPage() {
)} )}
{confirm && ( {confirm && (
<ConfirmDialog <ConfirmDialog
title="Disconnect Meta?" title="Disconnect Facebook?"
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." 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 Meta" confirmLabel="Disconnect Facebook"
busy={busy} busy={busy}
onCancel={() => setConfirm(false)} onCancel={() => setConfirm(false)}
onConfirm={disconnect} onConfirm={disconnect}

View file

@ -32,6 +32,7 @@ export type MetaConnection = {
selected_page_name: string selected_page_name: string
selected_ad_account_name: string selected_ad_account_name: string
last_error: string last_error: string
oauth_redirect_uri: string
} }
export type MetaPage = { export type MetaPage = {
@ -78,10 +79,24 @@ export type MetaCampaign = {
reach: number | null 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 = { export type MetaAd = {
id: string id: string
name: string name: string
campaign_name: string campaign_name: string
adset_name: string
status: string status: string
headline: string headline: string
message: string message: string
@ -124,6 +139,8 @@ async function metaRequest<T>(path: string, init?: RequestInit): Promise<T> {
export const metaApi = { export const metaApi = {
status: () => metaRequest<MetaConnection>('/status'), status: () => metaRequest<MetaConnection>('/status'),
start: () => metaRequest<{ authorization_url: string }>('/oauth/start'), 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 }) }), disconnect: () => metaRequest('/disconnect', { method: 'POST', body: JSON.stringify({ confirm: true }) }),
overview: () => metaRequest<MetaOverview>('/overview'), overview: () => metaRequest<MetaOverview>('/overview'),
businesses: () => metaRequest<{ businesses: { name: string }[] }>('/businesses'), businesses: () => metaRequest<{ businesses: { name: string }[] }>('/businesses'),
@ -133,6 +150,8 @@ export const metaApi = {
adAccounts: () => metaRequest<{ accounts: MetaAdAccount[]; selected_name: string }>('/ad-accounts'), adAccounts: () => metaRequest<{ accounts: MetaAdAccount[]; selected_name: string }>('/ad-accounts'),
selectAdAccount: (id: string) => metaRequest('/ad-accounts/select', { method: 'POST', body: JSON.stringify({ id, confirm: true }) }), 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'), 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 }) }), 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 }) => editCampaign: (id: string, payload: { name?: string; daily_budget?: number }) =>
metaRequest(`/campaigns/${id}`, { method: 'POST', body: JSON.stringify({ ...payload, confirm: true }) }), metaRequest(`/campaigns/${id}`, { method: 'POST', body: JSON.stringify({ ...payload, confirm: true }) }),
@ -164,6 +183,28 @@ export type MetaOverview = {
activity: MetaAudit[] 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 = { export type MetaAnalytics = {
account_name: string account_name: string
currency: string currency: string
@ -179,6 +220,18 @@ export type MetaAnalytics = {
cpm: string | null cpm: string | null
} }
series: { date: string; spend: number; impressions: number; clicks: number }[] 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 = { export type MetaSettings = {
@ -189,6 +242,7 @@ export type MetaSettings = {
debug: { debug: {
app_configured: boolean app_configured: boolean
redirect_configured: boolean redirect_configured: boolean
oauth_redirect_uri: string
login_config_set: boolean login_config_set: boolean
granted_scopes: string[] granted_scopes: string[]
last_error: string last_error: string

View file

@ -49,7 +49,7 @@ export function MetaScreen({
<section className="meta-page"> <section className="meta-page">
<div className="welcome-row"> <div className="welcome-row">
<div> <div>
<span className="eyebrow">Meta</span> <span className="eyebrow">Advertising</span>
<h1>{title}</h1> <h1>{title}</h1>
<p>{subtitle}</p> <p>{subtitle}</p>
</div> </div>
@ -68,24 +68,24 @@ export function MetaState({ error }: { error: MetaRequestError }) {
return ( return (
<div className={`meta-state ${denied ? 'is-denied' : expired ? 'is-expired' : ''}`}> <div className={`meta-state ${denied ? 'is-denied' : expired ? 'is-expired' : ''}`}>
<strong> <strong>
{error.code === 'not_configured' && 'Meta Login is not configured'} {error.code === 'not_configured' && 'Facebook sign-in is not set up yet'}
{error.code === 'not_connected' && 'Meta is not connected'} {error.code === 'not_connected' && 'Facebook is not connected'}
{expired && 'Meta access has expired'} {expired && 'Facebook access has expired'}
{denied && 'This connection does not include that permission'} {denied && 'This connection does not include that permission'}
{needsAccount && 'Choose an asset first'} {needsAccount && 'Choose an account or Page first'}
{error.code === 'feature_unavailable' && 'This Meta feature is not available'} {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) && 'Meta could not complete this request'} {!['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> </strong>
<p>{error.message}</p> <p>{error.message}</p>
{disconnected && <Link to="/console/meta/connections">Open connections</Link>} {disconnected && <Link to="/console/meta/connections">Connect Facebook</Link>}
{expired && <Link to="/console/meta/settings">Reconnect Meta</Link>} {expired && <Link to="/console/meta/settings">Reconnect Facebook</Link>}
{needsAccount && <Link to={error.code === 'no_page' ? '/console/meta/pages' : '/console/meta/ad-accounts'}>Choose an asset</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> </div>
) )
} }
export function MetaLoading() { 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 }) { 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) { export function auditLabel(action: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
'connection.authorized': 'Meta connected', 'connection.authorized': 'Facebook connected',
'connection.disconnected': 'Meta disconnected', 'connection.disconnected': 'Facebook disconnected',
'page.selected': 'Page selected', 'page.selected': 'Page selected',
'ad_account.selected': 'Ad account selected', 'ad_account.selected': 'Ad account selected',
'campaign.publish': 'Campaign published', 'campaign.publish': 'Campaign published',
@ -157,8 +157,32 @@ export function auditLabel(action: string) {
return labels[action] || action 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 }) { export function StatusPill({ value }: { value: string }) {
const live = value === 'ACTIVE' || value === 'Active' || value === 'connected' const live = value === 'ACTIVE' || value === 'Active' || value === 'connected'
const paused = value === 'PAUSED' || value === 'Disabled' || value === 'expired' 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>
} }

View file

@ -52,9 +52,9 @@ export function BuyNumbersPage() {
return ( return (
<div className="numbers-page"> <div className="numbers-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Numbers</span> <span className="eyebrow">Phone</span>
<h1>Buy Numbers</h1> <h1>Get a number</h1>
<p>Purchase voice numbers with monthly number cost plus per-minute inbound and outbound rates.</p> <p>Choose a phone number. You pay a monthly fee, plus a per-minute rate for calls you receive and place.</p>
</div> </div>
<section className="panel numbers-panel"> <section className="panel numbers-panel">
@ -69,7 +69,7 @@ export function BuyNumbersPage() {
{numbers.length === 0 ? ( {numbers.length === 0 ? (
<div className="numbers-empty"> <div className="numbers-empty">
<strong>No numbers available right now</strong> <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>
) : ( ) : (
<div className="numbers-table numbers-table-pricing"> <div className="numbers-table numbers-table-pricing">
@ -132,15 +132,15 @@ export function MyNumbersPage() {
return ( return (
<div className="numbers-page"> <div className="numbers-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Numbers</span> <span className="eyebrow">Phone</span>
<h1>My Numbers</h1> <h1>Your numbers</h1>
<p>Numbers assigned by admin or purchased from the catalog.</p> <p>Numbers on this account, whether you bought them or they were assigned to you.</p>
</div> </div>
<section className="panel numbers-panel"> <section className="panel numbers-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>Your inventory</h2> <h2>Numbers on this account</h2>
<p>{numbers.length} active number{numbers.length === 1 ? '' : 's'}</p> <p>{numbers.length} active number{numbers.length === 1 ? '' : 's'}</p>
</div> </div>
<Network size={19} /> <Network size={19} />
@ -149,7 +149,7 @@ export function MyNumbersPage() {
{numbers.length === 0 ? ( {numbers.length === 0 ? (
<div className="numbers-empty"> <div className="numbers-empty">
<strong>No numbers yet</strong> <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>
) : ( ) : (
<div className="numbers-table numbers-table-pricing"> <div className="numbers-table numbers-table-pricing">

View file

@ -236,13 +236,13 @@ function MessagingApiKeyTest({
} }
const keyHint = apiKeys.find((item) => item.status === 'active') 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}…` ? `Paste a full key from Access keys. Active 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 ( return (
<ApiTestCard <ApiTestCard
title="Send template SMS" title="Send one text"
summary="API key authentication for a single recipient." summary="Send one text message with an access key."
method="POST" method="POST"
path="/api/v1/messages/send" path="/api/v1/messages/send"
running={running} running={running}
@ -251,7 +251,7 @@ function MessagingApiKeyTest({
error={error} error={error}
runDisabled={!apiKey.trim() || !senderId || !templateId || !message.trim() || approvedSenders.length === 0} 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_..." /> <input type="password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} placeholder="urmi_live_..." />
</TestField> </TestField>
<TestField label="Sender ID" hint="Pre-filled from your approved senders"> <TestField label="Sender ID" hint="Pre-filled from your approved senders">
@ -359,8 +359,8 @@ function MessagingConsoleTest({
return ( return (
<ApiTestCard <ApiTestCard
title="Send SMS from console" title="Send while signed in"
summary="Session-authenticated batch send used by the compose screen." summary="Send a batch the same way the message screen does, using your signed-in account."
method="POST" method="POST"
path="/api/v1/messaging/send" path="/api/v1/messaging/send"
running={running} running={running}
@ -369,7 +369,7 @@ function MessagingConsoleTest({
error={error} error={error}
runDisabled={!senderConfigId || !templateId || !message.trim() || approvedSenders.length === 0} 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}> <select value={senderConfigId} onChange={(event) => setSenderConfigId(event.target.value)} disabled={approvedSenders.length === 0}>
{approvedSenders.length === 0 && <option value="">No approved senders</option>} {approvedSenders.length === 0 && <option value="">No approved senders</option>}
{approvedSenders.map((sender) => ( {approvedSenders.map((sender) => (
@ -487,22 +487,22 @@ function UrmiOtpGenerateTest({
const keyHint = apiKeys.find((item) => item.status === 'active') const keyHint = apiKeys.find((item) => item.status === 'active')
? `Active key prefix: ${apiKeys.find((item) => item.status === 'active')?.key_prefix}…` ? `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 ( return (
<ApiTestCard <ApiTestCard
title="Generate verification OTP" title="Send a verification code"
summary="Unified UrmiOTP delivery API for approved projects." summary="Send a verification code for an approved project."
method="POST" method="POST"
path="/api/v1/otp/generate" 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} running={running}
onRun={runTest} onRun={runTest}
response={response} response={response}
error={error} error={error}
runDisabled={!projectName || approvedProjects.length === 0} 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_..." /> <input type="password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} placeholder="urmi_live_..." />
</TestField> </TestField>
<TestField label="Project name" hint="Pre-filled from your approved UrmiOTP projects"> <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)) 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 ( return (
<div className="testing-center-page"> <div className="testing-center-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Test & observe</span> <span className="eyebrow">Insights</span>
<h1>Testing Center</h1> <h1>Send a test</h1>
<p>Try Urmi APIs with fields pre-filled from your approved senders, templates, and UrmiOTP projects.</p> <p>Try a message with your approved senders, templates, and verification projects already filled in.</p>
</div> </div>
<TestingAccordion <TestingAccordion
id="messaging" id="messaging"
title="Messaging" title="Text message"
description="Send DLT-approved SMS using API keys or your signed-in console session." description="Send an approved text with an access key, or while you are signed in."
icon={MessageSquareText} icon={MessageSquareText}
open={expanded === 'messaging'} open={expanded === 'messaging'}
onToggle={toggleGroup} onToggle={toggleGroup}
@ -594,8 +594,8 @@ export function TestingCenterPage() {
<TestingAccordion <TestingAccordion
id="urmi-otp" id="urmi-otp"
title="URMI OTP" title="Verification code"
description="Generate and deliver verification codes from approved UrmiOTP projects." description="Send a code from an approved verification project."
icon={KeyRound} icon={KeyRound}
open={expanded === 'urmi-otp'} open={expanded === 'urmi-otp'}
onToggle={toggleGroup} onToggle={toggleGroup}
@ -609,7 +609,7 @@ export function TestingCenterPage() {
<section className="panel testing-empty"> <section className="panel testing-empty">
<TestTube2 size={22} /> <TestTube2 size={22} />
<strong>No test data yet</strong> <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> </section>
)} )}
</div> </div>

View file

@ -105,7 +105,7 @@ export function VoicePage() {
socket.onopen = () => { socket.onopen = () => {
setConsoleConnected(true) setConsoleConnected(true)
pushEvent('Console audio bridge connected') pushEvent('Call audio connected')
} }
socket.onmessage = (message) => { socket.onmessage = (message) => {
@ -156,7 +156,7 @@ export function VoicePage() {
socket.onclose = () => { socket.onclose = () => {
setConsoleConnected(false) setConsoleConnected(false)
pushEvent('Console audio bridge disconnected') pushEvent('Call audio disconnected')
} }
socket.onerror = () => { socket.onerror = () => {
@ -229,7 +229,7 @@ export function VoicePage() {
async function startOutboundCall(useApiKey: boolean) { async function startOutboundCall(useApiKey: boolean) {
setError('') setError('')
if (!consoleConnected) { if (!consoleConnected) {
setError('Console audio bridge is not connected — refresh this page') setError('Call audio is not connected yet. Refresh this page.')
return return
} }
if (audioState !== 'granted') { 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 ( return (
<div className="voice-page"> <div className="voice-page">
<div className="account-heading"> <div className="account-heading">
<span className="eyebrow">Numbers</span> <span className="eyebrow">Phone</span>
<h1>Voice</h1> <h1>Calls</h1>
<p> <p>
Place outbound test calls through the production API. With <code>bridge_mode: console</code>, Place a test call from one of your numbers. Audio plays through this computer, and your microphone is what the other person hears.
audio from the call is played through your speakers and your microphone is sent to the callee.
</p> </p>
</div> </div>
@ -273,13 +272,13 @@ export function VoicePage() {
<section className="panel voice-panel"> <section className="panel voice-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>Console audio</h2> <h2>Call audio</h2>
<p>Required for console-bridged outbound calls on this page.</p> <p>Allow the microphone so you can hear and speak on this call.</p>
</div> </div>
<Radio size={18} className={consoleConnected ? 'voice-live-icon' : ''} /> <Radio size={18} className={consoleConnected ? 'voice-live-icon' : ''} />
</div> </div>
<div className={`voice-presence ${consoleConnected ? 'is-live' : ''}`}> <div className={`voice-presence ${consoleConnected ? 'is-live' : ''}`}>
<span>{consoleConnected ? 'Audio bridge connected' : 'Connecting…'}</span> <span>{consoleConnected ? 'Ready for a call' : 'Connecting…'}</span>
</div> </div>
<div className="voice-audio-panel"> <div className="voice-audio-panel">
<p className="voice-events-empty"> <p className="voice-events-empty">
@ -308,10 +307,10 @@ export function VoicePage() {
</div> </div>
{!numbers.length && ( {!numbers.length && (
<div className="numbers-empty"> <div className="numbers-empty">
<strong>No purchased numbers</strong> <strong>No numbers yet</strong>
<span> <span>
Buy a number from <Link to="/console/numbers/buy">Buy Numbers</Link> or check{' '} Get one from <Link to="/console/numbers/buy">Get a number</Link> or check{' '}
<Link to="/console/numbers">My Numbers</Link>. The <code>from</code> DID must match a purchased number. <Link to="/console/numbers">Your numbers</Link>. Calls can only start from a number on this account.
</span> </span>
</div> </div>
)} )}
@ -320,13 +319,13 @@ export function VoicePage() {
<section className="panel voice-panel"> <section className="panel voice-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>Live events</h2> <h2>Call activity</h2>
<p>Outbound media events appear here while you stay on this page.</p> <p>What happens on this call, while you stay on the page.</p>
</div> </div>
<Radio size={18} /> <Radio size={18} />
</div> </div>
{events.length === 0 ? ( {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"> <ul className="voice-events">
{events.map((event) => ( {events.map((event) => (
@ -344,16 +343,16 @@ export function VoicePage() {
<section className="panel voice-panel"> <section className="panel voice-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>Outbound test</h2> <h2>Place a call</h2>
<p>Place a call through <code>POST /api/v1/voice/calls</code> with console bridging.</p> <p>Call someone from a number on this account.</p>
</div> </div>
<PhoneOutgoing size={18} /> <PhoneOutgoing size={18} />
</div> </div>
<div className="explorer-request-grid voice-form-grid"> <div className="explorer-request-grid voice-form-grid">
<div className="explorer-input"> <div className="explorer-input">
<label> <label>
<strong>From (purchased DID)</strong> <strong>From</strong>
<span>Must be a number assigned to your account.</span> <span>A number already on this account.</span>
<select value={fromDid} onChange={(event) => setFromDid(event.target.value)} disabled={!numbers.length}> <select value={fromDid} onChange={(event) => setFromDid(event.target.value)} disabled={!numbers.length}>
{!numbers.length ? <option value="">No numbers</option> : null} {!numbers.length ? <option value="">No numbers</option> : null}
{numbers.map((number) => ( {numbers.map((number) => (
@ -373,8 +372,8 @@ export function VoicePage() {
</div> </div>
<div className="explorer-input"> <div className="explorer-input">
<label> <label>
<strong>API key (optional)</strong> <strong>Access key (optional)</strong>
<span>Leave empty to use your console session cookie.</span> <span>Leave this empty to place the call while you are signed in.</span>
<input <input
value={apiKey} value={apiKey}
onChange={(event) => setApiKey(event.target.value)} onChange={(event) => setApiKey(event.target.value)}
@ -404,23 +403,23 @@ export function VoicePage() {
<section className="panel explorer-endpoint-card voice-api-panel"> <section className="panel explorer-endpoint-card voice-api-panel">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>API for your projects</h2> <h2>Place calls from your own systems</h2>
<p>{docs?.summary}</p> <p>{docs?.summary}</p>
</div> </div>
<ShieldCheck size={18} /> <ShieldCheck size={18} />
</div> </div>
<div className="explorer-field-row"> <div className="explorer-field-row">
<span>Outbound</span> <span>Place a call</span>
<code>POST {API_BASE_URL}/api/v1/voice/calls</code> <code>POST {API_BASE_URL}/api/v1/voice/calls</code>
</div> </div>
<div className="explorer-field-row"> <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> <code>GET {API_BASE_URL}/api/v1/voice/calls/{'{call_id}'}</code>
</div> </div>
{docs?.relay && ( {docs?.relay && (
<p className="voice-events-empty">{docs.relay.latency}</p> <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> </section>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react' import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox' import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, type FormEvent } from 'react'
import { import {
whatsappApi, whatsappApi,
type WhatsAppAccount, type WhatsAppAccount,
@ -32,16 +32,16 @@ type FacebookSdk = {
} }
const STEPS = [ const STEPS = [
'Connect or create a Meta business', 'Sign in to your Facebook business',
'Create or select a WhatsApp Business Account', 'Choose or create a WhatsApp Business account',
'Add and verify a business phone number', 'Add and verify a business phone number',
'Grant Urmi access to manage messaging', 'Allow Urmi to send and receive messages',
] ]
const WHY = [ const WHY = [
['Send and receive messages', 'Use the number you verify with Meta for customer conversations.'], ['Send and receive messages', 'Use the number you verify with Facebook for customer conversations.'],
['Manage templates', 'Review template status and quality for each WhatsApp Business Account.'], ['Manage templates', 'Review template status and quality for each WhatsApp account.'],
['Multiple numbers', 'Connect the businesses, accounts, and numbers Meta makes available to you.'], ['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.'], ['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 [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null) const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
const [copied, setCopied] = useState(false) 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 [pins, setPins] = useState<Record<string, string>>({})
const sessionRef = useRef<SignupSession | null>(null) const sessionRef = useRef<SignupSession | null>(null)
const codeRef = useRef<string | null>(null) const codeRef = useRef<string | null>(null)
@ -257,6 +261,41 @@ export function WhatsAppSetupPage() {
reportRef.current = reportSession reportRef.current = reportSession
finishRef.current = finishIfReady 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() { async function finishIfReady() {
const session = sessionRef.current const session = sessionRef.current
const code = codeRef.current const code = codeRef.current
@ -389,18 +428,35 @@ export function WhatsAppSetupPage() {
<div className="account-heading"> <div className="account-heading">
<div className="wa-biz-heading"> <div className="wa-biz-heading">
<div> <div>
<span className="eyebrow">Communicate</span> <span className="eyebrow">Messages</span>
<h1>WhatsApp Business</h1> <h1>WhatsApp Business</h1>
<p> <p>
{accounts.length {accounts.length
? 'Manage WhatsApp Business accounts and phone numbers connected to this Urmi workspace.' ? 'The WhatsApp Business accounts and phone numbers on this workspace.'
: 'Connect and manage WhatsApp Business accounts with Urmi.'} : 'Connect WhatsApp Business so you can message customers from Urmi.'}
</p> </p>
</div> </div>
{accounts.length > 0 && phase !== 'provisioning' && ( {accounts.length > 0 && phase !== 'provisioning' && (
<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}> <button className="primary-button" type="button" onClick={() => void startEmbeddedSignup()} disabled={busy}>
<Plus size={16} /> Connect account <Plus size={16} /> Connect account
</button> </button>
</div>
)} )}
</div> </div>
</div> </div>
@ -413,8 +469,8 @@ export function WhatsAppSetupPage() {
<div className="wa-biz-hero"> <div className="wa-biz-hero">
<section className="wa-biz-card"> <section className="wa-biz-card">
<div className="wa-biz-mark"><MessageCircle size={22} /></div> <div className="wa-biz-mark"><MessageCircle size={22} /></div>
<h2>Set up WhatsApp with Meta</h2> <h2>Connect WhatsApp</h2>
<p>Set up WhatsApp Business Platform through Meta&apos;s secure onboarding process.</p> <p>We’ll walk you through Facebook’s secure setup. You stay in control of the business account.</p>
<ul> <ul>
{STEPS.map((step) => ( {STEPS.map((step) => (
<li key={step}><Check size={16} /> {step}</li> <li key={step}><Check size={16} /> {step}</li>
@ -422,16 +478,20 @@ export function WhatsAppSetupPage() {
</ul> </ul>
<button className="primary-button wa-biz-cta" onClick={() => void startEmbeddedSignup()} disabled={busy || !config?.ready}> <button className="primary-button wa-biz-cta" onClick={() => void startEmbeddedSignup()} disabled={busy || !config?.ready}>
{busy ? <Loader2 size={16} className="wa-spin" /> : null} {busy ? <Loader2 size={16} className="wa-spin" /> : null}
Set up WhatsApp with Meta Continue with Facebook
</button> </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> <small>
{config?.ready {config?.ready
? 'Setup usually takes a few minutes. Meta decides which businesses, accounts, and numbers you can use.' ? 'Setup usually takes a few minutes. Facebook decides which businesses, accounts, and numbers you can use.'
: 'Embedded Signup is not available until the Meta configuration id is set for this environment.'} : 'WhatsApp setup is not available until this workspace is configured.'}
</small> </small>
{config?.ready && ( {config?.ready && (
<small> <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> </small>
)} )}
</section> </section>
@ -450,9 +510,9 @@ export function WhatsAppSetupPage() {
{phase === 'provisioning' && ( {phase === 'provisioning' && (
<section className="wa-biz-card wa-biz-provision"> <section className="wa-biz-card wa-biz-provision">
<div className="wa-biz-mark ok"><Check size={18} /></div> <div className="wa-biz-mark ok"><Check size={18} /></div>
<h2>Meta authorization completed</h2> <h2>Facebook sign-in completed</h2>
<p>We&apos;re configuring your WhatsApp Business connection. This screen updates from the setup result, not from a placeholder status.</p> <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 Meta returned</div> <div className="wa-biz-wait"><Loader2 size={18} className="wa-spin" /> Checking the business, account, and phone</div>
</section> </section>
)} )}
@ -472,7 +532,7 @@ export function WhatsAppSetupPage() {
<div><dt>Status</dt><dd><StatusDot status={resultAccount.status} /></dd></div> <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>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>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> </dl>
)} )}
<div className="wa-biz-actions"> <div className="wa-biz-actions">
@ -486,6 +546,10 @@ export function WhatsAppSetupPage() {
</section> </section>
)} )}
{phase === 'list' && showToken && (
<TokenConnectForm busy={busy} tokenValue={tokenValue} wabaId={wabaId} phoneNumberId={phoneNumberId} onToken={setTokenValue} onWaba={setWabaId} onPhone={setPhoneNumberId} onSubmit={submitToken} />
)}
{phase === 'list' && ( {phase === 'list' && (
<div className="wa-biz-list"> <div className="wa-biz-list">
{accounts.map((account) => ( {accounts.map((account) => (
@ -520,7 +584,7 @@ export function WhatsAppSetupPage() {
<small>{account.primary_phone?.verified_name || account.name}</small> <small>{account.primary_phone?.verified_name || account.name}</small>
</div> </div>
<div> <div>
<span>WABA</span> <span>Account reference</span>
<strong>{account.waba_id_masked || '—'}</strong> <strong>{account.waba_id_masked || '—'}</strong>
</div> </div>
<div> <div>
@ -566,7 +630,7 @@ export function WhatsAppSetupPage() {
<div className="wa-biz-tabs"> <div className="wa-biz-tabs">
{(['overview', 'numbers', 'templates', 'configuration', 'logs'] as DetailTab[]).map((item) => ( {(['overview', 'numbers', 'templates', 'configuration', 'logs'] as DetailTab[]).map((item) => (
<button key={item} className={tab === item ? 'active' : ''} onClick={() => setTab(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> </button>
))} ))}
</div> </div>
@ -578,7 +642,7 @@ export function WhatsAppSetupPage() {
<dl className="wa-biz-facts"> <dl className="wa-biz-facts">
<div><dt>Business portfolio</dt><dd>{selected.business_name || '—'}</dd></div> <div><dt>Business portfolio</dt><dd>{selected.business_name || '—'}</dd></div>
<div><dt>WhatsApp Business Account</dt><dd>{selected.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> <div><dt>Connection</dt><dd><StatusDot status={selected.status === 'connected' ? 'connected' : selected.status} label={selected.status === 'connected' ? 'Healthy' : statusLabel(selected.status)} /></dd></div>
</dl> </dl>
{selected.last_error && <p className="wa-biz-inline-error">{selected.last_error}</p>} {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>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>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>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> </ul>
{!selected.usable && ( {!selected.usable && (
<p className="wa-biz-inline-error">Messaging stays unavailable until Urmi can verify a connected phone on this account.</p> <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' && ( {tab === 'numbers' && (
<section className="wa-biz-card"> <section className="wa-biz-card">
<h2>Phone numbers</h2> <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> <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">Meta has not returned a phone number for this account 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"> <ul className="wa-biz-numbers">
{selected.phones.map((phone) => ( {selected.phones.map((phone) => (
<li key={phone.id}> <li key={phone.id}>
<div> <div>
<strong>{phone.display_phone || 'Number pending'}</strong> <strong>{phone.display_phone || 'Number pending'}</strong>
<small>{phone.verified_name || 'Display name pending'}</small> <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> </div>
<StatusDot status={phone.status} /> <StatusDot status={phone.status} />
<span>Quality: {phone.quality || '—'}</span> <span>Quality: {phone.quality || '—'}</span>
@ -660,12 +724,12 @@ export function WhatsAppSetupPage() {
<section className="wa-biz-card"> <section className="wa-biz-card">
<div className="wa-biz-card-head"> <div className="wa-biz-card-head">
<div> <div>
<h2>Developer configuration</h2> <h2>Message delivery</h2>
<p>Webhook delivery for this workspace. Verification credentials stay on Urmi&apos;s servers.</p> <p>Where WhatsApp sends updates for this account. The verification details stay on Urmi.</p>
</div> </div>
{developer && ( {developer && (
<a className="ghost-button" href={developer.docs_url} target="_blank" rel="noreferrer"> <a className="ghost-button" href={developer.docs_url} target="_blank" rel="noreferrer">
Meta docs <ExternalLink size={14} /> WhatsApp help <ExternalLink size={14} />
</a> </a>
)} )}
</div> </div>
@ -674,7 +738,7 @@ export function WhatsAppSetupPage() {
<> <>
<div className="whatsapp-config-row"> <div className="whatsapp-config-row">
<div> <div>
<span>Callback URL</span> <span>Update address</span>
<code>{developer.webhook_url}</code> <code>{developer.webhook_url}</code>
</div> </div>
<button className="secondary-button" onClick={() => void copyWebhook(developer.webhook_url)}> <button className="secondary-button" onClick={() => void copyWebhook(developer.webhook_url)}>
@ -683,18 +747,18 @@ export function WhatsAppSetupPage() {
</div> </div>
<div className="whatsapp-config-row"> <div className="whatsapp-config-row">
<div> <div>
<span>Verify token</span> <span>Verification secret</span>
<code>{developer.verify_token_configured ? 'Hidden — managed by Urmi' : 'Not configured'}</code> <code>{developer.verify_token_configured ? 'Hidden — managed by Urmi' : 'Not set up yet'}</code>
<small>The verify token is not shown in the customer console.</small> <small>This secret stays on Urmi and is not shown here.</small>
</div> </div>
</div> </div>
<p className="wa-biz-subhead">Webhook subscriptions</p> <p className="wa-biz-subhead">Updates we receive</p>
<ul className="whatsapp-field-list"> <ul className="whatsapp-field-list">
{developer.recommended_fields.map((field) => ( {developer.recommended_fields.map((field) => (
<li key={field}><Check size={14} /> {field}</li> <li key={field}><Check size={14} /> {field}</li>
))} ))}
</ul> </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> </section>
@ -704,7 +768,7 @@ export function WhatsAppSetupPage() {
<div className="wa-biz-overview"> <div className="wa-biz-overview">
<section className="wa-biz-card"> <section className="wa-biz-card">
<h2>Connection history</h2> <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>} {logs && logs.audits.length === 0 && <p className="wa-biz-muted">No connection events yet.</p>}
<ul className="wa-biz-log"> <ul className="wa-biz-log">
{logs?.audits.map((item) => ( {logs?.audits.map((item) => (
@ -717,8 +781,8 @@ export function WhatsAppSetupPage() {
</ul> </ul>
</section> </section>
<section className="wa-biz-card"> <section className="wa-biz-card">
<h2>Recent webhook events</h2> <h2>Recent delivery updates</h2>
{logs && logs.events.length === 0 && <p className="wa-biz-muted">No webhook events for this account.</p>} {logs && logs.events.length === 0 && <p className="wa-biz-muted">No delivery updates for this account yet.</p>}
<ul className="wa-biz-log"> <ul className="wa-biz-log">
{logs?.events.map((item) => ( {logs?.events.map((item) => (
<li key={item.id}> <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 }) { 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' const tone = status === 'connected' || status === 'active' ? 'ok' : status === 'unavailable' || status === 'failed' || status === 'expired' || status === 'disconnected' ? 'bad' : 'wait'
return ( return (

View file

@ -19,7 +19,7 @@ button { cursor: pointer; }
.app-shell { min-height: 100vh; display: flex; } .app-shell { min-height: 100vh; display: flex; }
.sidebar { .sidebar {
position: fixed; inset: 0 auto 0 0; z-index: 30; width: 248px; color: #aebcd0; position: fixed; inset: 0 auto 0 0; z-index: 30; width: 264px; color: #aebcd0;
background: linear-gradient(180deg, #061b39 0%, #07172e 100%); display: flex; flex-direction: column; background: linear-gradient(180deg, #061b39 0%, #07172e 100%); display: flex; flex-direction: column;
border-right: 1px solid rgba(255,255,255,.05); border-right: 1px solid rgba(255,255,255,.05);
} }
@ -37,26 +37,26 @@ button { cursor: pointer; }
.sidebar-close { display: none !important; margin-left: auto; color: white !important; } .sidebar-close { display: none !important; margin-left: auto; color: white !important; }
.sidebar nav { padding: 12px 10px 24px; overflow-y: auto; flex: 1; scrollbar-width: thin; } .sidebar nav { padding: 12px 10px 24px; overflow-y: auto; flex: 1; scrollbar-width: thin; }
.nav-group { margin-bottom: 15px; } .nav-group { margin-bottom: 15px; }
.nav-group p { margin: 0 10px 6px; color: #647793; font-size: 9px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; } .nav-group p { margin: 0 10px 6px; color: #8ea0b8; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.nav-group a { color: #aab8ca; text-decoration: none; height: 34px; padding: 0 10px; border-radius: 7px; display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 2px; transition: .18s ease; } .nav-group a { color: #d5deea; text-decoration: none; height: 36px; padding: 0 10px; border-radius: 8px; display: flex; align-items: center; gap: 10px; font-size: 13.5px; margin-bottom: 2px; transition: .18s ease; }
.nav-group a:hover { color: white; background: rgba(255,255,255,.055); } .nav-group a:hover { color: white; background: rgba(255,255,255,.055); }
.nav-group a.active { color: white; background: linear-gradient(90deg, #0877f9, #0b6de3); box-shadow: 0 7px 20px rgba(0,104,239,.23); } .nav-group a.active { color: white; background: linear-gradient(90deg, #0877f9, #0b6de3); box-shadow: 0 7px 20px rgba(0,104,239,.23); }
.sidebar-plan { margin: 12px; padding: 13px; border-radius: 10px; background: rgba(255,255,255,.055); border: 1px solid rgba(255,255,255,.08); } .sidebar-plan { margin: 12px; padding: 13px; border-radius: 10px; background: rgba(255,255,255,.055); border: 1px solid rgba(255,255,255,.08); }
.sidebar-plan div { display: flex; align-items: center; gap: 7px; color: #c1ccda; font-size: 11px; } .sidebar-plan div { display: flex; align-items: center; gap: 7px; color: #c1ccda; font-size: 11px; }
.sidebar-plan strong { display: block; color: white; font-size: 16px; margin-top: 10px; } .sidebar-plan strong { display: block; color: white; font-size: 16px; margin-top: 10px; }
.sidebar-plan small { color: #7487a3; font-size: 9px; } .sidebar-plan small { color: #7487a3; font-size: 9px; }
.sidebar-plan button { width: 100%; border: 0; color: white; background: #0877f9; border-radius: 6px; padding: 7px; font-size: 10px; margin-top: 10px; } .sidebar-plan button { width: 100%; border: 0; color: white; background: #0877f9; border-radius: 6px; padding: 8px; font-size: 12px; margin-top: 10px; }
.workspace { min-width: 0; width: calc(100% - 248px); margin-left: 248px; } .workspace { min-width: 0; width: calc(100% - 264px); margin-left: 264px; }
.topbar { height: 72px; position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 9px; padding: 0 25px; background: rgba(255,255,255,.94); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); } .topbar { height: 72px; position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 9px; padding: 0 25px; background: rgba(255,255,255,.94); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); }
.icon-button { border: 0; background: transparent; color: #627087; width: 34px; height: 34px; border-radius: 8px; display: inline-grid; place-items: center; } .icon-button { border: 0; background: transparent; color: #627087; width: 34px; height: 34px; border-radius: 8px; display: inline-grid; place-items: center; }
.icon-button:hover { background: #f1f5fa; } .icon-button:hover { background: #f1f5fa; }
.menu-button { display: none; } .menu-button { display: none; }
.selector { display: flex; align-items: center; gap: 8px; color: #35435a; background: white; border: 1px solid var(--border); padding: 8px 10px; border-radius: 7px; font-size: 11px; white-space: nowrap; } .selector { display: flex; align-items: center; gap: 8px; color: #35435a; background: white; border: 1px solid var(--border); padding: 8px 10px; border-radius: 7px; font-size: 13px; white-space: nowrap; }
.environment, .system-status { display: flex; align-items: center; gap: 6px; color: #64728a; font-size: 10px; white-space: nowrap; } .environment, .system-status { display: flex; align-items: center; gap: 6px; color: #64728a; font-size: 12px; white-space: nowrap; }
.environment i, .system-status i { display: block; width: 7px; height: 7px; border-radius: 50%; background: #15b87a; box-shadow: 0 0 0 3px #e4f8f0; } .environment i, .system-status i { display: block; width: 7px; height: 7px; border-radius: 50%; background: #15b87a; box-shadow: 0 0 0 3px #e4f8f0; }
.global-search { margin-left: auto; width: min(340px, 34vw); height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #f6f8fb; border: 1px solid #edf0f4; border-radius: 8px; color: #8b97a9; } .global-search { margin-left: auto; width: min(340px, 34vw); height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #f6f8fb; border: 1px solid #edf0f4; border-radius: 8px; color: #8b97a9; }
.global-search input { width: 100%; outline: none; border: 0; background: transparent; color: #26364f; font-size: 11px; } .global-search input { width: 100%; outline: none; border: 0; background: transparent; color: #26364f; font-size: 13px; }
.global-search kbd { color: #8e9aad; background: white; border: 1px solid #e4e8ef; border-radius: 4px; padding: 2px 5px; font-size: 9px; white-space: nowrap; } .global-search kbd { color: #8e9aad; background: white; border: 1px solid #e4e8ef; border-radius: 4px; padding: 2px 5px; font-size: 9px; white-space: nowrap; }
.system-status { color: #279270; } .system-status { color: #279270; }
.notification-button { position: relative; } .notification-button { position: relative; }
@ -74,18 +74,18 @@ button { cursor: pointer; }
main { padding: 30px clamp(20px, 3vw, 44px) 50px; } main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.dashboard { max-width: 1500px; margin: 0 auto; } .dashboard { max-width: 1500px; margin: 0 auto; }
.eyebrow { color: #8a96a7; text-transform: uppercase; letter-spacing: .12em; font-size: 9px; font-weight: 700; } .eyebrow { color: #6d7c90; text-transform: uppercase; letter-spacing: .08em; font-size: 11px; font-weight: 700; }
.welcome-row { display: flex; justify-content: space-between; align-items: end; gap: 20px; } .welcome-row { display: flex; justify-content: space-between; align-items: end; gap: 20px; }
.welcome-row h1, .placeholder h1 { color: #10203b; font-size: clamp(24px, 2.3vw, 32px); letter-spacing: -.04em; margin: 6px 0 5px; } .welcome-row h1, .placeholder h1 { color: #10203b; font-size: clamp(24px, 2.3vw, 32px); letter-spacing: -.04em; margin: 6px 0 5px; }
.welcome-row h1 span { font-size: .8em; } .welcome-row h1 span { font-size: .8em; }
.welcome-row p, .placeholder p { color: var(--muted); font-size: 12px; margin: 0; } .welcome-row p, .placeholder p { color: #5c6b80; font-size: 15px; line-height: 1.55; margin: 0; max-width: 62ch; }
.welcome-actions { display: flex; gap: 9px; } .welcome-actions { display: flex; gap: 9px; }
.primary-button, .secondary-button { display: inline-flex; justify-content: center; align-items: center; gap: 7px; border-radius: 8px; padding: 9px 13px; font-size: 11px; font-weight: 600; } .primary-button, .secondary-button { display: inline-flex; justify-content: center; align-items: center; gap: 7px; border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; }
.primary-button { color: white; border: 1px solid #1475ff; background: #1475ff; box-shadow: 0 5px 12px rgba(20,117,255,.18); } .primary-button { color: white; border: 1px solid #1475ff; background: #1475ff; box-shadow: 0 5px 12px rgba(20,117,255,.18); }
.secondary-button { color: #4b5a70; border: 1px solid var(--border); background: white; } .secondary-button { color: #4b5a70; border: 1px solid var(--border); background: white; }
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(135px, 1fr)) 1.25fr; gap: 12px; margin: 25px 0 18px; } .stats-grid { display: grid; grid-template-columns: repeat(4, minmax(135px, 1fr)) 1.25fr; gap: 12px; margin: 25px 0 18px; }
.stat-card, .balance-card, .panel { background: white; border: 1px solid var(--border); border-radius: 11px; box-shadow: 0 3px 12px rgba(28,45,76,.025); } .stat-card, .balance-card, .panel { background: white; border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 8px 24px rgba(28,45,76,.04); }
.stat-card { position: relative; min-height: 137px; padding: 16px; } .stat-card { position: relative; min-height: 137px; padding: 16px; }
.stat-icon { position: absolute; top: 15px; right: 15px; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; } .stat-icon { position: absolute; top: 15px; right: 15px; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; }
.stat-icon.blue, .activity-icon.blue { color: #1475ff; background: #edf5ff; } .stat-icon.blue, .activity-icon.blue { color: #1475ff; background: #edf5ff; }
@ -105,8 +105,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(270px, .85fr); gap: 18px; } .dashboard-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(270px, .85fr); gap: 18px; }
.panel { padding: 19px; min-width: 0; } .panel { padding: 19px; min-width: 0; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.panel-heading h2 { font-size: 13px; color: #17253e; margin: 0 0 4px; } .panel-heading h2 { font-size: 16px; color: #17253e; margin: 0 0 4px; letter-spacing: -.02em; }
.panel-heading p { font-size: 9px; color: #8b96a7; margin: 0; } .panel-heading p { font-size: 13px; color: #66758a; margin: 0; line-height: 1.45; }
.legend { display: flex; gap: 13px; color: #7c899d; font-size: 9px; } .legend { display: flex; gap: 13px; color: #7c899d; font-size: 9px; }
.legend span { display: flex; align-items: center; gap: 5px; } .legend span { display: flex; align-items: center; gap: 5px; }
.legend i { width: 7px; height: 7px; border-radius: 50%; } .legend i { width: 7px; height: 7px; border-radius: 50%; }
@ -168,12 +168,14 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.hero-proof span { display: flex; align-items: center; gap: 5px; } .hero-proof span { display: flex; align-items: center; gap: 5px; }
.hero-proof svg { color: #37c9b6; } .hero-proof svg { color: #37c9b6; }
.agent-demo { position: relative; max-width: 610px; width: 100%; justify-self: center; } .agent-demo { position: relative; max-width: 610px; width: 100%; justify-self: center; }
.demo-window { position: relative; z-index: 2; overflow: hidden; background: rgba(9,28,55,.92); border: 1px solid rgba(116,169,229,.2); border-radius: 16px; box-shadow: 0 35px 90px rgba(0,0,0,.38); backdrop-filter: blur(15px); } .demo-window { position: relative; z-index: 2; overflow: hidden; background: #f7f9fc; border: 0; border-radius: 18px; box-shadow: 0 30px 80px rgba(0,0,0,.32); color: #13213a; }
.demo-top { height: 48px; display: flex; align-items: center; gap: 15px; padding: 0 16px; border-bottom: 1px solid rgba(255,255,255,.08); color: #7d91ac; } .demo-top { height: 56px; display: flex; align-items: center; gap: 15px; padding: 0 18px; border-bottom: 1px solid #e6ebf2; color: #13213a; }
.demo-top > span { display: flex; gap: 5px; } .demo-top > span, .thread-brand { font-size: 14px; font-weight: 700; color: #13213a; }
.demo-top i { width: 7px; height: 7px; background: #31445e; border-radius: 50%; } .demo-top b { margin-left: auto; color: #128660; background: #e7f8f1; padding: 5px 9px; border-radius: 12px; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
.demo-top small { font-family: ui-monospace, monospace; font-size: 10px; } .thread-list { display: grid; gap: 10px; padding: 16px; }
.demo-top b { margin-left: auto; color: #49cdae; background: rgba(73,205,174,.08); padding: 4px 7px; border-radius: 12px; font-size: 8px; letter-spacing: .05em; text-transform: uppercase; } .thread-list article { background: white; border: 1px solid #e6ebf2; border-radius: 12px; padding: 14px 16px; }
.thread-list small { color: #718096; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.thread-list p { margin: 6px 0 0; color: #1c2b44; font-size: 15px; line-height: 1.5; }
.demo-window pre { margin: 0; padding: 34px 28px 42px; color: #c3d1e4; font: 12px/2 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-x: auto; } .demo-window pre { margin: 0; padding: 34px 28px 42px; color: #c3d1e4; font: 12px/2 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-x: auto; }
.code-purple { color: #b89cff; }.code-green { color: #72d6b9; } .code-purple { color: #b89cff; }.code-green { color: #72d6b9; }
.demo-event { display: grid; grid-template-columns: 38px 1fr auto; align-items: center; gap: 11px; margin: 0 15px 15px; padding: 13px; background: rgba(255,255,255,.045); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; } .demo-event { display: grid; grid-template-columns: 38px 1fr auto; align-items: center; gap: 11px; margin: 0 15px 15px; padding: 13px; background: rgba(255,255,255,.045); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; }
@ -190,18 +192,19 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.trust-strip b { font-size: 13px; font-weight: 600; } .trust-strip b { font-size: 13px; font-weight: 600; }
.feature-section { padding: 100px max(7vw, 28px) 120px; } .feature-section { padding: 100px max(7vw, 28px) 120px; }
.section-heading { max-width: 650px; margin: 0 auto 50px; text-align: center; } .section-heading { max-width: 650px; margin: 0 auto 50px; text-align: center; }
.section-heading > span, .developer-band > div > span { color: #1475ff; font-size: 9px; font-weight: 700; letter-spacing: .14em; } .section-heading > span, .developer-band > div > span, .closing-band > div > span { color: #1475ff; font-size: 11px; font-weight: 700; letter-spacing: .12em; }
.section-heading h2, .developer-band h2 { margin: 12px 0; color: #10203b; font-size: clamp(28px, 3vw, 42px); letter-spacing: -.045em; } .section-heading h2, .developer-band h2, .closing-band h2 { margin: 12px 0; color: #10203b; font-size: clamp(28px, 3vw, 42px); letter-spacing: -.045em; }
.section-heading p, .developer-band p { color: #7b8799; font-size: 13px; line-height: 1.7; } .section-heading p, .developer-band p, .closing-band p { color: #7b8799; font-size: 15px; line-height: 1.7; }
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; max-width: 1160px; margin: auto; } .feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; max-width: 1160px; margin: auto; }
.feature-grid article { padding: 28px; background: white; border: 1px solid #e7ebf1; border-radius: 13px; box-shadow: 0 8px 30px rgba(26,42,70,.035); } .feature-grid article { padding: 28px; background: white; border: 1px solid #e7ebf1; border-radius: 13px; box-shadow: 0 8px 30px rgba(26,42,70,.035); }
.feature-grid article > span { width: 42px; height: 42px; display: grid; place-items: center; color: #1475ff; background: #edf5ff; border-radius: 10px; } .feature-grid article > span { width: 42px; height: 42px; display: grid; place-items: center; color: #1475ff; background: #edf5ff; border-radius: 10px; }
.feature-grid article svg { width: 19px; } .feature-grid article svg { width: 19px; }
.feature-grid h3 { margin: 23px 0 9px; color: #152440; font-size: 15px; } .feature-grid h3 { margin: 23px 0 9px; color: #152440; font-size: 15px; }
.feature-grid p { color: #7f8a9b; font-size: 11px; line-height: 1.7; margin: 0; } .feature-grid p { color: #5c6b80; font-size: 14px; line-height: 1.65; margin: 0; }
.developer-band { margin: 0 max(5vw, 20px) 70px; padding: 55px max(5vw, 30px); display: flex; align-items: center; justify-content: space-between; gap: 30px; color: white; background: #081a35; border-radius: 18px; } .developer-band, .closing-band { margin: 0 max(5vw, 20px) 70px; padding: 55px max(5vw, 30px); display: flex; align-items: center; justify-content: space-between; gap: 30px; color: white; background: #081a35; border-radius: 18px; }
.developer-band h2 { color: white; margin-bottom: 7px; }.developer-band p { margin: 0; color: #8fa0b6; } .developer-band h2, .closing-band h2 { color: white; margin-bottom: 7px; }
.developer-band > a { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; color: white; background: #1475ff; border-radius: 8px; padding: 12px 16px; text-decoration: none; font-size: 11px; font-weight: 700; } .developer-band p, .closing-band p { margin: 0; color: #c5d0e0; }
.developer-band > a, .closing-band > a { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; color: white; background: #1475ff; border-radius: 8px; padding: 12px 16px; text-decoration: none; font-size: 13px; font-weight: 700; }
.landing-footer { min-height: 100px; display: flex; align-items: center; gap: 22px; padding: 20px max(5vw, 24px); color: #788ba5; background: #07172f; } .landing-footer { min-height: 100px; display: flex; align-items: center; gap: 22px; padding: 20px max(5vw, 24px); color: #788ba5; background: #07172f; }
.landing-footer img { width: 90px; height: 48px; object-fit: contain; } .landing-footer img { width: 90px; height: 48px; object-fit: contain; }
.landing-footer span { font-size: 10px; } .landing-footer span { font-size: 10px; }
@ -274,7 +277,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.auth-tabs button.active { color: #13213a; background: white; box-shadow: 0 2px 8px rgba(20,34,58,.08); } .auth-tabs button.active { color: #13213a; background: white; box-shadow: 0 2px 8px rgba(20,34,58,.08); }
.auth-heading { margin: 29px 0 24px; text-align: center; } .auth-heading { margin: 29px 0 24px; text-align: center; }
.auth-heading h2 { margin: 0 0 8px; color: #14233d; font-size: 25px; letter-spacing: -.035em; } .auth-heading h2 { margin: 0 0 8px; color: #14233d; font-size: 25px; letter-spacing: -.035em; }
.auth-heading p { margin: 0; color: #7f8b9d; font-size: 11px; } .auth-heading p { margin: 0; color: #5c6b80; font-size: 14px; line-height: 1.5; }
.google-button { height: 44px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #344157; background: white; border: 1px solid #dfe5ed; border-radius: 8px; text-decoration: none; font-size: 11px; font-weight: 600; } .google-button { height: 44px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #344157; background: white; border: 1px solid #dfe5ed; border-radius: 8px; text-decoration: none; font-size: 11px; font-weight: 600; }
.google-button:hover { border-color: #c7d0dc; background: #fafbfd; }.google-button svg { width: 17px; } .google-button:hover { border-color: #c7d0dc; background: #fafbfd; }.google-button svg { width: 17px; }
.auth-divider { display: flex; align-items: center; gap: 13px; margin: 22px 0; color: #a0a9b5; font-size: 9px; } .auth-divider { display: flex; align-items: center; gap: 13px; margin: 22px 0; color: #a0a9b5; font-size: 9px; }
@ -293,7 +296,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.auth-terms { max-width: 300px; margin: 19px auto 0; color: #9aa4b2; text-align: center; font-size: 8px; line-height: 1.6; } .auth-terms { max-width: 300px; margin: 19px auto 0; color: #9aa4b2; text-align: center; font-size: 8px; line-height: 1.6; }
/* Account security */ /* Account security */
.account-heading { margin-bottom: 24px; }.account-heading h1 { margin: 6px 0; font-size: 28px; letter-spacing: -.04em; }.account-heading p { margin: 0; color: #7c899b; font-size: 11px; } .account-heading { margin-bottom: 24px; }.account-heading h1 { margin: 8px 0; font-size: 32px; letter-spacing: -.04em; }.account-heading p { margin: 0; color: #5c6b80; font-size: 15px; line-height: 1.55; max-width: 68ch; }
.account-grid { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 18px; } .account-grid { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 18px; }
.account-profile { display: flex; align-items: center; gap: 15px; align-self: start; } .account-profile { display: flex; align-items: center; gap: 15px; align-self: start; }
.account-avatar { width: 48px; height: 48px; flex: 0 0 48px; display: grid; place-items: center; overflow: hidden; color: #1475ff; background: #e7f1ff; border-radius: 50%; font-size: 13px; font-weight: 700; } .account-avatar { width: 48px; height: 48px; flex: 0 0 48px; display: grid; place-items: center; overflow: hidden; color: #1475ff; background: #e7f1ff; border-radius: 50%; font-size: 13px; font-weight: 700; }
@ -1642,7 +1645,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.demo-window pre { padding: 25px 17px 30px; font-size: 10px; } .demo-window pre { padding: 25px 17px 30px; font-size: 10px; }
.orbit-one { right: 16px; } .orbit-one { right: 16px; }
.orbit-two { left: -8px; } .orbit-two { left: -8px; }
.developer-band { align-items: flex-start; flex-direction: column; } .developer-band, .closing-band { align-items: flex-start; flex-direction: column; }
.landing-footer { flex-wrap: wrap; } .landing-footer { flex-wrap: wrap; }
.landing-footer small { margin-left: 0; width: 100%; } .landing-footer small { margin-left: 0; width: 100%; }
.auth-main { padding: 78px 20px 40px; } .auth-main { padding: 78px 20px 40px; }
@ -1829,6 +1832,13 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-biz { max-width: 1080px; } .wa-biz { max-width: 1080px; }
.wa-biz-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; } .wa-biz-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.wa-biz-heading-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.wa-token-toggle { margin-top: 14px; font-size: 14px; }
.wa-token-card { margin-bottom: 16px; }
.wa-token-embed { display: grid; gap: 8px; margin-top: 18px; padding-top: 16px; border-top: 1px solid #e6ebf2; }
.wa-token-embed h2 { margin-top: 0; }
.wa-token-form { display: grid; gap: 12px; margin-top: 16px; }
.wa-token-form .primary-button { justify-self: start; display: inline-flex; align-items: center; gap: 8px; }
.wa-biz-heading h1 { margin: 6px 0; } .wa-biz-heading h1 { margin: 6px 0; }
.wa-biz-heading p { max-width: 640px; } .wa-biz-heading p { max-width: 640px; }
.wa-biz-notice { margin: 0 0 16px; padding: 12px 14px; border: 1px solid #d7e6ff; border-radius: 12px; background: #f4f8ff; color: #1d4f91; font-size: 13px; } .wa-biz-notice { margin: 0 0 16px; padding: 12px 14px; border: 1px solid #d7e6ff; border-radius: 12px; background: #f4f8ff; color: #1d4f91; font-size: 13px; }
@ -1963,6 +1973,31 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.meta-dialog h2 { margin: 0; color: #17253e; font-size: 16px; } .meta-dialog h2 { margin: 0; color: #17253e; font-size: 16px; }
.meta-dialog p { margin: 0; color: #5d6b80; font-size: 12px; line-height: 1.45; } .meta-dialog p { margin: 0; color: #5d6b80; font-size: 12px; line-height: 1.45; }
.meta-debug { border-style: dashed; } .meta-debug { border-style: dashed; }
.meta-oauth-setup .panel-heading p { max-width: 560px; }
.meta-oauth-steps { margin: 0 0 12px; padding-left: 18px; color: #526176; font-size: 12px; line-height: 1.55; }
.meta-oauth-steps li + li { margin-top: 6px; }
.meta-oauth-steps code { font-size: 11px; }
.meta-oauth-uri { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 10px 12px; border: 1px solid #dbe2eb; border-radius: 8px; background: #f8fafc; }
.meta-oauth-uri code { flex: 1; min-width: 240px; word-break: break-all; color: #17253e; font-size: 12px; }
.meta-token-setup { display: grid; gap: 14px; margin-bottom: 16px; }
.meta-token-setup .panel-heading { margin: 0; }
.meta-token-actions { display: flex; align-items: center; gap: 12px; }
.meta-token-actions span { color: #12805c; font-size: 13px; }
.meta-tabs { display: flex; gap: 8px; margin-bottom: 4px; }
.meta-tabs button { border: 1px solid var(--border); background: white; color: #526176; border-radius: 999px; padding: 6px 12px; font-size: 11px; font-weight: 700; }
.meta-tabs button.is-active { border-color: #1475ff; color: #1475ff; background: #edf5ff; }
@media (max-width: 900px) { @media (max-width: 900px) {
.meta-split, .meta-posts li { grid-template-columns: 1fr; } .meta-split, .meta-posts li { grid-template-columns: 1fr; }
} }
.stat-card > span, .balance-card > span { font-size: 13px; }
.stat-card p { font-size: 12px; }
.insight strong, .activity-item strong, .compliance-row { font-size: 13px; }
.insight p, .activity-item p, .activity-item time, .legend { font-size: 12px; }
.auth-card label > span, .account-password label > span { font-size: 13px; }
.auth-card input, .kyc-form input, .account-password input { font-size: 14px; }
.google-button, .auth-submit, .auth-tabs button { font-size: 14px; }
.auth-message > div { font-size: 14px; }
.meta-activity strong, .meta-list strong, .meta-kv strong, .meta-state strong { font-size: 14px; }
.meta-activity span, .meta-activity em, .meta-muted, .meta-list span, .meta-kv span, .meta-state p { font-size: 13px; }
.meta-tabs button { font-size: 13px; }

View file

@ -1237,6 +1237,11 @@ export const whatsappApi = {
events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'), events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'),
signupConfig: () => jsonRequest<WhatsAppSignupConfig>('/api/v1/whatsapp/signup/config'), signupConfig: () => jsonRequest<WhatsAppSignupConfig>('/api/v1/whatsapp/signup/config'),
accounts: () => jsonRequest<WhatsAppAccount[]>('/api/v1/whatsapp/accounts'), accounts: () => jsonRequest<WhatsAppAccount[]>('/api/v1/whatsapp/accounts'),
connectToken: (payload: { access_token: string; waba_id: string; phone_number_id?: string | null }) =>
jsonRequest<WhatsAppSignupResult>('/api/v1/whatsapp/connections/token', {
method: 'POST',
body: JSON.stringify(payload),
}),
completeSignup: (payload: { completeSignup: (payload: {
event: string event: string
code?: string | null code?: string | null