feat(campaigns): add campaign wizard with Meta ad preview
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(/&/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 }}
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user