feat(campaigns): add campaign wizard with Meta ad preview

This commit is contained in:
AFFAANh
2026-08-02 02:57:41 +05:30
parent 3d238bcfec
commit 07e4c8997e
3 changed files with 373 additions and 3 deletions
+261
View File
@@ -0,0 +1,261 @@
import { 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",
];
export default function CampaignWizard({
open,
adAccount,
onClose,
onCreated,
}: Props) {
const [form] = Form.useForm();
const [step, setStep] = useState(0);
const [saving, setSaving] = useState(false);
const values = Form.useWatch([], form) ?? {};
const creative = {
object_story_spec: {
link_data: {
message: values.primary_text ?? "",
name: values.headline ?? "",
description: values.description ?? "",
link: values.link ?? "https://example.com",
},
},
};
const submit = async () => {
setSaving(true);
try {
const raw = await form.validateFields();
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={onClose}
width={720}
footer={
<Space>
<Button disabled={step === 0} onClick={() => setStep(step - 1)}>
Back
</Button>
{step < steps.length - 1 ? (
<Button type="primary" onClick={() => setStep(step + 1)}>
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>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Button, Spin, Tabs } from "antd";
import api from "../../api";
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" },
];
interface Props {
adAccountId: string;
creative: Record<string, unknown>;
}
/**
* Meta returns a full `<iframe src="...">` string per placement. Render a
* real sandboxed iframe from the extracted URL rather than injecting Meta's
* markup into the DOM.
*/
// extractSrc is exported alongside the component intentionally so its
// regex-based URL extraction (the XSS-safe alternative to
// dangerouslySetInnerHTML) is directly testable in isolation.
// eslint-disable-next-line react-refresh/only-export-components
export function extractSrc(body: string | undefined): string | null {
if (!body) return null;
const match = body.match(/src="([^"]+)"/);
if (!match) return null;
return match[1].replace(/&amp;/g, "&");
}
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 load = useCallback(async () => {
if (!adAccountId) return;
setLoading(true);
setError(null);
try {
const result = await api.previewCampaign({
ad_account_id: adAccountId,
creative,
ad_formats: FORMATS.map((f) => f.key),
});
setPreviews(result.previews);
} catch (err) {
setError(err instanceof Error ? err.message : "Preview failed");
} finally {
setLoading(false);
}
}, [adAccountId, creative]);
useEffect(() => {
void load();
}, [load]);
if (loading) return <Spin tip="Rendering previews" />;
if (error) {
return (
<Alert
type="error"
showIcon
message="Could not render preview"
description={error}
action={<Button onClick={() => void load()}>Retry</Button>}
/>
);
}
return (
<Tabs
items={FORMATS.filter((f) => extractSrc(previews[f.key])).map((f) => ({
key: f.key,
label: f.label,
children: (
<iframe
title={f.label}
src={extractSrc(previews[f.key]) as string}
sandbox="allow-scripts allow-same-origin"
style={{ width: "100%", height: 620, border: 0 }}
/>
),
}))}
/>
);
}
+20 -3
View File
@@ -1,6 +1,8 @@
import { Alert, Card, Space, Statistic, Table, Tag } from "antd";
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> = {
@@ -20,9 +22,10 @@ function money(value?: string, currency?: string) {
}
export default function CampaignsPage() {
const { campaigns, account, loading } = useCampaigns(
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" },
@@ -93,7 +96,14 @@ export default function CampaignsPage() {
</Card>
)}
<Card title="Campaigns">
<Card
title="Campaigns"
extra={
<Button type="primary" onClick={() => setWizardOpen(true)}>
New campaign
</Button>
}
>
<Table
rowKey="id"
loading={loading}
@@ -101,6 +111,13 @@ export default function CampaignsPage() {
dataSource={campaigns}
/>
</Card>
<CampaignWizard
open={wizardOpen}
adAccount={account}
onClose={() => setWizardOpen(false)}
onCreated={reload}
/>
</Space>
);
}