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:
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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(/&/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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user