Merge Phase 1 campaign management

Campaigns page with ad-account billing panel, six-step creation wizard,
and Meta ad preview across six placements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-02 23:36:05 +05:30
co-authored by Claude Opus 5
16 changed files with 859 additions and 0 deletions
+5
View File
@@ -1,2 +1,7 @@
VITE_MASKANX_API_URL=https://api-dev.example.com
VITE_MASKANX_PROXY_TARGET=http://127.0.0.1:8088
# Meta ad account used by the Campaigns page (act_<id>).
# Without it the billing panel, the "no payment method" warning, ad preview,
# and the account-minimum budget check are all silently unavailable.
VITE_META_AD_ACCOUNT_ID=
+5
View File
@@ -1,2 +1,7 @@
VITE_MASKANX_API_URL=http://127.0.0.1:8088
VITE_MASKANX_PROXY_TARGET=http://127.0.0.1:8088
# Meta ad account used by the Campaigns page (act_<id>).
# Without it the billing panel, the "no payment method" warning, ad preview,
# and the account-minimum budget check are all silently unavailable.
VITE_META_AD_ACCOUNT_ID=
+5
View File
@@ -1,3 +1,8 @@
# Leave the public URL empty to use the Vite /api proxy locally.
VITE_MASKANX_API_URL=
VITE_MASKANX_PROXY_TARGET=http://127.0.0.1:8088
# Meta ad account used by the Campaigns page (act_<id>).
# Without it the billing panel, the "no payment method" warning, ad preview,
# and the account-minimum budget check are all silently unavailable.
VITE_META_AD_ACCOUNT_ID=
+5
View File
@@ -1 +1,6 @@
VITE_MASKANX_API_URL=https://api.maskanx.example.com
# Meta ad account used by the Campaigns page (act_<id>).
# Without it the billing panel, the "no payment method" warning, ad preview,
# and the account-minimum budget check are all silently unavailable.
VITE_META_AD_ACCOUNT_ID=
+5
View File
@@ -1,2 +1,7 @@
VITE_MASKANX_API_URL=http://127.0.0.1:8089
VITE_MASKANX_PROXY_TARGET=http://127.0.0.1:8089
# Meta ad account used by the Campaigns page (act_<id>).
# Without it the billing panel, the "no payment method" warning, ad preview,
# and the account-minimum budget check are all silently unavailable.
VITE_META_AD_ACCOUNT_ID=
+14
View File
@@ -19,6 +19,20 @@ npm run local
Start `maskanx-backend` on port `8088` before using the full UI.
## Campaigns page
The Campaigns page needs `VITE_META_AD_ACCOUNT_ID` set to the Meta ad account
id (`act_<digits>`) in your `.env.*` file.
If it is unset the page still loads and lists campaigns, but four things are
silently unavailable: the ad-account billing panel, the warning shown when no
payment method is attached, ad preview in the wizard, and the account-minimum
budget check (the backend cannot look up `min_daily_budget` without an
account, so it skips that validation and logs a warning).
Ad preview also requires `META_ADS_ACCESS_TOKEN` to be configured on the
backend, in Settings > Environments.
## Commands
| Command | Purpose |
+4
View File
@@ -22,6 +22,7 @@ import { diagnosticsApi } from "./modules/diagnostics";
import { personaApi } from "./modules/persona";
import { brandApi } from "./modules/brand";
import { crmApi } from "./modules/crm";
import { campaignApi } from "./modules/campaign";
export const api = {
// Root
@@ -80,6 +81,9 @@ export const api = {
// First-party CRM
...crmApi,
// Campaigns
...campaignApi,
};
export default api;
+70
View File
@@ -0,0 +1,70 @@
import { request } from "../request";
import type {
AdAccount,
Campaign,
CampaignEvent,
CampaignPayload,
PreviewResponse,
} from "../types/campaign";
export const campaignApi = {
listCampaigns: (params?: { company_id?: string; status_filter?: string }) => {
const search = new URLSearchParams();
if (params?.company_id) search.append("company_id", params.company_id);
if (params?.status_filter) search.append("status_filter", params.status_filter);
const query = search.toString();
return request<Campaign[]>(`/campaigns${query ? `?${query}` : ""}`);
},
getCampaign: (id: string) =>
request<Campaign>(`/campaigns/${encodeURIComponent(id)}`),
createCampaign: (payload: CampaignPayload) =>
request<Campaign>("/campaigns", {
method: "POST",
body: JSON.stringify(payload),
}),
updateCampaign: (id: string, payload: CampaignPayload) =>
request<Campaign>(`/campaigns/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteCampaign: (id: string) =>
request<void>(`/campaigns/${encodeURIComponent(id)}`, { method: "DELETE" }),
submitCampaign: (id: string, actor?: string) =>
request<Campaign>(`/campaigns/${encodeURIComponent(id)}/submit`, {
method: "POST",
body: JSON.stringify({ actor: actor ?? null }),
}),
approveCampaign: (id: string, actor?: string) =>
request<Campaign>(`/campaigns/${encodeURIComponent(id)}/approve`, {
method: "POST",
body: JSON.stringify({ actor: actor ?? null }),
}),
rejectCampaign: (id: string, reason?: string) =>
request<Campaign>(`/campaigns/${encodeURIComponent(id)}/reject`, {
method: "POST",
body: JSON.stringify({ reason: reason ?? null }),
}),
previewCampaign: (payload: {
ad_account_id: string;
creative: Record<string, unknown>;
ad_formats?: string[];
}) =>
request<PreviewResponse>("/campaigns/preview", {
method: "POST",
body: JSON.stringify(payload),
}),
getAdAccount: (adAccountId: string) =>
request<AdAccount>(`/campaigns/account/${encodeURIComponent(adAccountId)}`),
listCampaignEvents: (id: string) =>
request<CampaignEvent[]>(`/campaigns/${encodeURIComponent(id)}/events`),
};
+93
View File
@@ -0,0 +1,93 @@
export type CampaignStatus =
| "draft"
| "pending_approval"
| "approved"
| "synced"
| "live"
| "paused"
| "stopped"
| "archived";
export interface CampaignBudget {
daily_budget?: number;
lifetime_budget?: number;
currency?: string;
bid_strategy?: string;
bid_amount?: number;
}
export interface CampaignGuardrails {
daily_budget_limit?: number;
lifetime_budget_limit?: number;
max_campaign_spend?: number;
max_cost_per_lead?: number;
max_cost_per_click?: number;
auto_pause_if_spend_reaches?: number;
require_approval_for_budget_increase?: boolean;
stop_loss_enabled?: boolean;
}
export interface Campaign {
id: string;
company_id: string | null;
name: string;
status: CampaignStatus;
origin: "maskanx" | "imported";
objective: string | null;
ad_account_id: string | null;
budget: CampaignBudget;
guardrails: CampaignGuardrails;
targeting: Record<string, unknown>;
advanced: Record<string, unknown>;
channels: string[];
schedule: Record<string, unknown>;
meta_campaign_id: string | null;
sync_status: string;
sync_error: string | null;
approved_by: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface CampaignPayload {
name: string;
company_id?: string | null;
objective?: string | null;
ad_account_id?: string | null;
// Optional because PUT /campaigns/{id} is a partial update: the backend
// applies only the fields present in the body. `name` stays required
// because the backend's request model requires it on both POST and PUT.
budget?: CampaignBudget;
guardrails?: CampaignGuardrails;
targeting?: Record<string, unknown>;
advanced?: Record<string, unknown>;
channels?: string[];
schedule?: Record<string, unknown>;
}
export interface AdAccount {
id: string;
name?: string;
currency?: string;
balance?: string;
amount_spent?: string;
spend_cap?: string;
min_daily_budget?: number;
is_prepay_account?: boolean;
funding_source?: string;
account_status?: number;
}
export interface PreviewResponse {
previews: Record<string, string>;
}
export interface CampaignEvent {
id: string;
campaign_id: string;
event_type: string;
actor: string | null;
reason: string | null;
payload: Record<string, unknown>;
occurred_at: string;
}
+3
View File
@@ -16,6 +16,7 @@ import ModelsPage from "../../pages/Settings/Models";
import EnvironmentsPage from "../../pages/Settings/Environments";
import BrandPage from "../../pages/Settings/Brand";
import CRMPage from "../../pages/Settings/CRM";
import CampaignsPage from "../../pages/Campaigns";
import DiagnosticsPage from "../../pages/Control/Diagnostics";
import PersonasPage from "../../pages/Personas";
import DashboardPage from "../../pages/Dashboard";
@@ -40,6 +41,7 @@ const pathToKey: Record<string, string> = {
"/environments": "environments",
"/brand": "brand",
"/crm": "crm",
"/campaigns": "campaigns",
"/agent-config": "agent-config",
"/diagnostics": "diagnostics",
};
@@ -91,6 +93,7 @@ export default function MainLayout() {
<Route path="/environments" element={<EnvironmentsPage />} />
<Route path="/brand" element={<BrandPage />} />
<Route path="/crm" element={<CRMPage />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/personas" element={<PersonasPage />} />
<Route path="/agent-config" element={<AgentConfigPage />} />
<Route path="/diagnostics" element={<DiagnosticsPage />} />
+7
View File
@@ -32,6 +32,7 @@ import {
Building2,
Database,
Plus,
Megaphone,
} from "lucide-react";
import maskanLogoUrl from "../assets/maskan-logo.png?url";
@@ -52,6 +53,7 @@ const keyToPath: Record<string, string> = {
environments: "/environments",
brand: "/brand",
crm: "/crm",
campaigns: "/campaigns",
"agent-config": "/agent-config",
diagnostics: "/diagnostics",
};
@@ -261,6 +263,11 @@ export default function Sidebar({ selectedKey }: SidebarProps) {
label: t("nav.crm"),
icon: <Database size={16} />,
},
{
key: "campaigns",
label: "Campaigns",
icon: <Megaphone size={16} />,
},
],
},
];
+325
View File
@@ -0,0 +1,325 @@
import { useMemo, useState } from "react";
import {
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Select,
Space,
Steps,
Switch,
message,
} from "antd";
import api from "../../api";
import PreviewStep from "./PreviewStep";
import type { AdAccount, CampaignPayload } from "../../api/types/campaign";
interface Props {
open: boolean;
adAccount: AdAccount | null;
onClose: () => void;
onCreated: () => void;
}
const OBJECTIVES = [
"OUTCOME_LEADS",
"OUTCOME_TRAFFIC",
"OUTCOME_ENGAGEMENT",
"OUTCOME_AWARENESS",
"OUTCOME_SALES",
"OUTCOME_APP_PROMOTION",
];
interface WizardValues {
name?: string;
objective?: string;
daily_budget?: number;
age_min?: number;
age_max?: number;
countries?: string[];
primary_text?: string;
headline?: string;
description?: string;
link?: string;
auto_pause_if_spend_reaches?: number;
max_cost_per_lead?: number;
max_cost_per_click?: number;
require_approval_for_budget_increase?: boolean;
stop_loss_enabled?: boolean;
}
// Stable identity so `values` does not churn on every render while useWatch
// is still undefined.
const EMPTY_VALUES: WizardValues = {};
// Fields validated when leaving each step. The Preview step (index 3) has no
// inputs of its own.
const STEP_FIELDS: string[][] = [
["name", "objective", "daily_budget"],
["age_min", "age_max", "countries"],
["primary_text", "headline", "description", "link"],
[],
["auto_pause_if_spend_reaches", "max_cost_per_lead", "max_cost_per_click"],
];
export default function CampaignWizard({
open,
adAccount,
onClose,
onCreated,
}: Props) {
const [form] = Form.useForm<WizardValues>();
const [step, setStep] = useState(0);
const [saving, setSaving] = useState(false);
// `preserve: true` is required. Only the active step is mounted, so a bare
// useWatch tracks getFieldsValue() — which returns {} on a step with no
// Form.Item (Preview, Review). Without this the Preview step would render a
// blank creative and the Review step would show "-" for every field.
const values: WizardValues =
Form.useWatch([], { form, preserve: true }) ?? EMPTY_VALUES;
// Memoised so PreviewStep's effect does not refire on every parent render.
const creative = useMemo(
() => ({
object_story_spec: {
link_data: {
message: values.primary_text ?? "",
name: values.headline ?? "",
description: values.description ?? "",
link: values.link ?? "https://example.com",
},
},
}),
[values.primary_text, values.headline, values.description, values.link],
);
const closeAndReset = () => {
form.resetFields();
setStep(0);
onClose();
};
const next = async () => {
try {
await form.validateFields(STEP_FIELDS[step]);
setStep(step + 1);
} catch {
// antd renders the field errors inline.
}
};
const submit = async () => {
setSaving(true);
try {
// Only the active step is mounted, so validateFields() would resolve to
// {} here and silently drop every value. getFieldsValue(true) returns
// the whole preserved store. Required fields are enforced per step by
// `next`, so by the time Review is reachable they are already valid.
const raw = form.getFieldsValue(true) as WizardValues;
if (!raw.name) {
message.error("Campaign name is required. Go back to the first step.");
return;
}
const payload: CampaignPayload = {
name: raw.name,
objective: raw.objective,
ad_account_id: adAccount?.id ?? null,
budget: { daily_budget: raw.daily_budget },
guardrails: {
max_cost_per_lead: raw.max_cost_per_lead,
max_cost_per_click: raw.max_cost_per_click,
auto_pause_if_spend_reaches: raw.auto_pause_if_spend_reaches,
require_approval_for_budget_increase:
raw.require_approval_for_budget_increase ?? true,
stop_loss_enabled: raw.stop_loss_enabled ?? true,
},
targeting: {
age_min: raw.age_min,
age_max: raw.age_max,
countries: raw.countries,
},
};
await api.createCampaign(payload);
message.success("Campaign created as draft");
form.resetFields();
setStep(0);
onCreated();
onClose();
} catch (error) {
const detail = error instanceof Error ? error.message : "Create failed";
message.error(detail);
} finally {
setSaving(false);
}
};
const steps = [
{
title: "Objective and budget",
content: (
<>
<Form.Item name="name" label="Campaign name" rules={[{ required: true }]}>
<Input placeholder="Q3 lead generation" />
</Form.Item>
<Form.Item name="objective" label="Objective" rules={[{ required: true }]}>
<Select options={OBJECTIVES.map((o) => ({ value: o, label: o }))} />
</Form.Item>
<Form.Item
name="daily_budget"
label={`Daily budget (minor units, minimum ${
adAccount?.min_daily_budget ?? "-"
})`}
rules={[{ required: true }]}
>
<InputNumber min={adAccount?.min_daily_budget ?? 1} style={{ width: "100%" }} />
</Form.Item>
</>
),
},
{
title: "Targeting",
content: (
<>
<Form.Item name="age_min" label="Minimum age" initialValue={18}>
<InputNumber min={13} max={65} style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="age_max" label="Maximum age" initialValue={65}>
<InputNumber min={13} max={65} style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="countries" label="Countries" initialValue={["IN"]}>
<Select mode="tags" placeholder="IN" />
</Form.Item>
</>
),
},
{
title: "Creative",
content: (
<>
<Form.Item name="primary_text" label="Primary text">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="headline" label="Headline">
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input />
</Form.Item>
<Form.Item name="link" label="Destination link">
<Input placeholder="https://" />
</Form.Item>
</>
),
},
{
title: "Preview",
content: adAccount ? (
<PreviewStep adAccountId={adAccount.id} creative={creative} />
) : (
<p>Select an ad account to preview.</p>
),
},
{
title: "Guardrails",
content: (
<>
<Form.Item name="auto_pause_if_spend_reaches" label="Auto-pause at spend">
<InputNumber style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="max_cost_per_lead" label="Maximum cost per lead">
<InputNumber style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="max_cost_per_click" label="Maximum cost per click">
<InputNumber style={{ width: "100%" }} />
</Form.Item>
<Form.Item
name="require_approval_for_budget_increase"
label="Require approval for budget increase"
valuePropName="checked"
initialValue
>
<Switch />
</Form.Item>
<Form.Item
name="stop_loss_enabled"
label="Enable stop-loss"
valuePropName="checked"
initialValue
>
<Switch />
</Form.Item>
</>
),
},
{
title: "Review",
content: (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Name">{values.name ?? "-"}</Descriptions.Item>
<Descriptions.Item label="Objective">
{values.objective ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="Ad account">
{adAccount?.id ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="Daily budget">
{values.daily_budget ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="Age range">
{`${values.age_min ?? 18} - ${values.age_max ?? 65}`}
</Descriptions.Item>
<Descriptions.Item label="Countries">
{(values.countries ?? []).join(", ") || "-"}
</Descriptions.Item>
<Descriptions.Item label="Auto-pause at spend">
{values.auto_pause_if_spend_reaches ?? "not set"}
</Descriptions.Item>
<Descriptions.Item label="Max cost per lead">
{values.max_cost_per_lead ?? "not set"}
</Descriptions.Item>
<Descriptions.Item label="Creates in Meta?">
No. This saves a MaskanX draft only.
</Descriptions.Item>
</Descriptions>
),
},
];
return (
<Drawer
title="New campaign"
open={open}
onClose={closeAndReset}
width={720}
footer={
<Space>
<Button disabled={step === 0} onClick={() => setStep(step - 1)}>
Back
</Button>
{step < steps.length - 1 ? (
<Button type="primary" onClick={() => void next()}>
Next
</Button>
) : (
<Button type="primary" loading={saving} onClick={() => void submit()}>
Create draft
</Button>
)}
</Space>
}
>
<Steps
current={step}
size="small"
items={steps.map((s) => ({ title: s.title }))}
style={{ marginBottom: 24 }}
/>
<Form form={form} layout="vertical">
{steps[step].content}
</Form>
</Drawer>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Spin, Tabs } from "antd";
import api from "../../api";
import { extractSrc } from "./extractSrc";
const FORMATS: { key: string; label: string }[] = [
{ key: "DESKTOP_FEED_STANDARD", label: "Desktop feed" },
{ key: "MOBILE_FEED_STANDARD", label: "Mobile feed" },
{ key: "INSTAGRAM_STANDARD", label: "Instagram feed" },
{ key: "INSTAGRAM_STORY", label: "Instagram story" },
{ key: "FACEBOOK_STORY_MOBILE", label: "Facebook story" },
{ key: "RIGHT_COLUMN_STANDARD", label: "Right column" },
];
const AD_FORMATS = FORMATS.map((f) => f.key);
interface Props {
adAccountId: string;
creative: Record<string, unknown>;
}
export default function PreviewStep({ adAccountId, creative }: Props) {
const [previews, setPreviews] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
if (!adAccountId) return;
// Guards against a stale response overwriting a newer one, and against
// setting state after unmount.
let ignore = false;
setLoading(true);
setError(null);
api
.previewCampaign({
ad_account_id: adAccountId,
creative,
ad_formats: AD_FORMATS,
})
.then((result) => {
if (!ignore) setPreviews(result.previews);
})
.catch((err: unknown) => {
if (!ignore) {
setError(err instanceof Error ? err.message : "Preview failed");
}
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [adAccountId, creative, reloadKey]);
const retry = () => setReloadKey((key) => key + 1);
// Resolve each placement once per render rather than calling extractSrc
// twice per tab.
const renderable = useMemo(
() =>
FORMATS.map((f) => ({ ...f, src: extractSrc(previews[f.key]) })).filter(
(f): f is { key: string; label: string; src: string } => f.src !== null,
),
[previews],
);
if (loading) {
return (
<Spin tip="Rendering previews">
<div style={{ height: 620 }} />
</Spin>
);
}
if (error) {
return (
<Alert
type="error"
showIcon
message="Could not render preview"
description={error}
action={<Button onClick={retry}>Retry</Button>}
/>
);
}
if (renderable.length === 0) {
return (
<Alert
type="info"
showIcon
message="No preview available"
description={
"Meta returned no renderable preview for this creative. Check the " +
"primary text, headline and destination link, then try again."
}
action={<Button onClick={retry}>Retry</Button>}
/>
);
}
return (
<Tabs
items={renderable.map((f) => ({
key: f.key,
label: f.label,
children: (
<iframe
title={f.label}
src={f.src}
sandbox="allow-scripts allow-same-origin"
referrerPolicy="no-referrer"
loading="lazy"
style={{ width: "100%", height: 620, border: 0 }}
/>
),
}))}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Meta's preview endpoint returns a full `<iframe src="...">` HTML string per
* placement. We never inject that markup into the DOM; we extract the URL and
* render a real sandboxed iframe from it.
*
* The extraction fails closed. Anything that is not an https URL on a Meta
* host returns null, and the caller filters that placement out. This matters
* because the iframe is rendered with `allow-same-origin`: a `javascript:` URL
* would otherwise inherit the application's own origin.
*/
const META_HOST = /(^|\.)(facebook\.com|fbcdn\.net)$/;
export function extractSrc(body: string | undefined): string | null {
if (!body) return null;
// Anchor on the iframe tag so an earlier `data-src` or `<img src>` in the
// body cannot hijack the match.
const match = body.match(/<iframe\b[^>]*?\ssrc="([^"]+)"/i);
if (!match) return null;
const raw = match[1].replace(/&amp;/g, "&");
try {
const url = new URL(raw);
if (url.protocol !== "https:") return null;
if (!META_HOST.test(url.hostname)) return null;
return url.toString();
} catch {
// Relative or protocol-relative URLs throw here, which is the desired
// fail-closed outcome.
return null;
}
}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from "react";
import { Alert, Button, Card, Space, Statistic, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { useCampaigns } from "./useCampaigns";
import CampaignWizard from "./CampaignWizard";
import type { Campaign, CampaignStatus } from "../../api/types/campaign";
const STATUS_COLORS: Record<CampaignStatus, string> = {
draft: "default",
pending_approval: "gold",
approved: "blue",
synced: "cyan",
live: "green",
paused: "orange",
stopped: "red",
archived: "default",
};
function money(value?: string, currency?: string) {
if (value === undefined) return "-";
return `${currency ?? ""} ${(Number(value) / 100).toFixed(2)}`.trim();
}
export default function CampaignsPage() {
const { campaigns, account, loading, reload } = useCampaigns(
import.meta.env.VITE_META_AD_ACCOUNT_ID,
);
const [wizardOpen, setWizardOpen] = useState(false);
const columns: ColumnsType<Campaign> = [
{ title: "Name", dataIndex: "name", key: "name" },
{
title: "Status",
dataIndex: "status",
key: "status",
render: (value: CampaignStatus) => (
<Tag color={STATUS_COLORS[value]}>{value.replace(/_/g, " ")}</Tag>
),
},
{
title: "Origin",
dataIndex: "origin",
key: "origin",
render: (value: string) => (
<Tag color={value === "imported" ? "purple" : "geekblue"}>
{value === "imported" ? "External" : "MaskanX"}
</Tag>
),
},
{ title: "Objective", dataIndex: "objective", key: "objective" },
{
title: "Daily budget",
key: "daily_budget",
render: (_, record) =>
money(
record.budget.daily_budget?.toString(),
record.budget.currency ?? account?.currency,
),
},
];
return (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
{account && !account.funding_source && (
<Alert
type="warning"
showIcon
message="No payment method on this ad account"
description={
"Meta will not deliver ads until a payment method is added. " +
"This must be done in Meta Business Manager; it cannot be set " +
"from MaskanX."
}
/>
)}
{account && (
<Card title="Ad account">
<Space size="large" wrap>
<Statistic
title="Balance"
value={money(account.balance, account.currency)}
/>
<Statistic
title="Lifetime spend"
value={money(account.amount_spent, account.currency)}
/>
<Statistic
title="Minimum daily budget"
value={money(
account.min_daily_budget?.toString(),
account.currency,
)}
/>
</Space>
</Card>
)}
<Card
title="Campaigns"
extra={
<Button type="primary" onClick={() => setWizardOpen(true)}>
New campaign
</Button>
}
>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={campaigns}
/>
</Card>
<CampaignWizard
open={wizardOpen}
adAccount={account}
onClose={() => setWizardOpen(false)}
onCreated={reload}
/>
</Space>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useState } from "react";
import { message } from "antd";
import api from "../../api";
import type { AdAccount, Campaign } from "../../api/types/campaign";
export function useCampaigns(adAccountId?: string) {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [account, setAccount] = useState<AdAccount | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setCampaigns(await api.listCampaigns());
} catch (error) {
message.error(
error instanceof Error ? error.message : "Failed to load campaigns",
);
} finally {
setLoading(false);
}
}, []);
const loadAccount = useCallback(async () => {
if (!adAccountId) return;
try {
setAccount(await api.getAdAccount(adAccountId));
} catch {
setAccount(null);
}
}, [adAccountId]);
useEffect(() => {
void load();
void loadAccount();
}, [load, loadAccount]);
return { campaigns, account, loading, reload: load };
}