diff --git a/app/(app)/ads/[id]/page.tsx b/app/(app)/ads/[id]/page.tsx index 6b057712..52ba772f 100644 --- a/app/(app)/ads/[id]/page.tsx +++ b/app/(app)/ads/[id]/page.tsx @@ -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 @@ -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) => ({ @@ -129,7 +137,10 @@ export default async function CampaignDetailPage({ - {!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" && (

{display.hint}

@@ -144,6 +155,16 @@ export default async function CampaignDetailPage({ + {/* Counted apart from the paid figures above so the CTR and CPC an + advertiser reads stay comparable across tiers. */} + {(freeImpressions > 0 || freeClicks > 0) && ( +
+ + + +
+ )} +
diff --git a/app/(app)/ads/page.tsx b/app/(app)/ads/page.tsx index 01b7781b..765092d2 100644 --- a/app/(app)/ads/page.tsx +++ b/app/(app)/ads/page.tsx @@ -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" }; @@ -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)}`; } @@ -37,17 +43,26 @@ 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(); let seriesById = new Map(); + let series: AccountPoint[] = []; + let rangeById = new Map(); + // 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( @@ -55,11 +70,17 @@ export default async function AdsPage() { ) .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), @@ -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 (
@@ -100,12 +113,56 @@ export default async function AdsPage() {

{campaigns.length > 0 && ( -
- - - - -
+ <> + {/* One filter row, above everything it scopes: the stats, the chart + and the per-campaign figures all read the same slice. */} +
+ + + + {range.hint} +
+ +
+ p.impressions} />} + /> + p.clicks} />} + /> + + p.spentCents} />} + /> +
+ + {/* 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) && ( +

+ Plus{" "} + + {totals.freeImpressions.toLocaleString()} + {" "} + free-tier impressions and{" "} + + {totals.freeClicks.toLocaleString()} + {" "} + free clicks in this range, at no cost. +

+ )} + +
+ +
+ )} {campaigns.length === 0 ? ( @@ -119,10 +176,11 @@ export default async function AdsPage() { ) : (
    {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 (
  • @@ -152,7 +210,10 @@ export default async function AdsPage() { - + + {s.freeImpressions > 0 && ( + + )}
    {label}
    -
    {value}
    +
    {value}
    + {spark &&
    {spark}
    }
    ); } diff --git a/app/globals.css b/app/globals.css index 868a53ea..c4bd7164 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/components/ads/account-trend.tsx b/components/ads/account-trend.tsx new file mode 100644 index 00000000..74f81b5a --- /dev/null +++ b/components/ads/account-trend.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { formatBucket, type RangeDef } from "@/lib/ads/ranges"; +import type { AccountPoint } from "@/lib/ads/series"; + +/** + * Account-wide delivery over the selected range. + * + * Impressions only, split paid vs free — one measure, one unit, one axis. + * Clicks and spend live in the stat tiles above rather than as extra lines here: + * clicks run ~1% of impressions and spend is money, so putting either on this + * plot would mean a second y-scale, and a dual-axis chart invites exactly the + * false correlations it appears to show. + * + * Stacked, because paid + free is a meaningful total (every ad actually shown) + * and the split is the point — free backfill is delivery that earns nobody + * anything, and it should be visible as a share of the whole. + */ +export function AccountTrend({ data, range }: { data: AccountPoint[]; range: RangeDef }) { + const total = data.reduce((n, p) => n + p.impressions + p.freeImpressions + p.clicks, 0); + + if (total === 0) { + return ( +
    + No delivery in this range. +
    + ); + } + + const hasFree = data.some((p) => p.freeImpressions > 0); + + return ( +
    +
    +

    Delivery over time

    + {range.hint} +
    +
    + + + + formatBucket(v, range.tick)} + /> + + formatBucket(Number(v), range.tick)} + formatter={(value, name) => [Number(value).toLocaleString(), String(name)]} + /> + {/* Legend only once there are two series to tell apart — with a + single series the heading already names it. */} + {hasFree && } + + {hasFree && ( + + )} + + +
    +
    + ); +} diff --git a/components/ads/range-tabs.tsx b/components/ads/range-tabs.tsx new file mode 100644 index 00000000..5f944bf8 --- /dev/null +++ b/components/ads/range-tabs.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTransition } from "react"; +import { RANGES, type RangeId } from "@/lib/ads/ranges"; + +/** + * Stock-chart style range picker: 1H · 4H · 1D · 1W · 1M · 3M · 1Y · ALL. + * + * One row, above everything it scopes — the range governs the header chart, the + * header stats and the per-campaign figures alike, so the numbers on the page + * always agree with each other. + * + * State lives in the URL rather than in component state so a range survives a + * refresh and can be linked to. Navigation runs in a transition and the + * surrounding content keeps its last render at reduced opacity while the server + * re-renders — no skeleton and no layout jump between ranges. + */ +export function RangeTabs({ value }: { value: RangeId }) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [pending, startTransition] = useTransition(); + + function select(id: RangeId) { + if (id === value) return; + const params = new URLSearchParams(searchParams.toString()); + params.set("range", id); + startTransition(() => router.replace(`${pathname}?${params}`, { scroll: false })); + } + + return ( +
    + {RANGES.map((r) => { + const selected = r.id === value; + return ( + + ); + })} +
    + ); +} diff --git a/components/ads/stat-spark.tsx b/components/ads/stat-spark.tsx new file mode 100644 index 00000000..fd4dd419 --- /dev/null +++ b/components/ads/stat-spark.tsx @@ -0,0 +1,62 @@ +import type { AccountPoint } from "@/lib/ads/series"; + +/** + * Sparkline for a stat tile — one measure, server-rendered SVG, no recharts. + * + * The tile's number is the headline; this only carries shape, so it has no + * axes, no labels and no hover. Every value it hints at is already reachable + * from the main chart's tooltip and the per-campaign table below, which is what + * lets it stay this bare. + */ +export function StatSpark({ + data, + pick, + color = "var(--color-chart-1)", + width = 120, + height = 28, +}: { + data: AccountPoint[]; + pick: (p: AccountPoint) => number; + color?: string; + width?: number; + height?: number; +}) { + const values = data.map(pick); + const max = Math.max(...values, 0); + if (values.length < 2 || max <= 0) { + return