Skip to content

Commit 3dddadf

Browse files
ralyodioclaude
andcommitted
Ad network: performance stats for advertisers and publishers
/ads now shows per-campaign impressions, clicks, CTR, spend (+ today) and an account-wide totals row, so advertisers can see whether ads are working. /ads/slots shows per-slot impressions/clicks/CTR/earned for publishers. Backed by two security_invoker views (ad_campaign_stats, ad_slot_stats) that aggregate ad_impressions/ad_clicks via correlated subqueries and respect the existing RLS — each user only sees their own rows. Verified: views return correct columns; full build passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0b6c878 commit 3dddadf

4 files changed

Lines changed: 174 additions & 37 deletions

File tree

‎app/(app)/ads/page.tsx‎

Lines changed: 93 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,55 @@ type CampaignRow = {
1414
created_at: string;
1515
};
1616

17+
type StatRow = {
18+
campaign_id: string;
19+
impressions: number;
20+
clicks: number;
21+
spent_cents: number;
22+
spend_today_cents: number;
23+
total_spent_cents: number;
24+
};
25+
1726
function dollars(cents: number): string {
1827
return `$${(cents / 100).toFixed(2)}`;
1928
}
2029

30+
function ctr(clicks: number, impressions: number): string {
31+
if (!impressions) return "—";
32+
return `${((clicks / impressions) * 100).toFixed(1)}%`;
33+
}
34+
2135
export default async function AdsPage() {
2236
const supabase = await createClient();
2337
const {
2438
data: { user },
2539
} = await supabase.auth.getUser();
2640

2741
let campaigns: CampaignRow[] = [];
42+
const statsById = new Map<string, StatRow>();
2843
if (user) {
29-
const { data } = await supabase
30-
.from("ad_campaigns")
31-
.select("id, ref_slug, name, destination_domain, daily_budget_cents, status, created_at")
32-
.order("created_at", { ascending: false });
44+
const [{ data }, { data: stats }] = await Promise.all([
45+
supabase
46+
.from("ad_campaigns")
47+
.select("id, ref_slug, name, destination_domain, daily_budget_cents, status, created_at")
48+
.order("created_at", { ascending: false }),
49+
supabase
50+
.from("ad_campaign_stats")
51+
.select("campaign_id, impressions, clicks, spent_cents, spend_today_cents, total_spent_cents"),
52+
]);
3353
campaigns = (data as CampaignRow[]) ?? [];
54+
for (const s of (stats as StatRow[]) ?? []) statsById.set(s.campaign_id, s);
3455
}
3556

57+
const totals = [...statsById.values()].reduce(
58+
(a, s) => ({
59+
impressions: a.impressions + (s.impressions ?? 0),
60+
clicks: a.clicks + (s.clicks ?? 0),
61+
spent: a.spent + (s.total_spent_cents ?? 0),
62+
}),
63+
{ impressions: 0, clicks: 0, spent: 0 },
64+
);
65+
3666
return (
3767
<div className="mx-auto max-w-4xl">
3868
<div className="flex items-center justify-between">
@@ -51,6 +81,15 @@ export default async function AdsPage() {
5181
you fund a daily budget — publishers earn crypto for the clicks.
5282
</p>
5383

84+
{campaigns.length > 0 && (
85+
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
86+
<Stat label="Impressions" value={totals.impressions.toLocaleString()} />
87+
<Stat label="Clicks" value={totals.clicks.toLocaleString()} />
88+
<Stat label="CTR" value={ctr(totals.clicks, totals.impressions)} />
89+
<Stat label="Total spend" value={dollars(totals.spent)} />
90+
</div>
91+
)}
92+
5493
{campaigns.length === 0 ? (
5594
<div className="card mt-6 p-8 text-center text-[var(--color-muted)]">
5695
No campaigns yet.{" "}
@@ -60,27 +99,59 @@ export default async function AdsPage() {
6099
.
61100
</div>
62101
) : (
63-
<ul className="mt-6 space-y-2">
64-
{campaigns.map((c) => (
65-
<li key={c.id} className="card flex items-center justify-between gap-4 p-4">
66-
<div className="min-w-0">
67-
<div className="truncate font-semibold">{c.name}</div>
68-
<div className="truncate text-sm text-[var(--color-muted)]">
69-
{c.destination_domain} · {dollars(c.daily_budget_cents)}/day ·{" "}
70-
<span className="font-mono">{c.ref_slug}</span>
102+
<ul className="mt-4 space-y-2">
103+
{campaigns.map((c) => {
104+
const s = statsById.get(c.id);
105+
const impr = s?.impressions ?? 0;
106+
const clk = s?.clicks ?? 0;
107+
return (
108+
<li key={c.id} className="card p-4">
109+
<div className="flex items-center justify-between gap-4">
110+
<div className="min-w-0">
111+
<div className="truncate font-semibold">{c.name}</div>
112+
<div className="truncate text-sm text-[var(--color-muted)]">
113+
{c.destination_domain} · {dollars(c.daily_budget_cents)}/day ·{" "}
114+
<span className="font-mono">{c.ref_slug}</span>
115+
</div>
116+
</div>
117+
<div className="flex items-center gap-3">
118+
<span className="badge whitespace-nowrap">{c.status}</span>
119+
<Link href={`/ads/${c.id}`} className="btn text-sm">
120+
Edit
121+
</Link>
122+
<CampaignActions id={c.id} status={c.status} />
123+
</div>
124+
</div>
125+
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-1 border-t border-[var(--color-border)] pt-3 text-sm">
126+
<MiniStat label="Impressions" value={impr.toLocaleString()} />
127+
<MiniStat label="Clicks" value={clk.toLocaleString()} />
128+
<MiniStat label="CTR" value={ctr(clk, impr)} />
129+
<MiniStat label="Spent" value={dollars(s?.total_spent_cents ?? 0)} />
130+
<MiniStat label="Today" value={dollars(s?.spend_today_cents ?? 0)} />
71131
</div>
72-
</div>
73-
<div className="flex items-center gap-3">
74-
<span className="badge whitespace-nowrap">{c.status}</span>
75-
<Link href={`/ads/${c.id}`} className="btn text-sm">
76-
Edit
77-
</Link>
78-
<CampaignActions id={c.id} status={c.status} />
79-
</div>
80-
</li>
81-
))}
132+
</li>
133+
);
134+
})}
82135
</ul>
83136
)}
84137
</div>
85138
);
86139
}
140+
141+
function Stat({ label, value }: { label: string; value: string }) {
142+
return (
143+
<div className="card p-4">
144+
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">{label}</div>
145+
<div className="mt-1 text-2xl font-bold">{value}</div>
146+
</div>
147+
);
148+
}
149+
150+
function MiniStat({ label, value }: { label: string; value: string }) {
151+
return (
152+
<span>
153+
<span className="text-[var(--color-muted)]">{label}: </span>
154+
<span className="font-mono font-semibold">{value}</span>
155+
</span>
156+
);
157+
}

‎app/(app)/ads/slots/page.tsx‎

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type Payout = {
3434
tx_hash: string | null;
3535
created_at: string;
3636
};
37+
type SlotStat = { slot_id: string; impressions: number; clicks: number; earned_cents: number };
3738

3839
export default async function SlotsPage() {
3940
const supabase = await createClient();
@@ -46,24 +47,28 @@ export default async function SlotsPage() {
4647
const earnedBySlot = new Map<string, number>();
4748
const withdrawnBySlot = new Map<string, number>();
4849
const payoutsBySlot = new Map<string, Payout[]>();
50+
const statsBySlot = new Map<string, SlotStat>();
4951
if (user) {
50-
const [{ data: p }, { data: s }, { data: ledger }, { data: payouts }] = await Promise.all([
51-
// Monetization is owner-only: you earn from a slot, so only list projects
52-
// you OWN — not org/member-shared ones the broad RLS would also return.
53-
supabase
54-
.from("projects")
55-
.select("id, name, url")
56-
.eq("owner_id", user.id)
57-
.order("created_at", { ascending: false }),
58-
supabase.from("ad_slots").select("id, project_id, status, payout_address, payout_currency"),
59-
supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual"),
60-
supabase
61-
.from("ad_payouts")
62-
.select("id, slot_id, amount_cents, currency, status, tx_hash, created_at")
63-
.order("created_at", { ascending: false }),
64-
]);
52+
const [{ data: p }, { data: s }, { data: ledger }, { data: payouts }, { data: slotStats }] =
53+
await Promise.all([
54+
// Monetization is owner-only: you earn from a slot, so only list projects
55+
// you OWN — not org/member-shared ones the broad RLS would also return.
56+
supabase
57+
.from("projects")
58+
.select("id, name, url")
59+
.eq("owner_id", user.id)
60+
.order("created_at", { ascending: false }),
61+
supabase.from("ad_slots").select("id, project_id, status, payout_address, payout_currency"),
62+
supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual"),
63+
supabase
64+
.from("ad_payouts")
65+
.select("id, slot_id, amount_cents, currency, status, tx_hash, created_at")
66+
.order("created_at", { ascending: false }),
67+
supabase.from("ad_slot_stats").select("slot_id, impressions, clicks, earned_cents"),
68+
]);
6569
projects = (p as Project[]) ?? [];
6670
slots = (s as Slot[]) ?? [];
71+
for (const st of (slotStats as SlotStat[]) ?? []) statsBySlot.set(st.slot_id, st);
6772
for (const row of (ledger as { slot_id: string; amount_cents: number }[]) ?? []) {
6873
if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0));
6974
}
@@ -110,6 +115,7 @@ export default async function SlotsPage() {
110115
const slot = slotByProject.get(p.id) ?? null;
111116
const earned = slot ? earnedBySlot.get(slot.id) ?? 0 : 0;
112117
const withdrawn = slot ? withdrawnBySlot.get(slot.id) ?? 0 : 0;
118+
const stat = slot ? statsBySlot.get(slot.id) : undefined;
113119
return (
114120
<SlotManager
115121
key={p.id}
@@ -119,6 +125,7 @@ export default async function SlotsPage() {
119125
availableCents={earned - withdrawn}
120126
coins={coins}
121127
payouts={slot ? payoutsBySlot.get(slot.id) ?? [] : []}
128+
stats={stat ? { impressions: stat.impressions, clicks: stat.clicks, earnedCents: stat.earned_cents } : null}
122129
/>
123130
);
124131
})}

‎components/ads/slot-manager.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@ export function SlotManager({
5858
availableCents = 0,
5959
coins,
6060
payouts = [],
61+
stats = null,
6162
}: {
6263
project: Project;
6364
slot: Slot | null;
6465
origin: string;
6566
availableCents?: number;
6667
coins: { code: string; label: string }[];
6768
payouts?: Payout[];
69+
stats?: { impressions: number; clicks: number; earnedCents: number } | null;
6870
}) {
6971
const router = useRouter();
7072
const [pending, start] = useTransition();
@@ -203,6 +205,28 @@ export function SlotManager({
203205

204206
{slot && (
205207
<>
208+
{stats && (
209+
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
210+
<span>
211+
<span className="text-[var(--color-muted)]">Impressions: </span>
212+
<span className="font-mono font-semibold">{stats.impressions.toLocaleString()}</span>
213+
</span>
214+
<span>
215+
<span className="text-[var(--color-muted)]">Clicks: </span>
216+
<span className="font-mono font-semibold">{stats.clicks.toLocaleString()}</span>
217+
</span>
218+
<span>
219+
<span className="text-[var(--color-muted)]">CTR: </span>
220+
<span className="font-mono font-semibold">
221+
{stats.impressions ? `${((stats.clicks / stats.impressions) * 100).toFixed(1)}%` : "—"}
222+
</span>
223+
</span>
224+
<span>
225+
<span className="text-[var(--color-muted)]">Earned: </span>
226+
<span className="font-mono font-semibold">${(stats.earnedCents / 100).toFixed(2)}</span>
227+
</span>
228+
</div>
229+
)}
206230
<div>
207231
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
208232
Embed — paste on your page
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
-- Ad network: per-campaign performance view for the advertiser dashboard.
2+
--
3+
-- security_invoker=true so the underlying ad_impressions/ad_clicks/ad_campaigns
4+
-- RLS applies to the querying user — an advertiser only ever sees stats for
5+
-- campaigns they own. Correlated subqueries (not a join) so impression and
6+
-- click aggregates don't multiply each other.
7+
--
8+
-- Apply via psql over the pooler (prod history diverged), not `db push`.
9+
10+
create or replace view public.ad_campaign_stats
11+
with (security_invoker = true) as
12+
select
13+
c.id as campaign_id,
14+
(select count(*) from public.ad_impressions i where i.campaign_id = c.id) as impressions,
15+
(select count(*) from public.ad_clicks cl where cl.campaign_id = c.id and cl.valid) as clicks,
16+
(select coalesce(sum(cl.charged_cents), 0)
17+
from public.ad_clicks cl where cl.campaign_id = c.id and cl.valid) as spent_cents,
18+
c.spend_today_cents,
19+
c.total_spent_cents
20+
from public.ad_campaigns c;
21+
22+
grant select on public.ad_campaign_stats to authenticated, service_role;
23+
24+
-- Publisher side: per-slot performance for the monetize dashboard.
25+
create or replace view public.ad_slot_stats
26+
with (security_invoker = true) as
27+
select
28+
s.id as slot_id,
29+
(select count(*) from public.ad_impressions i where i.slot_id = s.id) as impressions,
30+
(select count(*) from public.ad_clicks cl where cl.slot_id = s.id and cl.valid) as clicks,
31+
(select coalesce(sum(cl.publisher_earn_cents), 0)
32+
from public.ad_clicks cl where cl.slot_id = s.id and cl.valid) as earned_cents
33+
from public.ad_slots s;
34+
35+
grant select on public.ad_slot_stats to authenticated, service_role;

0 commit comments

Comments
 (0)