Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions app/(app)/ads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ export default async function CampaignDetailPage({
.maybeSingle();
if (!campaign) notFound();

const [{ data: stats }, { data: creativeRows }, series] = await Promise.all([
const [{ data: stats }, { data: creativeRows }, series, { data: profile }] = await Promise.all([
supabase
.from("ad_campaign_stats")
.select("impressions, clicks")
.select("impressions, free_impressions, clicks, free_clicks")
.eq("campaign_id", id)
.maybeSingle(),
supabase
Expand All @@ -69,13 +69,21 @@ export default async function CampaignDetailPage({
.eq("campaign_id", id)
.order("format"),
getCampaignDailySeries(supabase, [id], 30),
supabase
.from("profiles")
.select("credits_balance, ad_bonus_credits")
.eq("id", user.id)
.maybeSingle(),
]);

const impressions = (stats?.impressions as number) ?? 0;
const clicks = (stats?.clicks as number) ?? 0;
const freeImpressions = (stats?.free_impressions as number) ?? 0;
const freeClicks = (stats?.free_clicks as number) ?? 0;
const daily = series.get(id) ?? [];
const today = utcToday();
const display = campaignDisplayStatus(campaign, today);
const creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0);
const display = campaignDisplayStatus(campaign, today, creditsAvailable);

const creatives: (AdCreative & { id: string })[] = ((creativeRows as CreativeRow[]) ?? []).map(
(r) => ({
Expand Down Expand Up @@ -129,7 +137,10 @@ export default async function CampaignDetailPage({
</div>
</div>

{!display.serving && (
{/* Free tier still serves, so key this on the tier rather than on
`serving` — otherwise a campaign quietly running as backfill would
look identical to one winning paid placements. */}
{display.tier !== "paid" && (
<p className="mt-4 rounded-md border border-[var(--color-border)] bg-[var(--color-surface,transparent)] p-3 text-sm text-[var(--color-muted)]">
{display.hint}
</p>
Expand All @@ -144,6 +155,16 @@ export default async function CampaignDetailPage({
<Stat label="Daily budget" value={dollars(campaign.daily_budget_cents)} />
</div>

{/* Counted apart from the paid figures above so the CTR and CPC an
advertiser reads stay comparable across tiers. */}
{(freeImpressions > 0 || freeClicks > 0) && (
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat label="Free impressions" value={freeImpressions.toLocaleString()} />
<Stat label="Free clicks" value={freeClicks.toLocaleString()} />
<Stat label="Free tier cost" value="$0.00" />
</div>
)}

<div className="mt-4">
<CampaignTrend data={daily} />
</div>
Expand Down
144 changes: 107 additions & 37 deletions app/(app)/ads/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import Link from "next/link";
import { Suspense } from "react";
import { createClient } from "@/lib/supabase/server";
import { CampaignActions } from "@/components/ads/campaign-actions";
import { MiniTrend } from "@/components/ads/mini-trend";
import { getCampaignDailySeries, type CampaignDailyPoint } from "@/lib/ads/series";
import { AccountTrend } from "@/components/ads/account-trend";
import { RangeTabs } from "@/components/ads/range-tabs";
import { StatSpark } from "@/components/ads/stat-spark";
import {
getAccountSeries,
getCampaignDailySeries,
getCampaignRangeTotals,
sumSeries,
EMPTY_TOTALS,
type AccountPoint,
type CampaignDailyPoint,
type RangeTotals,
} from "@/lib/ads/series";
import { resolveRange } from "@/lib/ads/ranges";
import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status";

export const metadata = { title: "Ad campaigns" };
Expand All @@ -20,14 +34,6 @@ type CampaignRow = {
created_at: string;
};

type StatRow = {
campaign_id: string;
impressions: number;
clicks: number;
spent_cents: number;
total_spent_cents: number;
};

function dollars(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
Expand All @@ -37,29 +43,44 @@ function ctr(clicks: number, impressions: number): string {
return `${((clicks / impressions) * 100).toFixed(1)}%`;
}

export default async function AdsPage() {
export default async function AdsPage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const range = resolveRange((await searchParams).range);
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

let campaigns: CampaignRow[] = [];
const statsById = new Map<string, StatRow>();
let seriesById = new Map<string, CampaignDailyPoint[]>();
let series: AccountPoint[] = [];
let rangeById = new Map<string, RangeTotals>();
// Spendable credits decide whether a campaign is on the paid tier or running
// as free backfill, so the badge can't be derived from the campaign row alone.
let creditsAvailable: number | null = null;
if (user) {
const [{ data }, { data: stats }] = await Promise.all([
const [{ data }, { data: profile }, accountSeries, campaignTotals] = await Promise.all([
supabase
.from("ad_campaigns")
.select(
"id, ref_slug, name, destination_domain, daily_budget_cents, bid_credits, spend_today_cents, spend_date, status, created_at",
)
.order("created_at", { ascending: false }),
supabase
.from("ad_campaign_stats")
.select("campaign_id, impressions, clicks, spent_cents, total_spent_cents"),
.from("profiles")
.select("credits_balance, ad_bonus_credits")
.eq("id", user.id)
.maybeSingle(),
getAccountSeries(supabase, range),
getCampaignRangeTotals(supabase, range),
]);
creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0);
campaigns = (data as CampaignRow[]) ?? [];
for (const s of (stats as StatRow[]) ?? []) statsById.set(s.campaign_id, s);
series = accountSeries;
rangeById = campaignTotals;
seriesById = await getCampaignDailySeries(
supabase,
campaigns.map((c) => c.id),
Expand All @@ -68,15 +89,7 @@ export default async function AdsPage() {
}

const today = utcToday();

const totals = [...statsById.values()].reduce(
(a, s) => ({
impressions: a.impressions + (s.impressions ?? 0),
clicks: a.clicks + (s.clicks ?? 0),
spent: a.spent + (s.total_spent_cents ?? 0),
}),
{ impressions: 0, clicks: 0, spent: 0 },
);
const totals = sumSeries(series);

return (
<div className="mx-auto max-w-4xl">
Expand All @@ -100,12 +113,56 @@ export default async function AdsPage() {
</p>

{campaigns.length > 0 && (
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Impressions" value={totals.impressions.toLocaleString()} />
<Stat label="Clicks" value={totals.clicks.toLocaleString()} />
<Stat label="CTR" value={ctr(totals.clicks, totals.impressions)} />
<Stat label="Total spend" value={dollars(totals.spent)} />
</div>
<>
{/* One filter row, above everything it scopes: the stats, the chart
and the per-campaign figures all read the same slice. */}
<div className="mt-6 flex flex-wrap items-center gap-3">
<Suspense fallback={null}>
<RangeTabs value={range.id} />
</Suspense>
<span className="text-sm text-[var(--color-muted)]">{range.hint}</span>
</div>

<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat
label="Impressions"
value={totals.impressions.toLocaleString()}
spark={<StatSpark data={series} pick={(p) => p.impressions} />}
/>
<Stat
label="Clicks"
value={totals.clicks.toLocaleString()}
spark={<StatSpark data={series} pick={(p) => p.clicks} />}
/>
<Stat label="CTR" value={ctr(totals.clicks, totals.impressions)} />
<Stat
label="Spend"
value={dollars(totals.spentCents)}
spark={<StatSpark data={series} pick={(p) => p.spentCents} />}
/>
</div>

{/* Free backfill is delivery that costs and earns nothing, so it never
belongs in the paid figures above — but hiding it entirely would
make impressions look like they collapsed when a campaign runs dry. */}
{(totals.freeImpressions > 0 || totals.freeClicks > 0) && (
<p className="mt-3 text-sm text-[var(--color-muted)]">
Plus{" "}
<span className="font-mono font-semibold text-[var(--color-fg)]">
{totals.freeImpressions.toLocaleString()}
</span>{" "}
free-tier impressions and{" "}
<span className="font-mono font-semibold text-[var(--color-fg)]">
{totals.freeClicks.toLocaleString()}
</span>{" "}
free clicks in this range, at no cost.
</p>
)}

<div className="mt-4">
<AccountTrend data={series} range={range} />
</div>
</>
)}

{campaigns.length === 0 ? (
Expand All @@ -119,10 +176,11 @@ export default async function AdsPage() {
) : (
<ul className="mt-4 space-y-2">
{campaigns.map((c) => {
const s = statsById.get(c.id);
const impr = s?.impressions ?? 0;
const clk = s?.clicks ?? 0;
const display = campaignDisplayStatus(c, today);
// Range-scoped, so a row never contradicts the header above it.
const s = rangeById.get(c.id) ?? EMPTY_TOTALS;
const impr = s.impressions;
const clk = s.clicks;
const display = campaignDisplayStatus(c, today, creditsAvailable);
return (
<li key={c.id} className="card p-4">
<div className="flex items-center justify-between gap-4">
Expand Down Expand Up @@ -152,7 +210,10 @@ export default async function AdsPage() {
<MiniStat label="Impressions" value={impr.toLocaleString()} />
<MiniStat label="Clicks" value={clk.toLocaleString()} />
<MiniStat label="CTR" value={ctr(clk, impr)} />
<MiniStat label="Spent" value={dollars(s?.total_spent_cents ?? 0)} />
<MiniStat label="Spent" value={dollars(s.spentCents)} />
{s.freeImpressions > 0 && (
<MiniStat label="Free" value={s.freeImpressions.toLocaleString()} />
)}
<MiniStat
label="Today"
value={`${dollars(spendTodayCents(c, today))} / ${dollars(c.daily_budget_cents)}`}
Expand All @@ -170,11 +231,20 @@ export default async function AdsPage() {
);
}

function Stat({ label, value }: { label: string; value: string }) {
function Stat({
label,
value,
spark,
}: {
label: string;
value: string;
spark?: React.ReactNode;
}) {
return (
<div className="card p-4">
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">{label}</div>
<div className="mt-1 text-2xl font-bold">{value}</div>
<div className="mt-1 text-2xl font-bold tabular-nums">{value}</div>
{spark && <div className="mt-2">{spark}</div>}
</div>
);
}
Expand Down
8 changes: 8 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
--color-fail: #f87171;
--color-pass: #34d399;
--color-unknown: #64748b;
/* Categorical chart series. In-hue with --color-accent / --color-warn but
stepped darker: the brand tokens sit at OKLCH L ~0.84, well outside the
0.48–0.67 band a dark chart surface wants, and validate as FAIL. These two
pass all six checks against --color-card (#12161c) — lightness band, chroma
floor, CVD separation (ΔE 10.4 deutan), normal-vision separation (ΔE 21.2)
and 3:1 contrast. Assigned in fixed order; never cycled. */
--color-chart-1: #12a97a;
--color-chart-2: #c2740a;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
Expand Down
Loading
Loading