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:
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user