feat(campaigns): show spend, leads and cost per lead

Yesterday and last-7-days panels, the most and least efficient campaign by
cost per lead, and a per-campaign table.

Both windows end yesterday, matching the API: today's figures are partial
and would make every morning read as a collapse in spend.

Costs render as a dash rather than zero when there is nothing to divide
by. A campaign with no leads yet has no cost per lead, and zero would read
as "free".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-03 18:17:03 +05:30
co-authored by Claude Opus 5
parent 5b0088e95a
commit e00884d369
4 changed files with 276 additions and 0 deletions
+37
View File
@@ -2,7 +2,10 @@ import { request } from "../request";
import type {
AdAccount,
Campaign,
CampaignBreakdown,
CampaignDashboard,
CampaignEvent,
CampaignInsights,
CampaignPayload,
MetaCampaignSummary,
PreviewResponse,
@@ -134,6 +137,40 @@ export const campaignApi = {
`/campaigns/discover?ad_account_id=${encodeURIComponent(adAccountId)}`,
),
// --- Phase 4: analytics ---
/**
* Spend, leads and cost per lead for yesterday and the last seven days,
* plus the best and worst campaign by cost per lead.
*
* Read from stored insights, not Meta, so it stays fast and keeps
* working while Meta is rate-limiting.
*/
getCampaignDashboard: () =>
request<CampaignDashboard>("/campaigns/analytics/dashboard"),
/** A daily series for one campaign, or its split by one breakdown. */
getCampaignInsights: (
id: string,
params?: { since?: string; until?: string; breakdown?: string },
) => {
const search = new URLSearchParams();
if (params?.since) search.append("since", params.since);
if (params?.until) search.append("until", params.until);
if (params?.breakdown) search.append("breakdown", params.breakdown);
const query = search.toString();
return request<CampaignInsights | CampaignBreakdown>(
`/campaigns/${encodeURIComponent(id)}/insights${query ? `?${query}` : ""}`,
);
},
/** Re-fetch this campaign's insights from Meta now. */
refreshCampaignInsights: (id: string) =>
request<{ rows_written: number }>(
`/campaigns/${encodeURIComponent(id)}/insights/refresh`,
{ method: "POST", headers: operatorHeaders() },
),
/** Import an existing Meta campaign. Safe to call twice. */
adoptCampaign: (payload: {
ad_account_id: string;
+50
View File
@@ -96,6 +96,56 @@ export interface MetaCampaignSummary {
created_time?: string;
}
/**
* A summed window of metrics.
*
* Money is in **minor** currency units (paise, cents), matching the budget
* fields. Costs are `null` rather than 0 when there is nothing to divide by:
* a campaign with no leads yet has no cost per lead, and showing 0 would
* read as "free".
*/
export interface MetricSummary {
spend: number;
impressions: number;
clicks: number;
leads: number;
cost_per_lead: number | null;
cost_per_click: number | null;
ctr: number;
}
export interface CampaignRanking extends MetricSummary {
campaign_id: string;
name: string;
status: string;
}
export interface CampaignDashboard {
yesterday: MetricSummary;
last_7_days: MetricSummary;
campaigns: CampaignRanking[];
/** Lowest cost per lead. Campaigns with no leads are not ranked. */
best: CampaignRanking | null;
/** Highest cost per lead; null when fewer than two campaigns qualify. */
worst: CampaignRanking | null;
window: {
yesterday: string;
last_7_days_from: string;
last_7_days_to: string;
};
}
export interface CampaignInsights {
series: (MetricSummary & { date: string })[];
totals: MetricSummary;
window: { since: string; until: string };
}
export interface CampaignBreakdown {
breakdown: string;
rows: (MetricSummary & { value: string })[];
}
export interface PreviewResponse {
previews: Record<string, string>;
}
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Card, Col, Empty, Row, Space, Statistic, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import api from "../../api";
import type {
CampaignDashboard,
CampaignRanking,
MetricSummary,
} from "../../api/types/campaign";
/**
* Every money figure from the API is in minor currency units, matching the
* budget fields. Dividing here rather than server-side keeps one convention
* end to end instead of two that can drift.
*/
function money(minorUnits: number | null | undefined, currency?: string) {
if (minorUnits === null || minorUnits === undefined) return "-";
return `${currency ?? ""} ${(minorUnits / 100).toFixed(2)}`.trim();
}
interface Props {
currency?: string;
}
function MetricRow({
metrics,
currency,
}: {
metrics: MetricSummary;
currency?: string;
}) {
return (
<Row gutter={[24, 16]}>
<Col xs={12} md={6}>
<Statistic title="Spend" value={money(metrics.spend, currency)} />
</Col>
<Col xs={12} md={6}>
<Statistic title="Leads" value={metrics.leads} />
</Col>
<Col xs={12} md={6}>
{/* A dash, not zero: no leads yet is not a cost of nothing. */}
<Statistic
title="Cost per lead"
value={money(metrics.cost_per_lead, currency)}
/>
</Col>
<Col xs={12} md={6}>
<Statistic title="Clicks" value={metrics.clicks} />
</Col>
</Row>
);
}
export default function CampaignAnalytics({ currency }: Props) {
const [data, setData] = useState<CampaignDashboard | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
setData(await api.getCampaignDashboard());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load analytics");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const columns: ColumnsType<CampaignRanking> = [
{ title: "Campaign", dataIndex: "name", key: "name" },
{
title: "Status",
dataIndex: "status",
key: "status",
render: (value: string) => <Tag>{value.replace(/_/g, " ")}</Tag>,
},
{
title: "Spend",
key: "spend",
render: (_, row) => money(row.spend, currency),
},
{ title: "Leads", dataIndex: "leads", key: "leads" },
{
title: "Cost per lead",
key: "cost_per_lead",
render: (_, row) => money(row.cost_per_lead, currency),
},
{
title: "CTR",
key: "ctr",
render: (_, row) => `${row.ctr.toFixed(2)}%`,
},
];
if (error) {
return <Alert type="error" showIcon message="Analytics unavailable" description={error} />;
}
if (!loading && data && data.campaigns.length === 0) {
return (
<Card title="Analytics">
<Empty
description={
"No insights stored yet. They arrive once a campaign has been " +
"live for a day, or immediately if you refresh one."
}
/>
</Card>
);
}
return (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card
title="Yesterday"
loading={loading}
/* Both windows end yesterday: today is partial, and including it
would make every morning read as a collapse in spend. */
extra={data ? <Tag>{data.window.yesterday}</Tag> : null}
>
{data && <MetricRow metrics={data.yesterday} currency={currency} />}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title="Last 7 days"
loading={loading}
extra={
data ? (
<Tag>{`${data.window.last_7_days_from} to ${data.window.last_7_days_to}`}</Tag>
) : null
}
>
{data && <MetricRow metrics={data.last_7_days} currency={currency} />}
</Card>
</Col>
</Row>
{data && (data.best || data.worst) && (
<Row gutter={[16, 16]}>
{data.best && (
<Col xs={24} lg={12}>
<Card size="small" title="Most efficient">
<Statistic
title={data.best.name}
value={money(data.best.cost_per_lead, currency)}
suffix="per lead"
/>
</Card>
</Col>
)}
{data.worst && (
<Col xs={24} lg={12}>
<Card size="small" title="Least efficient">
<Statistic
title={data.worst.name}
value={money(data.worst.cost_per_lead, currency)}
suffix="per lead"
/>
</Card>
</Col>
)}
</Row>
)}
<Card title="By campaign, last 7 days">
<Table
rowKey="campaign_id"
size="small"
loading={loading}
columns={columns}
dataSource={data?.campaigns ?? []}
pagination={false}
/>
</Card>
</Space>
);
}
+3
View File
@@ -4,6 +4,7 @@ import type { ColumnsType } from "antd/es/table";
import { useCampaigns } from "./useCampaigns";
import CampaignWizard from "./CampaignWizard";
import CampaignActions from "./CampaignActions";
import CampaignAnalytics from "./CampaignAnalytics";
import OperatorTokenField from "./OperatorTokenField";
import type {
Campaign,
@@ -147,6 +148,8 @@ export default function CampaignsPage() {
</Card>
)}
<CampaignAnalytics currency={account?.currency} />
<Card
title="Campaigns"
extra={