meta options

This commit is contained in:
debu-2 2026-10-08 07:20:18 +05:30
parent 4697dab339
commit 8726c931fa
21 changed files with 3028 additions and 3 deletions

View file

@ -46,7 +46,13 @@ WHATSAPP_ACCESS_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID=
META_APP_ID=
# Optional. Falls back to WHATSAPP_APP_SECRET when both belong to the same Meta app.
META_APP_SECRET=
# Facebook Login for Business configuration ID (Embedded Signup). Not a 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.
META_OAUTH_REDIRECT_URI=
WHATSAPP_BUSINESS_PHONE=919411257916
WHATSAPP_API_VERSION=v21.0

View file

@ -37,7 +37,11 @@ class Settings(BaseSettings):
whatsapp_phone_number_id: str = ""
whatsapp_business_account_id: str = ""
meta_app_id: str = ""
meta_app_secret: str = ""
meta_embedded_signup_config_id: str = ""
# Facebook Login for Business configuration. Do not reuse the WhatsApp Embedded Signup config.
meta_login_config_id: str = ""
meta_oauth_redirect_uri: str = ""
whatsapp_business_phone: str = "919411257916"
# Public HTTPS base for webhook callback (no trailing slash), e.g. https://urmiai.com
public_api_base_url: str = ""
@ -57,6 +61,26 @@ class Settings(BaseSettings):
return [self.frontend_origin]
@computed_field # type: ignore[prop-decorator]
@property
def meta_graph_app_secret(self) -> str:
return self.meta_app_secret or self.whatsapp_app_secret
@computed_field # type: ignore[prop-decorator]
@property
def meta_oauth_redirect_uri_resolved(self) -> str:
if self.meta_oauth_redirect_uri.strip():
return self.meta_oauth_redirect_uri.strip()
base = self.public_api_base_url.rstrip("/") if self.public_api_base_url else ""
if base:
return f"{base}/api/v1/meta/oauth/callback"
return "http://localhost:8000/api/v1/meta/oauth/callback"
@computed_field # type: ignore[prop-decorator]
@property
def meta_oauth_configured(self) -> bool:
return bool(self.meta_app_id and self.meta_graph_app_secret)
@computed_field # type: ignore[prop-decorator]
@property
def whatsapp_webhook_url(self) -> str:

View file

@ -26,6 +26,7 @@ from .routes_numbers import router as numbers_router
from .routes_urmiotp import router as urmiotp_router
from .routes_voice import router as voice_router
from .routes_whatsapp import router as whatsapp_router
from .routes_meta import router as meta_router
from .routes_agent_templates import router as agent_templates_router
from .routes_agent_messages import router as agent_messages_router
from .routes_urmiport import router as urmiport_router
@ -106,6 +107,7 @@ app.include_router(numbers_router)
app.include_router(integrations_router)
app.include_router(voice_router)
app.include_router(whatsapp_router)
app.include_router(meta_router)
app.include_router(agent_templates_router)
app.include_router(agent_messages_router)
app.include_router(urmiport_router)

1228
backend/app/meta_service.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -743,6 +743,49 @@ class UrmiportReport(Base):
group: Mapped[UrmiportReportGroup] = relationship(back_populates="reports")
class MetaConnection(Base):
"""One Meta Login connection per customer. The user token never leaves the server."""
__tablename__ = "meta_connections"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id"), unique=True, index=True)
meta_user_id: Mapped[str] = mapped_column(String(64), default="")
meta_user_name: Mapped[str] = mapped_column(String(255), default="")
granted_scopes: Mapped[str] = mapped_column(Text, default="")
access_token: Mapped[str] = mapped_column(Text, default="")
token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="disconnected")
selected_page_id: Mapped[str] = mapped_column(String(64), default="")
selected_page_name: Mapped[str] = mapped_column(String(255), default="")
selected_ad_account_id: Mapped[str] = mapped_column(String(64), default="")
selected_ad_account_name: Mapped[str] = mapped_column(String(255), default="")
last_error: Mapped[str] = mapped_column(Text, default="")
last_graph_code: Mapped[int | None] = mapped_column(nullable=True)
connected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MetaAuditEvent(Base):
"""Customer-visible record of Meta connect, publish, and status changes."""
__tablename__ = "meta_audit_events"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id"), index=True)
action: Mapped[str] = mapped_column(String(64))
asset_type: Mapped[str] = mapped_column(String(32), default="")
asset_label: Mapped[str] = mapped_column(String(255), default="")
asset_id: Mapped[str] = mapped_column(String(64), default="")
detail: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(16), default="success")
error_message: Mapped[str] = mapped_column(Text, default="")
graph_code: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class PlatformSetting(Base):
"""Runtime overrides for platform credentials (admin-editable)."""

281
backend/app/routes_meta.py Normal file
View file

@ -0,0 +1,281 @@
from urllib.parse import quote
from fastapi import APIRouter, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .auth import current_user
from .config import settings
from .database import get_db
from .meta_service import (
MetaApiError,
analytics,
authorization_url,
disconnect,
finish_oauth,
http_meta,
list_ad_accounts,
list_ads,
list_businesses,
list_campaigns,
list_pages,
overview,
page_detail,
public_connection,
publish_campaign,
load_connection,
recent_audit,
select_ad_account,
select_page,
set_ad_status,
set_campaign_status,
settings_view,
update_ad,
update_campaign,
)
from .models import UserAccount
router = APIRouter(prefix="/api/v1/meta", tags=["meta"])
class Confirm(BaseModel):
confirm: bool = False
class IdBody(Confirm):
id: str = Field(min_length=1, max_length=64)
class StatusBody(Confirm):
status: str
class CampaignEdit(Confirm):
name: str | None = None
daily_budget: float | None = Field(default=None, gt=0, le=10_000_000)
class CampaignPublish(Confirm):
name: str = Field(min_length=1, max_length=120)
objective: str
destination: str
page_id: str
daily_budget: float = Field(gt=0, le=10_000_000)
countries: list[str] = Field(min_length=1, max_length=5)
age_min: int = Field(default=18, ge=18, le=65)
age_max: int = Field(default=65, ge=18, le=65)
message: str = Field(min_length=1, max_length=2000)
headline: str = ""
link: str = ""
call_to_action: str = "LEARN_MORE"
activate: bool = False
class AdEdit(Confirm):
name: str | None = None
message: str | None = None
headline: str | None = None
call_to_action: str | None = None
link: str | None = None
def _confirmed(body: Confirm) -> None:
if not body.confirm:
raise http_meta(MetaApiError(422, "validation", "Confirm this action before Urmi sends it to Meta."))
def _redirect(query: str) -> RedirectResponse:
origin = settings.frontend_origin.rstrip("/")
return RedirectResponse(f"{origin}/console/meta/connections?{query}", status_code=302)
@router.get("/status")
def status(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
conn = load_connection(db, user)
visible = conn if conn and conn.status != "disconnected" else None
return public_connection(visible)
@router.get("/oauth/start")
def oauth_start(user: UserAccount = Depends(current_user)) -> dict:
try:
return {"authorization_url": authorization_url(user)}
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/oauth/callback")
async def oauth_callback(code: str = "", state: str = "", error: str = "", db: Session = Depends(get_db)):
if error or not code or not state:
return _redirect("error=Meta%20Login%20was%20cancelled")
try:
await finish_oauth(db, code, state)
except MetaApiError as exc:
return _redirect(f"error={quote(exc.message)}")
except Exception:
return _redirect("error=Meta%20Login%20could%20not%20be%20completed")
return _redirect("connected=1")
@router.post("/disconnect")
def disconnect_meta(body: Confirm, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
disconnect(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
return {"status": "disconnected"}
@router.get("/overview")
async def meta_overview(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
return await overview(db, user)
@router.get("/businesses")
async def businesses(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_businesses(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/pages")
async def pages(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_pages(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/pages/{page_id}")
async def page(page_id: str, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await page_detail(db, user, page_id)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/pages/select")
async def choose_page(body: IdBody, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
return await select_page(db, user, body.id)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/ad-accounts")
async def ad_accounts(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_ad_accounts(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/ad-accounts/select")
async def choose_ad_account(body: IdBody, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
return await select_ad_account(db, user, body.id)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/campaigns")
async def campaigns(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_campaigns(db, user)
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)
try:
return await publish_campaign(db, user, body.model_dump())
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/campaigns/{campaign_id}")
async def edit_campaign(
campaign_id: str,
body: CampaignEdit,
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_confirmed(body)
try:
return await update_campaign(db, user, campaign_id, body.name, body.daily_budget)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/campaigns/{campaign_id}/status")
async def campaign_status(
campaign_id: str,
body: StatusBody,
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_confirmed(body)
try:
return await set_campaign_status(db, user, campaign_id, body.status)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/ads")
async def ads(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_ads(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/ads/{ad_id}")
async def edit_ad(ad_id: str, body: AdEdit, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
return await update_ad(db, user, ad_id, body.model_dump())
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/ads/{ad_id}/status")
async def ad_status(
ad_id: str,
body: StatusBody,
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_confirmed(body)
try:
return await set_ad_status(db, user, ad_id, body.status)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/analytics")
async def meta_analytics(
date_preset: str = "last_30d",
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
try:
return await analytics(db, user, date_preset)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/audit")
def audit(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
return {"events": recent_audit(db, user)}
@router.get("/settings")
def meta_settings(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
return settings_view(db, user)

View file

@ -58,7 +58,10 @@ services:
WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-}
WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-}
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
META_EMBEDDED_SIGNUP_CONFIG_ID: ${META_EMBEDDED_SIGNUP_CONFIG_ID:-}
META_LOGIN_CONFIG_ID: ${META_LOGIN_CONFIG_ID:-}
META_OAUTH_REDIRECT_URI: ${META_OAUTH_REDIRECT_URI:-}
WHATSAPP_BUSINESS_PHONE: ${WHATSAPP_BUSINESS_PHONE:-919411257916}
WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0}
UPLOAD_DIR: uploads

View file

@ -19,6 +19,15 @@ import { TestingCenterPage } from './features/testing/TestingCenterPage'
import { LogsPage } from './features/logs/LogsPage'
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
import { MetaOverviewPage } from './features/meta/OverviewPage'
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 { MetaCampaignBuilderPage } from './features/meta/CampaignBuilderPage'
import { MetaAdsPage } from './features/meta/AdsPage'
import { MetaAnalyticsPage } from './features/meta/AnalyticsPage'
import { MetaSettingsPage } from './features/meta/SettingsPage'
import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage'
import { AgentTemplatesPage } from './features/agent/AgentTemplatesPage'
@ -62,6 +71,16 @@ export default function App() {
<Route path="templates" element={<TemplatesPage />} />
<Route path="messaging" element={<ComposeMessage />} />
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
<Route path="meta" element={<MetaOverviewPage />} />
<Route path="meta/connections" element={<MetaConnectionsPage />} />
<Route path="meta/pages" element={<MetaPagesPage />} />
<Route path="meta/pages/:pageId" element={<MetaPagesPage />} />
<Route path="meta/ad-accounts" element={<MetaAdAccountsPage />} />
<Route path="meta/campaigns" element={<MetaCampaignsPage />} />
<Route path="meta/campaigns/new" element={<MetaCampaignBuilderPage />} />
<Route path="meta/ads" element={<MetaAdsPage />} />
<Route path="meta/analytics" element={<MetaAnalyticsPage />} />
<Route path="meta/settings" element={<MetaSettingsPage />} />
<Route path="campaigns" element={<CampaignsPage />} />
<Route path="campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />

View file

@ -1,8 +1,8 @@
import { useState } from 'react'
import {
Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
Code2, CreditCard, Cpu, FileCheck2, FileText, Gauge, KeyRound, LayoutDashboard,
LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck,
Activity, BarChart3, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
Code2, Cog, CreditCard, Cpu, FileCheck2, FileText, Gauge, Globe, Image, KeyRound, LayoutDashboard,
Link2, LogOut, Megaphone, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck,
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
MessageCircle,
} from 'lucide-react'
@ -34,6 +34,16 @@ const groups = [
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap },
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
]},
{ label: 'Meta', links: [
{ label: 'Overview', path: '/console/meta', icon: LayoutDashboard, end: true },
{ label: 'Connections', 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: 'Numbers', links: [
{ label: 'Voice', path: '/console/voice', icon: Phone },
{ label: 'Buy Numbers', path: '/console/numbers/buy', icon: Search },

View file

@ -0,0 +1,52 @@
import { useState } from 'react'
import { metaApi, type MetaAdAccount, MetaRequestError } from './api'
import { MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, useMetaLoad } from './ui'
export function MetaAdAccountsPage() {
const list = useMetaLoad('ad-accounts', () => metaApi.adAccounts())
const campaigns = useMetaLoad(list.data?.selected_name ? 'campaign-count' : 'campaign-count-skip', () =>
list.data?.selected_name ? metaApi.campaigns() : Promise.resolve(null),
)
const [busy, setBusy] = useState('')
const [error, setError] = useState<MetaRequestError | null>(null)
async function select(account: MetaAdAccount) {
setBusy(account.id)
setError(null)
try {
await metaApi.selectAdAccount(account.id)
list.reload()
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('The ad account could not be selected'))
} finally {
setBusy('')
}
}
return (
<MetaScreen title="Ad accounts" subtitle="Ad accounts Meta returned for this authorization. Spend is the amount Meta reports for that account.">
{error && <MetaState error={error} />}
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.accounts.length === 0 ? (
<MetaEmpty title="No ad accounts returned" body="This login cannot see an ad account yet. Check the granted permissions in Meta Settings." to="/console/meta/settings" action="Open settings" />
) : (
<div className="meta-table-wrap panel">
<table className="meta-table">
<thead><tr><th>Account</th><th>Status</th><th>Currency</th><th>Campaigns</th><th>Amount spent</th><th></th></tr></thead>
<tbody>
{list.data?.accounts.map((account) => (
<tr key={account.id}>
<td><strong>{account.name}</strong><small>{account.business_name}</small></td>
<td><StatusPill value={account.status} /></td>
<td>{account.currency || '—'}</td>
<td>{account.selected ? (campaigns.data ? campaigns.data.campaigns.length : campaigns.loading ? '…' : '—') : '—'}</td>
<td>{account.amount_spent || '—'}</td>
<td><button type="button" disabled={busy === account.id || account.selected} onClick={() => select(account)}>{account.selected ? 'Selected' : 'Use account'}</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</MetaScreen>
)
}

View file

@ -0,0 +1,122 @@
import { useState } from 'react'
import { metaApi, type MetaAd, MetaRequestError } from './api'
import { ConfirmDialog, MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, useMetaLoad } from './ui'
const CTA_LABELS: Record<string, string> = {
LEARN_MORE: 'Learn more',
SHOP_NOW: 'Shop now',
SIGN_UP: 'Sign up',
CONTACT_US: 'Contact us',
WHATSAPP_MESSAGE: 'WhatsApp',
SEND_MESSAGE: 'Send message',
}
export function MetaAdsPage() {
const list = useMetaLoad('ads', () => metaApi.ads())
const [selected, setSelected] = useState<string>('')
const [draft, setDraft] = useState<MetaAd | null>(null)
const [pending, setPending] = useState<MetaAd | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<MetaRequestError | null>(null)
const ad = list.data?.ads.find((item) => item.id === selected) || list.data?.ads[0]
async function save() {
if (!draft) return
setBusy(true)
setError(null)
try {
await metaApi.editAd(draft.id, {
name: draft.name,
message: draft.message,
headline: draft.headline,
call_to_action: draft.call_to_action || undefined,
link: draft.link || undefined,
})
setDraft(null)
list.reload()
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta did not update the ad'))
} finally {
setBusy(false)
}
}
async function changeStatus() {
if (!pending) return
setBusy(true)
setError(null)
try {
await metaApi.adStatus(pending.id, pending.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE')
setPending(null)
list.reload()
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta did not change the ad'))
} finally {
setBusy(false)
}
}
return (
<MetaScreen title="Ads and creatives" subtitle={list.data?.account_name ? `Ads in ${list.data.account_name}.` : 'Ads in the selected ad account.'}>
{error && <MetaState error={error} />}
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.ads.length === 0 ? (
<MetaEmpty title="No ads returned" body="Meta did not return ads for the selected ad account." />
) : (
<div className="meta-split">
<div className="meta-table-wrap panel">
<table className="meta-table">
<thead><tr><th>Ad</th><th>Campaign</th><th>Status</th></tr></thead>
<tbody>
{list.data?.ads.map((item) => (
<tr key={item.id} className={ad?.id === item.id ? 'is-selected' : ''} onClick={() => setSelected(item.id)}>
<td><strong>{item.name}</strong></td>
<td>{item.campaign_name || '—'}</td>
<td><StatusPill value={item.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
{ad && (
<article className="panel meta-preview">
{ad.image_url ? <img src={ad.image_url} alt="" /> : <div className="meta-preview-empty">No image was returned for this creative.</div>}
<strong>{ad.headline || ad.name}</strong>
<p>{ad.message || 'No primary text was returned.'}</p>
<p className="meta-muted">{CTA_LABELS[ad.call_to_action] || ad.call_to_action || 'No call to action returned'}</p>
{list.data?.can_manage && (
<div className="welcome-actions">
<button className="secondary-button" type="button" onClick={() => { setDraft(ad); setError(null) }} disabled={!ad.editable}>Edit creative</button>
<button className="secondary-button" type="button" onClick={() => setPending(ad)}>{ad.status === 'ACTIVE' ? 'Pause' : 'Resume'}</button>
</div>
)}
{list.data?.can_manage && <p className="meta-muted">{ad.editable ? 'Name, text, link, and call to action can be replaced for this Page link ad. The image shown is the one Meta already stored; this screen does not upload new media.' : 'Urmi can change status for this ad. Its creative is not a Page link ad, so text and call to action stay read-only.'}</p>}
</article>
)}
</div>
)}
{draft && (
<ConfirmDialog title="Update this creative in Meta?" body="Urmi creates a replacement Page link creative and attaches it to the ad. Meta may reject unsupported media or call-to-action combinations." confirmLabel="Save creative" busy={busy} onCancel={() => setDraft(null)} onConfirm={save}>
<label className="meta-field">Name<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label>
<label className="meta-field">Primary text<textarea rows={3} value={draft.message} onChange={(event) => setDraft({ ...draft, message: event.target.value })} /></label>
<label className="meta-field">Headline<input value={draft.headline} onChange={(event) => setDraft({ ...draft, headline: event.target.value })} /></label>
<label className="meta-field">Link<input value={draft.link} onChange={(event) => setDraft({ ...draft, link: event.target.value })} /></label>
<label className="meta-field">Call to action
<select value={draft.call_to_action || 'LEARN_MORE'} onChange={(event) => setDraft({ ...draft, call_to_action: event.target.value })}>
{Object.entries(CTA_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
</ConfirmDialog>
)}
{pending && (
<ConfirmDialog
title={pending.status === 'ACTIVE' ? `Pause ${pending.name}?` : `Resume ${pending.name}?`}
body="Urmi will send this status change to Meta. The ad changes only if Meta accepts it."
confirmLabel={pending.status === 'ACTIVE' ? 'Pause ad' : 'Resume ad'}
busy={busy}
onCancel={() => setPending(null)}
onConfirm={changeStatus}
/>
)}
</MetaScreen>
)
}

View file

@ -0,0 +1,63 @@
import { useState } from 'react'
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { metaApi } from './api'
import { MetaEmpty, MetaLoading, MetaScreen, MetaState, formatCount, useMetaLoad } from './ui'
const RANGES = [
{ id: 'last_7d', label: 'Last 7 days' },
{ id: 'last_14d', label: 'Last 14 days' },
{ id: 'last_30d', label: 'Last 30 days' },
{ id: 'last_90d', label: 'Last 90 days' },
{ id: 'maximum', label: 'Maximum' },
]
export function MetaAnalyticsPage() {
const [range, setRange] = useState('last_30d')
const report = useMetaLoad(range, () => metaApi.analytics(range))
return (
<MetaScreen
title="Analytics"
subtitle={report.data?.account_name ? `Delivery for ${report.data.account_name}.` : 'Delivery for the selected ad account.'}
actions={
<select className="meta-select" value={range} onChange={(event) => setRange(event.target.value)}>
{RANGES.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select>
}
>
{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." />
) : (
<>
<div className="meta-metrics">
<Metric label="Spend" value={report.data.totals.spend || '—'} />
<Metric label="Impressions" value={formatCount(report.data.totals.impressions)} />
<Metric label="Reach" value={formatCount(report.data.totals.reach)} />
<Metric label="Clicks" value={formatCount(report.data.totals.clicks)} />
<Metric label="CTR" value={report.data.totals.ctr == null ? '—' : `${report.data.totals.ctr}%`} />
<Metric label="CPC" value={report.data.totals.cpc || '—'} />
</div>
<article className="panel">
<div className="panel-heading"><h2>Spend</h2><p>{report.data.currency}</p></div>
{report.data.series.length === 0 ? <MetaEmpty title="No daily breakdown" body="Meta returned a total without daily points for this range." /> : (
<div className="chart-wrap">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={report.data.series}>
<CartesianGrid stroke="#eef2f6" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#7c899d' }} />
<YAxis tick={{ fontSize: 10, fill: '#7c899d' }} />
<Tooltip />
<Area dataKey="spend" stroke="#1475ff" fill="#dcebff" name="Spend" />
</AreaChart>
</ResponsiveContainer>
</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

@ -0,0 +1,205 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { metaApi, MetaRequestError } from './api'
import { ConfirmDialog, MetaLoading, MetaScreen, MetaState, useMetaLoad } from './ui'
const STEPS = ['Objective', 'Destination', 'Page', 'Budget', 'Audience', 'Creative', 'Review']
const OBJECTIVES = [
{ id: 'OUTCOME_TRAFFIC', label: 'Traffic', detail: 'Send people to a website or WhatsApp.', enabled: true },
{ id: 'OUTCOME_ENGAGEMENT', label: 'Engagement', detail: 'Promote the connected Page.', enabled: true },
{ id: 'OUTCOME_AWARENESS', label: 'Awareness', detail: 'Reach people with the connected Page.', enabled: true },
{ id: 'OUTCOME_LEADS', label: 'Leads', detail: 'Needs a lead form on the Page. Not available on this connection.', enabled: false },
{ id: 'OUTCOME_SALES', label: 'Sales', detail: 'Needs a Meta pixel on the ad account. Not available on this connection.', enabled: false },
{ id: 'OUTCOME_APP_PROMOTION', label: 'App promotion', detail: 'Needs a linked app. Not available on this connection.', enabled: false },
]
export function MetaCampaignBuilderPage() {
const navigate = useNavigate()
const pages = useMetaLoad('builder-pages', () => metaApi.pages())
const accounts = useMetaLoad('builder-accounts', () => metaApi.adAccounts())
const status = useMetaLoad('builder-status', () => metaApi.status())
const [step, setStep] = useState(0)
const [objective, setObjective] = useState('OUTCOME_TRAFFIC')
const [destination, setDestination] = useState('website')
const [pageId, setPageId] = useState('')
const [budget, setBudget] = useState('')
const [country, setCountry] = useState('IN')
const [ageMin, setAgeMin] = useState(18)
const [ageMax, setAgeMax] = useState(65)
const [name, setName] = useState('')
const [message, setMessage] = useState('')
const [headline, setHeadline] = useState('')
const [link, setLink] = useState('')
const [cta, setCta] = useState('LEARN_MORE')
const [confirming, setConfirming] = useState<'PAUSED' | 'ACTIVE' | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<MetaRequestError | null>(null)
const selectedPage = pages.data?.pages.find((page) => page.id === pageId)
const account = accounts.data?.accounts.find((item) => item.selected)
const blocked = !status.data?.capabilities.ads_manage
const destinations = useMemo(() => [
{ id: 'website', label: 'Website', enabled: true, detail: 'Requires a public http or https link.' },
{ id: 'page', label: 'Facebook Page', enabled: true, detail: 'Uses the Page you select on the next step.' },
{ id: 'whatsapp', label: 'WhatsApp', enabled: objective !== 'OUTCOME_AWARENESS', detail: 'Meta accepts this only when the Page is allowed to send people to WhatsApp. A rejection is shown as a Meta error.' },
], [objective])
function next() {
setError(null)
if (step === 2 && !pageId) {
setError(new MetaRequestError('Select a Page Meta returned for this login.', 'validation'))
return
}
if (step === 3 && !(Number(budget) > 0)) {
setError(new MetaRequestError('Enter a daily budget greater than zero.', 'validation'))
return
}
if (step === 5 && (!name.trim() || !message.trim() || (destination === 'website' && !link.trim()))) {
setError(new MetaRequestError('Add the campaign name, message, and a link when the destination is a website.', 'validation'))
return
}
setStep((value) => Math.min(value + 1, STEPS.length - 1))
}
async function publish(activate: boolean) {
setBusy(true)
setError(null)
try {
await metaApi.publishCampaign({
name: name.trim(),
objective,
destination,
page_id: pageId,
daily_budget: Number(budget),
countries: [country],
age_min: ageMin,
age_max: ageMax,
message: message.trim(),
headline: headline.trim(),
link: link.trim(),
call_to_action: destination === 'whatsapp' ? 'WHATSAPP_MESSAGE' : cta,
activate,
})
navigate('/console/meta/campaigns')
} catch (err) {
setConfirming(null)
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta did not publish the campaign'))
} finally {
setBusy(false)
}
}
return (
<MetaScreen title="Campaign builder" subtitle="Each step is sent to Meta only when you confirm publish. Unavailable objectives stay disabled.">
<ol className="meta-steps">
{STEPS.map((label, index) => <li key={label} className={index === step ? 'is-current' : index < step ? 'is-done' : ''}>{label}</li>)}
</ol>
{blocked && status.data && <MetaState error={new MetaRequestError('Meta has not granted Ads management for this connection.', 'permission_denied', 'ads_management')} />}
{!account && accounts.data && <MetaState error={new MetaRequestError('Select an ad account before building a campaign.', 'no_ad_account')} />}
{error && <MetaState error={error} />}
{pages.loading || accounts.loading || status.loading ? <MetaLoading /> : (
<article className="panel meta-builder">
{step === 0 && (
<div className="meta-choice-grid">
{OBJECTIVES.map((item) => (
<button key={item.id} type="button" disabled={!item.enabled} className={objective === item.id ? 'is-selected' : ''} onClick={() => item.enabled && setObjective(item.id)}>
<strong>{item.label}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
)}
{step === 1 && (
<div className="meta-choice-grid">
{destinations.map((item) => (
<button key={item.id} type="button" disabled={!item.enabled} className={destination === item.id ? 'is-selected' : ''} onClick={() => item.enabled && setDestination(item.id)}>
<strong>{item.label}</strong>
<span>{item.detail}</span>
</button>
))}
</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> : (
<div className="meta-choice-grid">
{pages.data?.pages.map((page) => (
<button key={page.id} type="button" className={pageId === page.id ? 'is-selected' : ''} onClick={() => setPageId(page.id)}>
<strong>{page.name}</strong>
<span>{page.category || 'Page'}</span>
</button>
))}
</div>
))}
{step === 3 && (
<label className="meta-field">Daily budget ({account?.currency || 'account currency'})
<input value={budget} onChange={(event) => setBudget(event.target.value)} inputMode="decimal" />
</label>
)}
{step === 4 && (
<div className="meta-form-grid">
<label className="meta-field">Country
<select value={country} onChange={(event) => setCountry(event.target.value)}>
<option value="IN">India</option>
<option value="US">United States</option>
<option value="AE">United Arab Emirates</option>
<option value="GB">United Kingdom</option>
<option value="SG">Singapore</option>
</select>
</label>
<label className="meta-field">Minimum age<input type="number" min={18} max={65} value={ageMin} onChange={(event) => setAgeMin(Number(event.target.value))} /></label>
<label className="meta-field">Maximum age<input type="number" min={18} max={65} value={ageMax} onChange={(event) => setAgeMax(Number(event.target.value))} /></label>
</div>
)}
{step === 5 && (
<div className="meta-form-grid">
<label className="meta-field">Campaign name<input value={name} onChange={(event) => setName(event.target.value)} /></label>
<label className="meta-field">Primary text<textarea value={message} onChange={(event) => setMessage(event.target.value)} rows={4} /></label>
<label className="meta-field">Headline<input value={headline} onChange={(event) => setHeadline(event.target.value)} /></label>
{destination === 'website' && <label className="meta-field">Website link<input value={link} onChange={(event) => setLink(event.target.value)} placeholder="https://" /></label>}
{destination !== 'whatsapp' && (
<label className="meta-field">Call to action
<select value={cta} onChange={(event) => setCta(event.target.value)}>
<option value="LEARN_MORE">Learn more</option>
<option value="SHOP_NOW">Shop now</option>
<option value="SIGN_UP">Sign up</option>
<option value="CONTACT_US">Contact us</option>
<option value="SEND_MESSAGE">Send message</option>
</select>
</label>
)}
</div>
)}
{step === 6 && (
<div className="meta-kv">
<p><span>Ad account</span><strong>{account?.name || 'Not selected'}</strong></p>
<p><span>Objective</span><strong>{OBJECTIVES.find((item) => item.id === objective)?.label}</strong></p>
<p><span>Destination</span><strong>{destination}</strong></p>
<p><span>Page</span><strong>{selectedPage?.name || 'Not selected'}</strong></p>
<p><span>Daily budget</span><strong>{budget} {account?.currency}</strong></p>
<p><span>Audience</span><strong>{country}, ages {ageMin}–{ageMax}</strong></p>
<p><span>Creative</span><strong>{headline || name}</strong></p>
</div>
)}
<div className="welcome-actions meta-builder-nav">
{step > 0 && <button className="secondary-button" type="button" onClick={() => setStep((value) => value - 1)}>Back</button>}
{step < 6 && <button className="primary-button" type="button" onClick={next} disabled={blocked || !account}>Continue</button>}
{step === 6 && <button className="primary-button" type="button" onClick={() => setConfirming('PAUSED')} disabled={blocked || !account}>Review and create</button>}
<Link className="secondary-button" to="/console/meta/campaigns">Cancel</Link>
</div>
</article>
)}
{confirming && (
<ConfirmDialog
title="Create this campaign in Meta?"
body="Urmi will create the campaign, ad set, creative, and ad in the selected ad account. It stays paused unless you publish it live. If Meta rejects any step, the error is shown here and recorded in the audit trail. A paused campaign may still exist if a later step fails."
confirmLabel={confirming === 'ACTIVE' ? 'Publish live' : 'Create paused'}
busy={busy}
onCancel={() => setConfirming(null)}
onConfirm={() => publish(confirming === 'ACTIVE')}
>
<button className="secondary-button" type="button" onClick={() => setConfirming('ACTIVE')}>Publish live instead</button>
</ConfirmDialog>
)}
</MetaScreen>
)
}

View file

@ -0,0 +1,113 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { metaApi, type MetaCampaign, MetaRequestError } from './api'
import { ConfirmDialog, MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, formatCount, formatWhen, useMetaLoad } from './ui'
export function MetaCampaignsPage() {
const list = useMetaLoad('campaigns', () => metaApi.campaigns())
const [pending, setPending] = useState<MetaCampaign | null>(null)
const [editing, setEditing] = useState<MetaCampaign | null>(null)
const [name, setName] = useState('')
const [budget, setBudget] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<MetaRequestError | null>(null)
async function changeStatus() {
if (!pending) return
setBusy(true)
setError(null)
const next = pending.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE'
try {
await metaApi.campaignStatus(pending.id, next)
setPending(null)
list.reload()
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta did not change the campaign'))
} finally {
setBusy(false)
}
}
async function saveEdit() {
if (!editing) return
setBusy(true)
setError(null)
try {
await metaApi.editCampaign(editing.id, {
name: name.trim() || undefined,
daily_budget: budget ? Number(budget) : undefined,
})
setEditing(null)
list.reload()
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta did not update the campaign'))
} finally {
setBusy(false)
}
}
return (
<MetaScreen
title="Campaigns"
subtitle={list.data?.account_name ? `Campaigns in ${list.data.account_name}.` : 'Campaigns in the ad account selected for this workspace.'}
actions={list.data?.can_manage ? <Link className="primary-button" to="/console/meta/campaigns/new">Create campaign</Link> : undefined}
>
{error && <MetaState error={error} />}
{list.data?.insights_error && <p className="meta-banner is-error">{list.data.insights_error.message}</p>}
{list.loading ? <MetaLoading /> : list.error ? <MetaState error={list.error} /> : list.data?.campaigns.length === 0 ? (
<MetaEmpty title="No campaigns returned" body="Meta did not return campaigns for the selected ad account." />
) : (
<div className="meta-table-wrap panel">
<table className="meta-table">
<thead><tr><th>Campaign</th><th>Status</th><th>Objective</th><th>Spend</th><th>Impressions</th><th>Clicks</th><th>CTR</th><th></th></tr></thead>
<tbody>
{list.data?.campaigns.map((campaign) => (
<tr key={campaign.id}>
<td><strong>{campaign.name}</strong><small>{formatWhen(campaign.created_time)}</small></td>
<td><StatusPill value={campaign.status} /></td>
<td>{campaign.objective}</td>
<td>{campaign.spend || '—'}</td>
<td>{formatCount(campaign.impressions)}</td>
<td>{formatCount(campaign.clicks)}</td>
<td>{campaign.ctr == null ? '—' : `${campaign.ctr}%`}</td>
<td className="meta-actions">
{list.data?.can_manage && (
<>
<button type="button" onClick={() => { setEditing(campaign); setName(campaign.name); setBudget(''); setError(null) }}>Edit</button>
<button type="button" onClick={() => { setPending(campaign); setError(null) }}>{campaign.status === 'ACTIVE' ? 'Pause' : 'Resume'}</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!list.data?.can_manage && list.data && <p className="meta-muted">Creating, editing, pausing, and resuming stay off until Meta grants Ads management.</p>}
{pending && (
<ConfirmDialog
title={pending.status === 'ACTIVE' ? `Pause ${pending.name}?` : `Resume ${pending.name}?`}
body="Urmi will send this status change to Meta for the selected ad account. Delivery and spend change only if Meta accepts it."
confirmLabel={pending.status === 'ACTIVE' ? 'Pause campaign' : 'Resume campaign'}
busy={busy}
onCancel={() => setPending(null)}
onConfirm={changeStatus}
/>
)}
{editing && (
<ConfirmDialog
title={`Update ${editing.name}`}
body="This sends the new name, and a daily budget when you enter one, to Meta. Budget edits work when the campaign has a single ad set."
confirmLabel="Save to Meta"
busy={busy}
onCancel={() => setEditing(null)}
onConfirm={saveEdit}
>
<label className="meta-field">Name<input value={name} onChange={(event) => setName(event.target.value)} /></label>
<label className="meta-field">Daily budget<input value={budget} onChange={(event) => setBudget(event.target.value)} inputMode="decimal" placeholder={list.data?.currency || 'Amount'} /></label>
</ConfirmDialog>
)}
</MetaScreen>
)
}

View file

@ -0,0 +1,121 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { metaApi, type MetaConnection, MetaRequestError } from './api'
import { MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, formatWhen, useMetaLoad } from './ui'
export function MetaConnectionsPage() {
const [params] = useSearchParams()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<MetaRequestError | null>(null)
const status = useMetaLoad('status', () => metaApi.status())
const businesses = useMetaLoad(status.data?.capabilities.business ? 'businesses' : 'businesses-skip', () =>
status.data?.capabilities.business ? metaApi.businesses() : Promise.resolve({ businesses: [] }),
)
const pages = useMetaLoad(status.data?.capabilities.pages ? 'pages' : 'pages-skip', () =>
status.data?.capabilities.pages ? metaApi.pages() : Promise.resolve({ pages: [], selected_name: '' }),
)
const accounts = useMetaLoad(status.data?.capabilities.ads_read || status.data?.capabilities.business ? 'accounts' : 'accounts-skip', () =>
status.data?.capabilities.ads_read || status.data?.capabilities.business ? metaApi.adAccounts() : Promise.resolve({ accounts: [], selected_name: '' }),
)
async function connect() {
setBusy(true)
setError(null)
try {
const result = await metaApi.start()
window.location.assign(result.authorization_url)
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta Login could not be started'))
setBusy(false)
}
}
const connection = status.data
return (
<MetaScreen
title="Connections"
subtitle="Authorization for this workspace only. Urmi stores the Meta token on the server and does not show it here."
actions={<button className="primary-button" type="button" onClick={connect} disabled={busy}>{connection?.connected ? 'Reconnect' : 'Connect Meta'}</button>}
>
{params.get('connected') && <p className="meta-banner">Meta authorization completed for this workspace.</p>}
{params.get('error') && <p className="meta-banner is-error">{params.get('error')}</p>}
{error && <MetaState error={error} />}
{status.loading || !connection ? <MetaLoading /> : (
<div className="meta-split">
<article className="panel">
<div className="panel-heading"><h2>Connected user</h2><StatusPill value={connection.status} /></div>
{connection.connected || connection.status === 'expired' ? (
<div className="meta-kv">
<p><span>Name</span><strong>{connection.user_name || 'Meta user'}</strong></p>
<p><span>Connected</span><strong>{formatWhen(connection.connected_at)}</strong></p>
<p><span>Access until</span><strong>{connection.expires_at ? formatWhen(connection.expires_at) : 'Until Meta revokes it'}</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>
</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." />}
</article>
<article className="panel">
<div className="panel-heading"><h2>Granted permissions</h2></div>
{connection.scopes.length === 0 ? <MetaEmpty title="No permissions granted" body="Permissions appear here after a successful login. A screen is not available just because it is in the menu." /> : (
<ul className="meta-list">
{connection.scopes.map((scope) => <li key={scope.scope}><strong>{scope.label}</strong><span>Granted</span></li>)}
</ul>
)}
</article>
</div>
)}
<div className="meta-card-grid">
<Section
title="Business portfolio"
loading={businesses.loading}
error={gatedError(connection, 'business', 'business_management', 'Business portfolio', businesses.error)}
empty="Meta did not return a business portfolio for this login."
>
{businesses.data?.businesses.map((item) => <li key={item.name}><strong>{item.name}</strong></li>)}
</Section>
<Section
title="Pages"
loading={pages.loading}
error={gatedError(connection, 'pages', 'pages_show_list', 'Page list', pages.error)}
empty="Meta did not return any Pages for this login."
>
{pages.data?.pages.map((page) => <li key={page.id}><strong>{page.name}</strong><span>{page.category}</span></li>)}
</Section>
<Section
title="Ad accounts"
loading={accounts.loading}
error={gatedError(connection, 'ads_read', 'ads_read', 'Ads reporting', accounts.error, connection?.capabilities.business)}
empty="Meta did not return any ad accounts for this login."
>
{accounts.data?.accounts.map((account) => <li key={account.id}><strong>{account.name}</strong><span>{account.currency} · {account.status}</span></li>)}
</Section>
</div>
</MetaScreen>
)
}
function gatedError(
connection: MetaConnection | null | undefined,
capability: keyof MetaConnection['capabilities'],
permission: string,
label: string,
error: MetaRequestError | null,
alsoAllowed = false,
) {
if (!connection) return error
if (!connection.connected) return new MetaRequestError('Connect Meta before using this screen.', 'not_connected')
if (!connection.capabilities[capability] && !alsoAllowed) {
return new MetaRequestError(`Meta has not granted ${label} for this connection.`, 'permission_denied', permission)
}
return error
}
function Section({ title, loading, error, empty, children }: { title: string; loading: boolean; error: MetaRequestError | null; empty: string; children: React.ReactNode }) {
const items = Array.isArray(children) ? children.filter(Boolean) : children ? [children] : []
return (
<article className="panel">
<div className="panel-heading"><h2>{title}</h2></div>
{loading ? <MetaLoading /> : error ? <MetaState error={error} /> : items.length === 0 ? <p className="meta-muted">{empty}</p> : <ul className="meta-list">{children}</ul>}
</article>
)
}

View file

@ -0,0 +1,98 @@
import { Link } from 'react-router-dom'
import { metaApi } from './api'
import { MetaEmpty, MetaLoading, MetaScreen, StatusPill, auditLabel, formatCount, formatWhen, useMetaLoad } from './ui'
export function MetaOverviewPage() {
const { data, loading } = useMetaLoad('overview', () => metaApi.overview())
if (loading || !data) return <MetaScreen title="Overview" subtitle="Connected Meta assets for this workspace."><MetaLoading /></MetaScreen>
const connection = data.connection
return (
<MetaScreen
title="Overview"
subtitle="Pages, ad accounts, campaigns, and delivery that this Meta authorization can actually see."
actions={!connection.connected ? <Link className="primary-button" to="/console/meta/connections">Connect Meta</Link> : undefined}
>
<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.">
{data.businesses.items.map((item) => <p key={item.name}><strong>{item.name}</strong><StatusPill value={connection.connected ? 'Connected' : connection.status} /></p>)}
</AssetCard>
<AssetCard title="Facebook Pages" state={data.pages.state} message={data.pages.message} empty="No Pages are available to this login.">
{data.pages.items.slice(0, 3).map((page) => <p key={page.id}><strong>{page.name}</strong>{page.selected && <span>Selected</span>}</p>)}
<Link to="/console/meta/pages">View Pages</Link>
</AssetCard>
<AssetCard title="Ad accounts" state={data.ad_accounts.state} message={data.ad_accounts.message} empty="No ad accounts are available to this login.">
{data.ad_accounts.items.slice(0, 3).map((account) => <p key={account.id}><strong>{account.name}</strong><span>{account.currency}</span></p>)}
<Link to="/console/meta/ad-accounts">View accounts</Link>
</AssetCard>
<AssetCard title="WhatsApp Business" state={data.whatsapp.length ? 'ok' : 'empty'} message="" empty="No WhatsApp Business account is connected in this workspace.">
{data.whatsapp.map((account) => <p key={account.name}><strong>{account.name}</strong><span>{account.phone || account.status}</span></p>)}
<Link to="/console/whatsapp">Open WhatsApp</Link>
</AssetCard>
</div>
<div className="meta-metrics">
<Metric label="Ad spend" value={data.performance.report?.totals.spend || '—'} note={performanceNote(data.performance.state, data.performance.message)} />
<Metric label="Impressions" value={formatCount(data.performance.report?.totals.impressions)} note="Last 30 days" />
<Metric label="Reach" value={formatCount(data.performance.report?.totals.reach)} note={data.performance.report?.account_name || 'No ad account selected'} />
<Metric label="Clicks" value={formatCount(data.performance.report?.totals.clicks)} note="From the selected ad account" />
<Metric label="CTR" value={data.performance.report?.totals.ctr == null ? '—' : `${data.performance.report.totals.ctr}%`} note="Click-through rate" />
<Metric label="CPC" value={data.performance.report?.totals.cpc || '—'} note="Cost per click" />
</div>
<div className="meta-split">
<article className="panel">
<div className="panel-heading"><h2>Recent Meta activity</h2></div>
{data.activity.length === 0 ? <MetaEmpty title="No Meta actions yet" body="Connect, publish, pause, and disconnect events for this workspace appear here." /> : (
<ul className="meta-activity">
{data.activity.map((event) => (
<li key={`${event.action}-${event.created_at}`}>
<strong>{auditLabel(event.action)}</strong>
<span>{event.asset_label || event.detail}</span>
<em>{formatWhen(event.created_at)} · {event.status}</em>
</li>
))}
</ul>
)}
</article>
<article className="panel">
<div className="panel-heading"><h2>Connection</h2></div>
{connection.connected ? (
<div className="meta-kv">
<p><span>Signed in as</span><strong>{connection.user_name || 'Meta user'}</strong></p>
<p><span>Page</span><strong>{connection.selected_page_name || 'Not selected'}</strong></p>
<p><span>Ad account</span><strong>{connection.selected_ad_account_name || 'Not selected'}</strong></p>
<p><span>Permissions</span><strong>{connection.scopes.map((scope) => scope.label).join(', ') || 'None granted'}</strong></p>
</div>
) : (
<MetaEmpty title="Nothing is connected" body={connection.last_error || 'Authorize Meta to load Pages, the business portfolio, and ad accounts for this workspace.'} to="/console/meta/connections" action="Connect Meta" />
)}
</article>
</div>
</MetaScreen>
)
}
function performanceNote(state: string, message: string) {
if (state === 'ok') return 'Last 30 days'
if (state === 'not_connected') return 'Connect Meta to load delivery'
return message || 'Delivery is not available'
}
function AssetCard({ title, state, message, empty, children }: { title: string; state: string; message: string; empty: string; children: React.ReactNode }) {
return (
<article className="panel meta-asset-card">
<div className="panel-heading"><h2>{title}</h2></div>
{state === 'ok' ? children : <p className="meta-muted">{state === 'empty' ? empty : message || empty}</p>}
</article>
)
}
function Metric({ label, value, note }: { label: string; value: string; note: string }) {
return (
<article className="stat-card meta-metric">
<span>{label}</span>
<strong>{value}</strong>
<p>{note}</p>
</article>
)
}

View file

@ -0,0 +1,85 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { metaApi, type MetaPage, MetaRequestError } from './api'
import { MetaEmpty, MetaLoading, MetaScreen, MetaState, formatCount, formatWhen, useMetaLoad } from './ui'
export function MetaPagesPage() {
const { pageId } = useParams()
const list = useMetaLoad('pages', () => metaApi.pages())
const detail = useMetaLoad(pageId || 'none', () => pageId ? metaApi.page(pageId) : Promise.resolve(null))
const [busy, setBusy] = useState('')
const [error, setError] = useState<MetaRequestError | null>(null)
async function select(page: MetaPage) {
setBusy(page.id)
setError(null)
try {
await metaApi.selectPage(page.id)
list.reload()
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('The Page could not be selected'))
} finally {
setBusy('')
}
}
return (
<MetaScreen title="Facebook Pages" subtitle="Pages this Meta login can administer. Engagement is shown only when that permission was granted.">
{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" />
) : (
<div className="meta-table-wrap panel">
<table className="meta-table">
<thead><tr><th>Page</th><th>Category</th><th>Followers</th><th></th></tr></thead>
<tbody>
{list.data?.pages.map((page) => (
<tr key={page.id}>
<td><strong>{page.name}</strong><small>{page.username ? `@${page.username}` : page.link}</small></td>
<td>{page.category || '—'}</td>
<td>{formatCount(page.fan_count)}</td>
<td className="meta-actions">
<Link to={`/console/meta/pages/${page.id}`}>Open</Link>
<button type="button" disabled={busy === page.id || page.selected} onClick={() => select(page)}>{page.selected ? 'Connected' : 'Use this Page'}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{error && <MetaState error={error} />}
{pageId && (detail.loading ? <MetaLoading /> : detail.error ? <MetaState error={detail.error} /> : detail.data && (
<article className="panel meta-detail">
<div className="panel-heading">
<div>
<h2>{detail.data.page.name}</h2>
<p>{detail.data.page.category}</p>
</div>
</div>
<div className="meta-kv">
<p><span>Username</span><strong>{detail.data.page.username ? `@${detail.data.page.username}` : '—'}</strong></p>
<p><span>Followers</span><strong>{formatCount(detail.data.page.fan_count)}</strong></p>
<p><span>Tasks Meta returned</span><strong>{detail.data.page.tasks.join(', ') || '—'}</strong></p>
</div>
<p className="meta-muted">{detail.data.engagement.note}</p>
{detail.data.engagement.error ? <MetaState error={new MetaRequestError(detail.data.engagement.error.message, detail.data.engagement.error.code, detail.data.engagement.error.permission)} /> : detail.data.posts.length === 0 ? (
<MetaEmpty title="No recent posts" body="Meta returned no posts for this Page." />
) : (
<ul className="meta-posts">
{detail.data.posts.map((post) => (
<li key={post.created_time || post.permalink_url}>
{post.picture_url && <img src={post.picture_url} alt="" />}
<div>
<p>{post.message || 'Post without text'}</p>
<small>{formatWhen(post.created_time)} · {formatCount(post.reactions)} reactions · {formatCount(post.comments)} comments</small>
{post.permalink_url && <a href={post.permalink_url} target="_blank" rel="noreferrer">View on Facebook</a>}
</div>
</li>
))}
</ul>
)}
</article>
))}
</MetaScreen>
)
}

View file

@ -0,0 +1,128 @@
import { useState } from 'react'
import { metaApi, MetaRequestError } from './api'
import { ConfirmDialog, MetaEmpty, MetaLoading, MetaScreen, MetaState, StatusPill, auditLabel, formatWhen, useMetaLoad } from './ui'
export function MetaSettingsPage() {
const view = useMetaLoad('settings', () => metaApi.settings())
const [debug, setDebug] = useState(false)
const [confirm, setConfirm] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<MetaRequestError | null>(null)
async function reconnect() {
setBusy(true)
setError(null)
try {
const result = await metaApi.start()
window.location.assign(result.authorization_url)
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta Login could not be started'))
setBusy(false)
}
}
async function disconnect() {
setBusy(true)
setError(null)
try {
await metaApi.disconnect()
setConfirm(false)
view.reload()
} catch (err) {
setError(err instanceof MetaRequestError ? err : new MetaRequestError('Meta could not be disconnected'))
} finally {
setBusy(false)
}
}
const connection = view.data?.connection
return (
<MetaScreen
title="Meta settings"
subtitle="Permissions, connection health, and disconnect for this workspace."
actions={
<>
<button className="secondary-button" type="button" onClick={() => setDebug((value) => !value)}>Troubleshooting</button>
<button className="secondary-button" type="button" onClick={reconnect} disabled={busy}>Reconnect</button>
<button className="primary-button" type="button" onClick={() => setConfirm(true)} disabled={!connection?.connected && connection?.status !== 'expired'}>Disconnect</button>
</>
}
>
{error && <MetaState error={error} />}
{view.loading || !view.data || !connection ? <MetaLoading /> : (
<>
<div className="meta-split">
<article className="panel">
<div className="panel-heading"><h2>Connection health</h2><StatusPill value={connection.status} /></div>
<div className="meta-kv">
<p><span>User</span><strong>{connection.user_name || 'Not connected'}</strong></p>
<p><span>Connected</span><strong>{formatWhen(connection.connected_at)}</strong></p>
<p><span>Expires</span><strong>{connection.expires_at ? formatWhen(connection.expires_at) : '—'}</strong></p>
<p><span>Page</span><strong>{connection.selected_page_name || 'Not selected'}</strong></p>
<p><span>Ad account</span><strong>{connection.selected_ad_account_name || 'Not selected'}</strong></p>
<p><span>Last error</span><strong>{connection.last_error || 'None'}</strong></p>
</div>
</article>
<article className="panel">
<div className="panel-heading"><h2>Permissions</h2></div>
{connection.scopes.length === 0 ? <MetaEmpty title="No granted permissions" body="Reconnect Meta and accept the permissions this workspace needs." /> : (
<ul className="meta-list">{connection.scopes.map((scope) => <li key={scope.scope}><strong>{scope.label}</strong><span>Granted</span></li>)}</ul>
)}
</article>
</div>
<div className="meta-split">
<article className="panel">
<div className="panel-heading"><h2>Webhooks</h2></div>
<p className="meta-muted">Urmi does not subscribe a separate ads webhook. WhatsApp message webhooks stay on the WhatsApp connection.</p>
{view.data.whatsapp.length === 0 ? <MetaEmpty title="No WhatsApp connection" body="WhatsApp webhook health appears when a WhatsApp Business account is connected." /> : (
<ul className="meta-list">
{view.data.whatsapp.map((account) => <li key={account.name}><strong>{account.name}</strong><StatusPill value={account.webhooks} /></li>)}
</ul>
)}
</article>
<article className="panel">
<div className="panel-heading"><h2>Audit trail</h2></div>
{view.data.audit.length === 0 ? <MetaEmpty title="No Meta actions yet" body="Publishing, status changes, and disconnects for this workspace are recorded here." /> : (
<ul className="meta-activity">
{view.data.audit.map((event) => (
<li key={`${event.action}-${event.created_at}`}>
<strong>{auditLabel(event.action)}{event.asset_label ? ` · ${event.asset_label}` : ''}</strong>
<span>{event.status === 'failed' ? event.error_message : event.detail}</span>
<em>{formatWhen(event.created_at)}</em>
</li>
))}
</ul>
)}
</article>
</div>
{debug && (
<aside className="panel meta-debug">
<div className="panel-heading"><h2>Troubleshooting</h2><p>Identifiers are masked. Tokens are not shown.</p></div>
<div className="meta-kv">
<p><span>App configured</span><strong>{view.data.debug.app_configured ? 'Yes' : 'No'}</strong></p>
<p><span>Redirect configured</span><strong>{view.data.debug.redirect_configured ? 'Yes' : 'No'}</strong></p>
<p><span>Login config</span><strong>{view.data.debug.login_config_set ? 'Set' : 'Not set'}</strong></p>
<p><span>Granted scopes</span><strong>{view.data.debug.granted_scopes.join(', ') || 'None'}</strong></p>
<p><span>Last Graph code</span><strong>{view.data.debug.last_graph_code ?? '—'}</strong></p>
<p><span>Last error</span><strong>{view.data.debug.last_error || 'None'}</strong></p>
<p><span>User reference</span><strong>{view.data.debug.meta_user_ref || '—'}</strong></p>
<p><span>Page reference</span><strong>{view.data.debug.page_ref || '—'}</strong></p>
<p><span>Ad account reference</span><strong>{view.data.debug.ad_account_ref || '—'}</strong></p>
</div>
</aside>
)}
</>
)}
{confirm && (
<ConfirmDialog
title="Disconnect Meta?"
body="Urmi deletes the stored token for this workspace and stops loading Pages, ad accounts, and campaigns. WhatsApp connections are left as they are. You can authorize Meta again later."
confirmLabel="Disconnect Meta"
busy={busy}
onCancel={() => setConfirm(false)}
onConfirm={disconnect}
/>
)}
</MetaScreen>
)
}

View file

@ -0,0 +1,200 @@
import { API_BASE_URL } from '../../lib/apiBase'
export class MetaRequestError extends Error {
code: string
permission?: string
constructor(message: string, code = 'graph_error', permission?: string) {
super(message)
this.code = code
this.permission = permission
}
}
export type MetaScope = { scope: string; label: string }
export type MetaConnection = {
configured: boolean
connected: boolean
status: string
user_name: string
connected_at: string | null
expires_at: string | null
scopes: MetaScope[]
capabilities: {
profile: boolean
pages: boolean
page_engagement: boolean
business: boolean
ads_read: boolean
ads_manage: boolean
}
selected_page_name: string
selected_ad_account_name: string
last_error: string
}
export type MetaPage = {
id: string
name: string
username: string
category: string
link: string
fan_count: number | null
tasks: string[]
selected: boolean
}
export type MetaPost = {
message: string
created_time: string | null
permalink_url: string
picture_url: string
reactions: number
comments: number
}
export type MetaAdAccount = {
id: string
name: string
business_name: string
currency: string
status: string
amount_spent: string | null
selected: boolean
ref: string
}
export type MetaCampaign = {
id: string
name: string
status: string
objective: string
created_time: string | null
spend: string | null
impressions: number | null
clicks: number | null
ctr: number | null
reach: number | null
}
export type MetaAd = {
id: string
name: string
campaign_name: string
status: string
headline: string
message: string
call_to_action: string
image_url: string
link: string
editable: boolean
}
export type MetaAudit = {
action: string
asset_type: string
asset_label: string
detail: string
status: string
error_message: string
created_at: string
}
async function metaRequest<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}/api/v1/meta${path}`, {
...init,
credentials: 'include',
headers: {
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
const body = await response.json().catch(() => null)
if (!response.ok) {
const detail = body?.detail
if (detail && typeof detail === 'object') {
throw new MetaRequestError(detail.message || 'Meta request failed', detail.code || 'graph_error', detail.permission)
}
throw new MetaRequestError(typeof detail === 'string' ? detail : 'Meta request failed', 'graph_error')
}
return body as T
}
export const metaApi = {
status: () => metaRequest<MetaConnection>('/status'),
start: () => metaRequest<{ authorization_url: string }>('/oauth/start'),
disconnect: () => metaRequest('/disconnect', { method: 'POST', body: JSON.stringify({ confirm: true }) }),
overview: () => metaRequest<MetaOverview>('/overview'),
businesses: () => metaRequest<{ businesses: { name: string }[] }>('/businesses'),
pages: () => metaRequest<{ pages: MetaPage[]; selected_name: string }>('/pages'),
page: (id: string) => metaRequest<{ page: MetaPage; posts: MetaPost[]; engagement: { available: boolean; error: { code: string; message: string; permission?: string } | null; note: string } }>(`/pages/${id}`),
selectPage: (id: string) => metaRequest('/pages/select', { method: 'POST', body: JSON.stringify({ id, confirm: true }) }),
adAccounts: () => metaRequest<{ accounts: MetaAdAccount[]; selected_name: string }>('/ad-accounts'),
selectAdAccount: (id: string) => metaRequest('/ad-accounts/select', { method: 'POST', body: JSON.stringify({ id, confirm: true }) }),
campaigns: () => metaRequest<{ account_name: string; currency: string; campaigns: MetaCampaign[]; can_manage: boolean; insights_error: { code: string; message: string } | null }>('/campaigns'),
publishCampaign: (payload: Record<string, unknown>) => metaRequest<{ name: string; status: string }>('/campaigns', { method: 'POST', body: JSON.stringify({ ...payload, confirm: true }) }),
editCampaign: (id: string, payload: { name?: string; daily_budget?: number }) =>
metaRequest(`/campaigns/${id}`, { method: 'POST', body: JSON.stringify({ ...payload, confirm: true }) }),
campaignStatus: (id: string, status: 'ACTIVE' | 'PAUSED') =>
metaRequest(`/campaigns/${id}/status`, { method: 'POST', body: JSON.stringify({ status, confirm: true }) }),
ads: () => metaRequest<{ account_name: string; ads: MetaAd[]; can_manage: boolean }>('/ads'),
editAd: (id: string, payload: Record<string, unknown>) =>
metaRequest(`/ads/${id}`, { method: 'POST', body: JSON.stringify({ ...payload, confirm: true }) }),
adStatus: (id: string, status: 'ACTIVE' | 'PAUSED') =>
metaRequest(`/ads/${id}/status`, { method: 'POST', body: JSON.stringify({ status, confirm: true }) }),
analytics: (datePreset: string) => metaRequest<MetaAnalytics>(`/analytics?date_preset=${datePreset}`),
settings: () => metaRequest<MetaSettings>('/settings'),
}
export type MetaSection<T> = {
state: string
message: string
permission?: string
items: T[]
}
export type MetaOverview = {
connection: MetaConnection
whatsapp: { name: string; phone: string; status: string; webhooks: string; templates: number }[]
businesses: MetaSection<{ name: string }>
pages: MetaSection<MetaPage>
ad_accounts: MetaSection<MetaAdAccount>
performance: { state: string; message: string; permission?: string; report: MetaAnalytics | null }
activity: MetaAudit[]
}
export type MetaAnalytics = {
account_name: string
currency: string
date_preset: string
has_delivery: boolean
totals: {
spend: string | null
impressions: number | null
reach: number | null
clicks: number | null
ctr: number | null
cpc: string | null
cpm: string | null
}
series: { date: string; spend: number; impressions: number; clicks: number }[]
}
export type MetaSettings = {
connection: MetaConnection
whatsapp: { name: string; status: string; webhooks: string }[]
ads_webhooks: string
audit: MetaAudit[]
debug: {
app_configured: boolean
redirect_configured: boolean
login_config_set: boolean
granted_scopes: string[]
last_error: string
last_graph_code: number | null
meta_user_ref: string
page_ref: string
ad_account_ref: string
}
}

View file

@ -0,0 +1,164 @@
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { MetaRequestError } from './api'
export function useMetaLoad<T>(key: string, load: () => Promise<T>) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<MetaRequestError | null>(null)
const [loading, setLoading] = useState(true)
const [reloadToken, setReloadToken] = useState(0)
const loadRef = useRef(load)
loadRef.current = load
useEffect(() => {
let active = true
setLoading(true)
setError(null)
loadRef.current()
.then((value) => {
if (!active) return
setData(value)
setLoading(false)
})
.catch((err: unknown) => {
if (!active) return
setData(null)
setError(err instanceof MetaRequestError ? err : new MetaRequestError(err instanceof Error ? err.message : 'Meta request failed'))
setLoading(false)
})
return () => {
active = false
}
}, [key, reloadToken])
return { data, error, loading, reload: () => setReloadToken((value) => value + 1) }
}
export function MetaScreen({
title,
subtitle,
actions,
children,
}: {
title: string
subtitle: string
actions?: React.ReactNode
children: React.ReactNode
}) {
return (
<section className="meta-page">
<div className="welcome-row">
<div>
<span className="eyebrow">Meta</span>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
{actions && <div className="welcome-actions">{actions}</div>}
</div>
{children}
</section>
)
}
export function MetaState({ error }: { error: MetaRequestError }) {
const expired = error.code === 'token_expired'
const denied = error.code === 'permission_denied'
const disconnected = error.code === 'not_connected' || error.code === 'not_configured'
const needsAccount = error.code === 'no_ad_account' || error.code === 'no_page'
return (
<div className={`meta-state ${denied ? 'is-denied' : expired ? 'is-expired' : ''}`}>
<strong>
{error.code === 'not_configured' && 'Meta Login is not configured'}
{error.code === 'not_connected' && 'Meta is not connected'}
{expired && 'Meta access has expired'}
{denied && 'This connection does not include that permission'}
{needsAccount && 'Choose an asset first'}
{error.code === 'feature_unavailable' && 'This Meta feature is not available'}
{!['not_configured', 'not_connected', 'token_expired', 'permission_denied', 'no_ad_account', 'no_page', 'feature_unavailable'].includes(error.code) && 'Meta could not complete this request'}
</strong>
<p>{error.message}</p>
{disconnected && <Link to="/console/meta/connections">Open connections</Link>}
{expired && <Link to="/console/meta/settings">Reconnect Meta</Link>}
{needsAccount && <Link to={error.code === 'no_page' ? '/console/meta/pages' : '/console/meta/ad-accounts'}>Choose an asset</Link>}
</div>
)
}
export function MetaLoading() {
return <div className="meta-state">Loading this workspace’s Meta data…</div>
}
export function MetaEmpty({ title, body, to, action }: { title: string; body: string; to?: string; action?: string }) {
return (
<div className="meta-state">
<strong>{title}</strong>
<p>{body}</p>
{to && action && <Link to={to}>{action}</Link>}
</div>
)
}
export function ConfirmDialog({
title,
body,
confirmLabel,
busy,
children,
onCancel,
onConfirm,
}: {
title: string
body: string
confirmLabel: string
busy?: boolean
children?: React.ReactNode
onCancel: () => void
onConfirm: () => void
}) {
return (
<div className="meta-dialog-backdrop" role="presentation" onClick={onCancel}>
<div className="meta-dialog" role="dialog" aria-modal="true" aria-labelledby="meta-confirm-title" onClick={(event) => event.stopPropagation()}>
<h2 id="meta-confirm-title">{title}</h2>
<p>{body}</p>
{children}
<div className="welcome-actions">
<button className="secondary-button" type="button" onClick={onCancel} disabled={busy}>Cancel</button>
<button className="primary-button" type="button" onClick={onConfirm} disabled={busy}>{busy ? 'Sending…' : confirmLabel}</button>
</div>
</div>
</div>
)
}
export function formatWhen(value: string | null | undefined) {
if (!value) return '—'
return new Date(value).toLocaleString('en-IN', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
export function formatCount(value: number | null | undefined) {
if (value == null) return '—'
return value.toLocaleString('en-IN')
}
export function auditLabel(action: string) {
const labels: Record<string, string> = {
'connection.authorized': 'Meta connected',
'connection.disconnected': 'Meta disconnected',
'page.selected': 'Page selected',
'ad_account.selected': 'Ad account selected',
'campaign.publish': 'Campaign published',
'campaign.pause': 'Campaign paused',
'campaign.resume': 'Campaign resumed',
'campaign.edit': 'Campaign updated',
'ad.pause': 'Ad paused',
'ad.resume': 'Ad resumed',
'ad.edit': 'Ad updated',
}
return labels[action] || action
}
export function StatusPill({ value }: { value: string }) {
const live = value === 'ACTIVE' || value === 'Active' || value === 'connected'
const paused = value === 'PAUSED' || value === 'Disabled' || value === 'expired'
return <span className={`meta-pill ${live ? 'is-live' : paused ? 'is-paused' : ''}`}>{value || 'Unknown'}</span>
}

View file

@ -1908,3 +1908,61 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-biz-table-head { display: none; }
.wa-biz-table-row, .wa-biz-numbers li { grid-template-columns: 1fr; }
}
.meta-page { max-width: 1500px; margin: 0 auto; display: grid; gap: 16px; }
.meta-card-grid, .meta-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; }
.meta-split { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, .8fr); gap: 16px; }
.meta-asset-card p, .meta-list { display: flex; justify-content: space-between; gap: 10px; align-items: center; margin: 8px 0 0; }
.meta-list { list-style: none; padding: 0; flex-direction: column; align-items: stretch; }
.meta-list li, .meta-activity li { display: grid; gap: 2px; padding: 10px 0; border-top: 1px solid #edf0f4; }
.meta-activity { list-style: none; margin: 8px 0 0; padding: 0; }
.meta-activity strong, .meta-list strong, .meta-kv strong { color: #17253e; font-size: 12px; }
.meta-activity span, .meta-activity em, .meta-muted, .meta-list span, .meta-kv span { color: #7c899d; font-size: 11px; font-style: normal; }
.meta-kv { display: grid; gap: 8px; margin-top: 8px; }
.meta-kv p { display: flex; justify-content: space-between; gap: 16px; margin: 0; }
.meta-metric strong { font-size: 22px; }
.meta-metric p { color: #7c899d; font-weight: 500; }
.meta-state, .meta-banner { border: 1px solid var(--border); border-radius: 11px; background: white; padding: 16px; }
.meta-state strong, .meta-banner { display: block; color: #17253e; font-size: 13px; }
.meta-state p { margin: 6px 0 10px; color: #66758a; font-size: 12px; }
.meta-state a { color: #1475ff; font-size: 12px; font-weight: 700; }
.meta-state.is-denied { background: #fff8ef; }
.meta-state.is-expired, .meta-banner.is-error { background: #fff5f5; color: #9f2d2d; }
.meta-banner.is-error { color: #9f2d2d; }
.meta-table-wrap { overflow-x: auto; }
.meta-table { width: 100%; border-collapse: collapse; }
.meta-table th { text-align: left; color: #8a95a7; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; padding: 0 8px 8px; }
.meta-table td { padding: 10px 8px; border-top: 1px solid #edf0f4; color: #24344c; font-size: 12px; vertical-align: top; }
.meta-table small { display: block; color: #8a95a7; margin-top: 2px; }
.meta-table tr.is-selected td { background: #f4f8ff; }
.meta-actions { display: flex; gap: 8px; white-space: nowrap; }
.meta-actions button, .meta-table button { border: 0; background: transparent; color: #1475ff; font-size: 12px; font-weight: 700; }
.meta-actions button:disabled, .meta-table button:disabled { color: #98a4b5; }
.meta-pill { display: inline-flex; border-radius: 99px; padding: 3px 8px; background: #f2f5f9; color: #536278; font-size: 10px; font-weight: 700; }
.meta-pill.is-live { background: #e8f8ef; color: #128660; }
.meta-pill.is-paused { background: #fff4e5; color: #b86e00; }
.meta-steps { display: flex; gap: 8px; flex-wrap: wrap; list-style: none; margin: 0; padding: 0; }
.meta-steps li { border-radius: 99px; padding: 5px 9px; background: #f3f6fa; color: #7c899d; font-size: 10px; font-weight: 700; }
.meta-steps .is-current { background: #1475ff; color: white; }
.meta-steps .is-done { background: #e7f1ff; color: #1475ff; }
.meta-choice-grid, .meta-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
.meta-choice-grid button, .meta-field { display: grid; gap: 4px; text-align: left; border: 1px solid var(--border); border-radius: 10px; background: white; padding: 12px; color: #526176; font-size: 11px; }
.meta-choice-grid button.is-selected { border-color: #1475ff; box-shadow: inset 0 0 0 1px #1475ff; }
.meta-choice-grid button:disabled { opacity: .55; }
.meta-choice-grid strong { color: #17253e; font-size: 12px; }
.meta-field input, .meta-field textarea, .meta-field select, .meta-select { width: 100%; border: 1px solid #dbe2eb; border-radius: 8px; padding: 8px 10px; font: inherit; color: #17253e; background: white; }
.meta-builder-nav { margin-top: 16px; }
.meta-posts { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 12px; }
.meta-posts li { display: grid; grid-template-columns: 92px 1fr; gap: 12px; padding-top: 12px; border-top: 1px solid #edf0f4; }
.meta-posts img, .meta-preview img { width: 100%; height: 92px; object-fit: cover; border-radius: 8px; background: #f4f7fb; }
.meta-preview img { height: 180px; }
.meta-preview-empty { height: 120px; display: grid; place-items: center; border-radius: 8px; background: #f6f8fb; color: #8a95a7; font-size: 12px; }
.meta-preview strong { display: block; margin-top: 10px; color: #17253e; }
.meta-dialog-backdrop { position: fixed; inset: 0; background: rgba(16, 32, 59, .35); display: grid; place-items: center; z-index: 30; padding: 20px; }
.meta-dialog { width: min(460px, 100%); background: white; border-radius: 12px; padding: 18px; display: grid; gap: 10px; }
.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-debug { border-style: dashed; }
@media (max-width: 900px) {
.meta-split, .meta-posts li { grid-template-columns: 1fr; }
}