meta options
This commit is contained in:
parent
4697dab339
commit
8726c931fa
21 changed files with 3028 additions and 3 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
52
frontend/src/features/meta/AdAccountsPage.tsx
Normal file
52
frontend/src/features/meta/AdAccountsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
frontend/src/features/meta/AdsPage.tsx
Normal file
122
frontend/src/features/meta/AdsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
frontend/src/features/meta/AnalyticsPage.tsx
Normal file
63
frontend/src/features/meta/AnalyticsPage.tsx
Normal 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>
|
||||
}
|
||||
205
frontend/src/features/meta/CampaignBuilderPage.tsx
Normal file
205
frontend/src/features/meta/CampaignBuilderPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
frontend/src/features/meta/CampaignsPage.tsx
Normal file
113
frontend/src/features/meta/CampaignsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
frontend/src/features/meta/ConnectionsPage.tsx
Normal file
121
frontend/src/features/meta/ConnectionsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
frontend/src/features/meta/OverviewPage.tsx
Normal file
98
frontend/src/features/meta/OverviewPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
frontend/src/features/meta/PagesPage.tsx
Normal file
85
frontend/src/features/meta/PagesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
frontend/src/features/meta/SettingsPage.tsx
Normal file
128
frontend/src/features/meta/SettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
frontend/src/features/meta/api.ts
Normal file
200
frontend/src/features/meta/api.ts
Normal 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
|
||||
}
|
||||
}
|
||||
164
frontend/src/features/meta/ui.tsx
Normal file
164
frontend/src/features/meta/ui.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue