From 8c46d091ff6db143fa6416fd239c39f3bbd2fc41 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Mon, 12 Oct 2026 23:19:09 +0530 Subject: [PATCH] Whatsapp --- .env.example | 6 +- backend/app/meta_service.py | 487 ++++++++++++++---- backend/app/routes_meta.py | 35 ++ backend/app/routes_whatsapp.py | 22 + backend/app/whatsapp_onboarding.py | 79 ++- docs/META_APP_REVIEW_PERMISSIONS.md | 2 +- frontend/src/App.tsx | 8 +- frontend/src/components/AppShell.tsx | 72 +-- .../features/agent/AgentTemplateDocsPage.tsx | 16 +- .../src/features/agent/AgentTemplatesPage.tsx | 35 +- .../src/features/agent/UrmiOtpDocsPage.tsx | 13 +- frontend/src/features/agent/UrmiOtpPage.tsx | 12 +- frontend/src/features/agent/UrmiportPage.tsx | 46 +- .../src/features/analytics/AnalyticsPage.tsx | 58 +-- .../src/features/auth/AccountSettings.tsx | 2 +- frontend/src/features/auth/AuthPage.tsx | 14 +- .../features/campaigns/CampaignDetailPage.tsx | 6 +- .../src/features/campaigns/CampaignsPage.tsx | 20 +- .../compliance/ComplianceProvider.tsx | 2 +- frontend/src/features/compliance/DltPage.tsx | 12 +- frontend/src/features/compliance/KycPage.tsx | 12 +- frontend/src/features/dashboard/Dashboard.tsx | 53 +- .../src/features/develop/ApiExplorerPage.tsx | 158 +++--- frontend/src/features/develop/ApiKeysPage.tsx | 34 +- .../features/develop/WebhooksSocketsPage.tsx | 56 +- frontend/src/features/landing/LandingPage.tsx | 62 ++- .../src/features/legal/PrivacyPolicyPage.tsx | 2 +- frontend/src/features/logs/LogsPage.tsx | 20 +- .../src/features/messaging/ComposeMessage.tsx | 38 +- .../features/messaging/MessageSettings.tsx | 26 +- .../src/features/messaging/TemplatesPage.tsx | 8 +- frontend/src/features/meta/AdAccountsPage.tsx | 4 +- frontend/src/features/meta/AdsPage.tsx | 48 +- frontend/src/features/meta/AnalyticsPage.tsx | 37 +- .../src/features/meta/CampaignBuilderPage.tsx | 24 +- .../src/features/meta/CampaignDetailPage.tsx | 78 +++ frontend/src/features/meta/CampaignsPage.tsx | 19 +- .../src/features/meta/ConnectionsPage.tsx | 118 ++++- frontend/src/features/meta/OverviewPage.tsx | 16 +- frontend/src/features/meta/PagesPage.tsx | 8 +- frontend/src/features/meta/SettingsPage.tsx | 32 +- frontend/src/features/meta/api.ts | 54 ++ frontend/src/features/meta/ui.tsx | 52 +- .../src/features/numbers/NumbersPages.tsx | 18 +- .../features/testing/TestingCenterPage.tsx | 44 +- frontend/src/features/voice/VoicePage.tsx | 53 +- .../features/whatsapp/WhatsAppSetupPage.tsx | 198 +++++-- frontend/src/index.css | 95 ++-- frontend/src/lib/api.ts | 5 + 49 files changed, 1607 insertions(+), 712 deletions(-) create mode 100644 frontend/src/features/meta/CampaignDetailPage.tsx diff --git a/.env.example b/.env.example index dfc3839..5a76e47 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,11 @@ META_APP_SECRET= META_EMBEDDED_SIGNUP_CONFIG_ID= # Optional Facebook Login for Business config for Pages and Ads. Do not put the WhatsApp Embedded Signup config here. 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= WHATSAPP_BUSINESS_PHONE=919411257916 WHATSAPP_API_VERSION=v21.0 diff --git a/backend/app/meta_service.py b/backend/app/meta_service.py index cb6a935..4a2a003 100644 --- a/backend/app/meta_service.py +++ b/backend/app/meta_service.py @@ -7,6 +7,7 @@ needs to call a follow-up action. from __future__ import annotations +import json import re import uuid 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}" +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: 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: - ensure_configured() conn = load_connection(db, user) if not conn or conn.status == "disconnected" or not conn.access_token: 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]: + redirect_uri = settings.meta_oauth_redirect_uri_resolved if not conn or conn.status == "disconnected": return { "configured": settings.meta_oauth_configured, @@ -248,6 +315,7 @@ def public_connection(conn: MetaConnection | None) -> dict[str, Any]: "selected_page_name": "", "selected_ad_account_name": "", "last_error": "", + "oauth_redirect_uri": redirect_uri, } return { "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_ad_account_name": conn.selected_ad_account_name, "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") +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]: return await graph( "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]: 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 or Business portfolio for this connection.", + "Meta has not granted Ads reporting or Ads management for this connection.", permission="ads_read", ) + data = await ads_api( + db, + conn, + "GET", + "me/adaccounts", + {"fields": "id,name,account_status,currency,amount_spent,business{name}", "limit": "50"}, + ) rows: dict[str, dict[str, Any]] = {} - if has_scope(conn, "ads_read"): - data = await graph_as_user( - db, - conn, - "GET", - "me/adaccounts", - {"fields": "id,name,account_status,currency,amount_spent", "limit": "50"}, - ) - for row in data.get("data") or []: - if isinstance(row, dict) and row.get("id"): - 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 + for row in data.get("data") or []: + 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 accounts = [ _account_summary(row, selected=act_id(str(row.get("id"))) == act_id(conn.selected_ad_account_id)) 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]: 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") accounts = await list_ad_accounts(db, user) 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") if not conn.selected_ad_account_id: raise MetaApiError(409, "no_ad_account", "Select an ad account before opening campaigns.") - accounts = await list_ad_accounts(db, user) - 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.") - return conn, match["id"] + return conn, act_id(conn.selected_ad_account_id) + + +async def _account_object( + 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]: conn, account_id = await _selected_account(db, user, manage=False) - data = await graph_as_user( + data = await marketing_get( db, conn, - "GET", - f"{account_id}/campaigns", + account_id, + "campaigns", { "fields": "id,name,status,effective_status,objective,created_time", "limit": "50", @@ -668,11 +884,11 @@ async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]: insights: dict[str, dict[str, Any]] = {} insights_error = None try: - report = await graph_as_user( + report = await marketing_get( db, conn, - "GET", - f"{account_id}/insights", + account_id, + "insights", { "level": "campaign", "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: 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 "") except MetaApiError: return "" @@ -741,13 +957,11 @@ async def set_campaign_status(db: Session, user: UserAccount, campaign_id: str, if status not in {"ACTIVE", "PAUSED"}: raise MetaApiError(422, "validation", "Choose pause or resume.") 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"}) - if not _same_account(current.get("account_id"), account_id): - raise MetaApiError(404, "not_found", "That campaign is not in the selected ad account.") + current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name") name = str(current.get("name") or "Campaign") action = "campaign.resume" if status == "ACTIVE" else "campaign.pause" 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: 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 @@ -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]: 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"}) - if not _same_account(current.get("account_id"), account_id): - raise MetaApiError(404, "not_found", "That campaign is not in the selected ad account.") + current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name") if not name and daily_budget is None: raise MetaApiError(422, "validation", "Add a name or daily budget to update.") label = name or str(current.get("name") or "Campaign") try: 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: 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")] if len(rows) != 1: 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)))) - 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: 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 @@ -820,12 +1038,12 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An status = "ACTIVE" if activate else "PAUSED" created_campaign = "" try: - campaign = await graph_as_user( + campaign = await marketing_post( db, conn, - "POST", - f"{account_id}/campaigns", - body={ + account_id, + "campaigns", + { "name": name, "objective": objective, "status": "PAUSED", @@ -855,21 +1073,21 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An adset_body["destination_type"] = "WHATSAPP" elif destination == "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) - creative = await graph_as_user( + creative = await marketing_post( db, conn, - "POST", - f"{account_id}/adcreatives", - body={"name": f"{name} creative", "object_story_spec": story}, + account_id, + "adcreatives", + {"name": f"{name} creative", "object_story_spec": story}, ) - await graph_as_user( + await marketing_post( db, conn, - "POST", - f"{account_id}/ads", - body={ + account_id, + "ads", + { "name": f"{name} ad", "adset_id": adset.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: - await graph_as_user(db, conn, "POST", created_campaign, body={"status": "ACTIVE"}) - await graph_as_user(db, conn, "POST", str(adset.get("id")), body={"status": "ACTIVE"}) + await ads_object_post(db, conn, created_campaign, {"status": "ACTIVE"}) + await ads_object_post(db, conn, str(adset.get("id")), {"status": "ACTIVE"}) except MetaApiError as exc: detail = exc.message 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]: conn, account_id = await _selected_account(db, user, manage=False) - data = await graph_as_user( + data = await marketing_get( db, conn, - "GET", - f"{account_id}/ads", + account_id, + "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", }, ) @@ -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 {} 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 {} + adset = row.get("adset") if isinstance(row.get("adset"), dict) else {} return { "id": str(row.get("id")), "name": row.get("name") or "Ad", "campaign_name": campaign.get("name") or "", + "adset_name": adset.get("name") 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 "", "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]: conn, account_id = await _selected_account(db, user, manage=True) - current = await graph_as_user( + current = await _account_object( db, conn, - "GET", + account_id, + "ads", 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") try: 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") if any(payload.get(field) for field in creative_fields): 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 "") if link and not link.startswith(("http://", "https://")): raise MetaApiError(422, "validation", "Creative links need to start with http or https.") - replacement = await graph_as_user( + replacement = await marketing_post( db, conn, - "POST", - f"{account_id}/adcreatives", + account_id, + "adcreatives", body={ "name": f"{label} creative", "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: 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 @@ -1025,13 +1244,11 @@ async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str) if status not in {"ACTIVE", "PAUSED"}: raise MetaApiError(422, "validation", "Choose pause or resume.") 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"}) - if not _same_account(current.get("account_id"), account_id): - raise MetaApiError(404, "not_found", "That ad is not in the selected ad account.") + current = await _account_object(db, conn, account_id, "ads", ad_id, "id,name") name = str(current.get("name") or "Ad") action = "ad.resume" if status == "ACTIVE" else "ad.pause" 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: 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 @@ -1039,23 +1256,23 @@ async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str) 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"}: raise MetaApiError(422, "validation", "Choose a supported date range.") conn, account_id = await _selected_account(db, user, manage=False) currency = await _account_currency(db, conn, account_id) - summary = await graph_as_user( + summary = await marketing_get( db, conn, - "GET", - f"{account_id}/insights", + account_id, + "insights", {"fields": "spend,impressions,reach,clicks,ctr,cpc,cpm", "date_preset": date_preset}, ) - daily = await graph_as_user( + daily = await marketing_get( db, conn, - "GET", - f"{account_id}/insights", + account_id, + "insights", { "fields": "spend,impressions,clicks,date_start", "date_preset": date_preset, @@ -1063,6 +1280,34 @@ async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[st "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 {} if not isinstance(row, dict): 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, }, "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 result["businesses"] = await _section(lambda: list_businesses(db, user), "businesses") 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") else: 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: try: - report = await analytics(db, user, "last_30d") + report = await analytics(db, user, "last_30d", breakdowns=False) result["performance"] = {"state": "ok", "message": "", "report": report} except MetaApiError as exc: 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 = { "app_configured": settings.meta_oauth_configured, "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), "granted_scopes": sorted(scopes_of(conn)) if conn else [], "last_error": conn.last_error if conn else "", diff --git a/backend/app/routes_meta.py b/backend/app/routes_meta.py index 06d76c8..5a7e476 100644 --- a/backend/app/routes_meta.py +++ b/backend/app/routes_meta.py @@ -13,11 +13,14 @@ from .meta_service import ( analytics, authorization_url, disconnect, + connect_with_user_token, finish_oauth, http_meta, list_ad_accounts, list_ads, + list_adsets, list_businesses, + campaign_detail, list_campaigns, list_pages, overview, @@ -43,6 +46,10 @@ class Confirm(BaseModel): confirm: bool = False +class ManualToken(BaseModel): + access_token: str = Field(min_length=1, max_length=4096) + + class IdBody(Confirm): 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") +@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") def disconnect_meta(body: Confirm, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict: _confirmed(body) @@ -191,6 +206,26 @@ async def campaigns(user: UserAccount = Depends(current_user), db: Session = Dep 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") async def create_campaign(body: CampaignPublish, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict: _confirmed(body) diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index f6ccb58..181935b 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -17,6 +17,7 @@ from .whatsapp_onboarding import ( account_detail, account_logs, complete_signup, + connect_with_token, customer_token_for_phone, developer_config, disconnect_account, @@ -174,6 +175,12 @@ class RegisterPhoneRequest(BaseModel): 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): event: str = Field(min_length=1, max_length=80) 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()) +@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}") def whatsapp_account_detail( account_id: str, diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py index 5ab8a2e..cf52a0d 100644 --- a/backend/app/whatsapp_onboarding.py +++ b/backend/app/whatsapp_onboarding.py @@ -8,6 +8,7 @@ addressed by another tenant. from __future__ import annotations import logging +import re import secrets import uuid 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: + message = fallback if isinstance(data, dict): error = data.get("error") if isinstance(error, dict) and error.get("message"): - return str(error["message"])[:500] - return fallback + message = str(error["message"]) + message = re.sub(r"(access_token|token|secret)=([^&\s]+)", r"\1=redacted", message, flags=re.I) + return message[:500] 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]} +_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]: account = _owned_account(db, user, account_id) connection = account.connection diff --git a/docs/META_APP_REVIEW_PERMISSIONS.md b/docs/META_APP_REVIEW_PERMISSIONS.md index b68ef1a..f594be8 100644 --- a/docs/META_APP_REVIEW_PERMISSIONS.md +++ b/docs/META_APP_REVIEW_PERMISSIONS.md @@ -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. | | **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. | -| **Tiers** | Adding Marketing API product → **Limited** (dev). **Full** needs App Review plus, to **maintain/upgrade**: ~**500 successful Marketing API calls in 15 days** and **<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 **<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. | **Relation to “Ads Management Standard Access”** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f3c5581..cf3a9bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { MetaConnectionsPage } from './features/meta/ConnectionsPage' import { MetaPagesPage } from './features/meta/PagesPage' import { MetaAdAccountsPage } from './features/meta/AdAccountsPage' import { MetaCampaignsPage } from './features/meta/CampaignsPage' +import { MetaCampaignDetailPage } from './features/meta/CampaignDetailPage' import { MetaCampaignBuilderPage } from './features/meta/CampaignBuilderPage' import { MetaAdsPage } from './features/meta/AdsPage' import { MetaAnalyticsPage } from './features/meta/AnalyticsPage' @@ -43,9 +44,9 @@ import { PrivacyPolicyPage } from './features/legal/PrivacyPolicyPage' function Placeholder() { return (
- Urmi workspace -

Module workspace

-

This product area is ready for the next iteration.

+ Coming soon +

This area is on the way

+

We’ll open it here when it’s ready. The rest of your workspace is unchanged.

) } @@ -78,6 +79,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 9b67039..c258d22 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -13,53 +13,53 @@ import { useCompliance } from '../features/compliance/compliance-context' import { COMPLIANCE_ONLY_PATHS } from '../features/compliance/compliance-context' const groups = [ - { label: 'Compliance', links: [ - { label: 'KYC', path: '/console/kyc', icon: ShieldCheck }, - { label: 'DLT', path: '/console/dlt', icon: FileCheck2 }, - { label: 'Message settings', path: '/console/message-settings', icon: SlidersHorizontal }, + { label: 'Get ready', links: [ + { label: 'Business KYC', path: '/console/kyc', icon: ShieldCheck }, + { label: 'DLT registration', path: '/console/dlt', icon: FileCheck2 }, + { label: 'Senders & templates', path: '/console/message-settings', icon: SlidersHorizontal }, ]}, - { label: 'Workspace', links: [ + { label: 'Home', links: [ { label: 'Overview', path: '/console', icon: LayoutDashboard }, { label: 'Activity', path: '/console/activity', icon: Activity }, ]}, - { label: 'Agent', links: [ - { label: 'UrmiAI', path: '/console/agent/urmiai', icon: Bot }, - { label: 'UrmIOT', path: '/console/agent/urmiot', icon: Cpu }, - { label: 'UrmiOTP', path: '/console/agent/urmiotp', icon: KeyRound }, - { label: 'Urmiport', path: '/console/agent/urmiport', icon: FileText }, + { label: 'Products', links: [ + { label: 'WhatsApp messages', path: '/console/agent/urmiai', icon: Bot }, + { label: 'Device alerts', path: '/console/agent/urmiot', icon: Cpu }, + { label: 'Verification codes', path: '/console/agent/urmiotp', icon: KeyRound }, + { label: 'Shared reports', path: '/console/agent/urmiport', icon: FileText }, ]}, - { label: 'Communicate', links: [ - { label: 'Messaging', path: '/console/messaging', icon: MessageSquareText }, + { label: 'Messages', links: [ + { label: 'Send a message', path: '/console/messaging', icon: MessageSquareText }, { label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle }, { label: 'Campaigns', path: '/console/campaigns', icon: Zap }, { label: 'Templates', path: '/console/templates', icon: BookOpen }, ]}, - { label: 'Meta', links: [ + { label: 'Advertising', links: [ { 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: 'Ad Accounts', path: '/console/meta/ad-accounts', icon: CircleDollarSign }, - { label: 'Campaigns', path: '/console/meta/campaigns', icon: Megaphone }, - { label: 'Ads & Creatives', path: '/console/meta/ads', icon: Image }, - { label: 'Analytics', path: '/console/meta/analytics', icon: BarChart3 }, - { label: 'Meta Settings', path: '/console/meta/settings', icon: Cog }, + { label: 'Ad accounts', path: '/console/meta/ad-accounts', icon: CircleDollarSign }, + { label: 'Ad campaigns', path: '/console/meta/campaigns', icon: Megaphone }, + { label: 'Ads', path: '/console/meta/ads', icon: Image }, + { label: 'Ad results', path: '/console/meta/analytics', icon: BarChart3 }, + { label: 'Ad settings', path: '/console/meta/settings', icon: Cog }, ]}, - { label: 'Numbers', links: [ - { label: 'Voice', path: '/console/voice', icon: Phone }, - { label: 'Buy Numbers', path: '/console/numbers/buy', icon: Search }, - { label: 'My Numbers', path: '/console/numbers', icon: Network, end: true }, + { label: 'Phone', links: [ + { label: 'Calls', path: '/console/voice', icon: Phone }, + { label: 'Get a number', path: '/console/numbers/buy', icon: Search }, + { label: 'Your numbers', path: '/console/numbers', icon: Network, end: true }, ]}, - { label: 'Develop', links: [ - { label: 'Webhooks & Sockets', path: '/console/webhooks', icon: Webhook }, - { label: 'API Keys', path: '/console/api-keys', icon: KeyRound }, - { label: 'API Explorer', path: '/console/api-explorer', icon: Code2 }, + { label: 'Connections', links: [ + { label: 'Delivery updates', path: '/console/webhooks', icon: Webhook }, + { label: 'Access keys', path: '/console/api-keys', icon: KeyRound }, + { label: 'Try a request', path: '/console/api-explorer', icon: Code2 }, ]}, - { label: 'Test & observe', links: [ - { label: 'Testing Center', path: '/console/testing', icon: TestTube2 }, - { label: 'Logs', path: '/console/logs', icon: Boxes }, - { label: 'Analytics', path: '/console/analytics', icon: Gauge }, + { label: 'Insights', links: [ + { label: 'Send a test', path: '/console/testing', icon: TestTube2 }, + { label: 'Activity history', path: '/console/logs', icon: Boxes }, + { label: 'Performance', path: '/console/analytics', icon: Gauge }, ]}, - { label: 'Manage', links: [ + { label: 'Account', links: [ { label: 'Billing', path: '/console/billing', icon: CreditCard }, { label: 'Team', path: '/console/team', icon: Users }, { label: 'Settings', path: '/console/settings', icon: Settings }, @@ -110,9 +110,9 @@ function AppShellContent() { {unlocked && (
-
Growth plan
+
Account balance
₹24,820.00 - Available balance + Available to send
)} @@ -124,8 +124,8 @@ function AppShellContent() {
- - Production + + Live