Critical: only the active wizard step is mounted, so on the Review step no
Form.Items are registered and form.validateFields() resolved to {}. Every
field was dropped and every submit failed validation server-side. Read the
preserved store with getFieldsValue(true) instead, and validate each step's
fields when leaving it so the required rules actually run.
Security: extractSrc matched src=" anywhere in the body with no scheme or
host check, so a data-src attribute or an earlier <img> could hijack the
match, and javascript:/data:/protocol-relative URLs passed through. Combined
with sandbox="allow-same-origin" a javascript: URL would have inherited the
app origin. It now anchors on the iframe tag and accepts only https URLs on
facebook.com/fbcdn.net, failing closed otherwise. Verified against 14 inputs
including both hijack shapes.
Also: memoise the creative object so PreviewStep does not refetch on every
parent render, type the form values, guard the preview effect against stale
responses, reset the wizard on close, and show an explicit message when no
placement renders.
Moving extractSrc to its own module removes the react-refresh lint
suppression rather than hiding it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
9.3 KiB
TypeScript
321 lines
9.3 KiB
TypeScript
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);
|
|
|
|
const values: WizardValues = Form.useWatch([], form) ?? 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>
|
|
);
|
|
}
|