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
252 changes: 252 additions & 0 deletions app/(app)/ads/earnings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { createClient } from "@/lib/supabase/server";
import { loadEarnings, dollars, type EarningsModel } from "@/lib/ads/earnings-data";
import { EarningsPdfButton } from "@/components/ads/earnings-pdf-button";

// recharts is client-only; keep it out of the server bundle.
const MoneyTrend = dynamic(() => import("@/components/ads/money-trend").then((m) => m.MoneyTrend));

export const metadata = { title: "Earnings & spend" };

const RANGE = 30;

function ctr(clicks: number, impressions: number): string {
return impressions ? `${((clicks / impressions) * 100).toFixed(1)}%` : "—";
}

const EMPTY: EarningsModel = {
rangeDays: RANGE,
totals: {
spentCents: 0,
earnedCents: 0,
netCents: 0,
availableCents: 0,
withdrawnCents: 0,
spendTodayCents: 0,
earnedTodayCents: 0,
advImpressions: 0,
advClicks: 0,
pubImpressions: 0,
pubClicks: 0,
},
campaigns: [],
slots: [],
payouts: [],
daily: [],
};

export default async function EarningsPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

const model = user ? await loadEarnings(supabase, user.id, RANGE) : EMPTY;
const t = model.totals;

return (
<div className="mx-auto max-w-4xl">
<Link href="/ads" className="text-sm text-[var(--color-muted)]">
← Ad campaigns
</Link>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-bold">Earnings &amp; spend</h1>
<EarningsPdfButton days={RANGE} />
</div>
<p className="mt-2 text-[var(--color-muted)]">
Your CrawlProof ad money across both sides — what you earn as a publisher and what
you spend as an advertiser. Last {RANGE} days. Download a PDF report for your
accountant or team.
</p>

<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Total earned" value={dollars(t.earnedCents)} accent />
<Stat label="Total spend" value={dollars(t.spentCents)} />
<Stat label="Net" value={dollars(t.netCents)} accent={t.netCents >= 0} danger={t.netCents < 0} />
<Stat label="Available to withdraw" value={dollars(t.availableCents)} />
</div>

<div className="mt-6">
<MoneyTrend data={model.daily} />
</div>

<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<MiniStat label="Earned today" value={dollars(t.earnedTodayCents)} />
<MiniStat label="Spent today" value={dollars(t.spendTodayCents)} />
<MiniStat label="Withdrawn" value={dollars(t.withdrawnCents)} />
<MiniStat label="Net" value={dollars(t.netCents)} />
</div>

{/* Publisher earnings */}
<h2 className="mt-8 text-xl font-semibold">Earnings by site</h2>
{model.slots.length === 0 ? (
<div className="card mt-3 p-6 text-center text-sm text-[var(--color-muted)]">
No monetized sites yet.{" "}
<Link href="/ads/slots" className="text-[var(--color-accent)]">
Monetize a site
</Link>
.
</div>
) : (
<div className="card mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-[var(--color-muted)]">
<Th>Site</Th>
<Th>Status</Th>
<Th right>Impr.</Th>
<Th right>Clicks</Th>
<Th right>CTR</Th>
<Th right>Earned</Th>
</tr>
</thead>
<tbody>
{model.slots.map((s) => (
<tr key={s.id} className="border-t border-[var(--color-border)]">
<Td>{s.name}</Td>
<Td>
<span className="badge whitespace-nowrap">{s.status}</span>
</Td>
<Td right>{s.impressions.toLocaleString()}</Td>
<Td right>{s.clicks.toLocaleString()}</Td>
<Td right>{ctr(s.clicks, s.impressions)}</Td>
<Td right mono>
{dollars(s.earnedCents)}
</Td>
</tr>
))}
</tbody>
</table>
</div>
)}

{/* Advertiser spend */}
<h2 className="mt-8 text-xl font-semibold">Spend by campaign</h2>
{model.campaigns.length === 0 ? (
<div className="card mt-3 p-6 text-center text-sm text-[var(--color-muted)]">
No ad campaigns yet.{" "}
<Link href="/ads/new" className="text-[var(--color-accent)]">
Create an ad
</Link>
.
</div>
) : (
<div className="card mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-[var(--color-muted)]">
<Th>Campaign</Th>
<Th>Status</Th>
<Th right>Impr.</Th>
<Th right>Clicks</Th>
<Th right>CTR</Th>
<Th right>Spent</Th>
</tr>
</thead>
<tbody>
{model.campaigns.map((c) => (
<tr key={c.id} className="border-t border-[var(--color-border)]">
<Td>{c.name}</Td>
<Td>
<span className="badge whitespace-nowrap">{c.status}</span>
</Td>
<Td right>{c.impressions.toLocaleString()}</Td>
<Td right>{c.clicks.toLocaleString()}</Td>
<Td right>{ctr(c.clicks, c.impressions)}</Td>
<Td right mono>
{dollars(c.spentCents)}
</Td>
</tr>
))}
</tbody>
</table>
</div>
)}

{model.payouts.length > 0 && (
<>
<h2 className="mt-8 text-xl font-semibold">Payout history</h2>
<div className="card mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-[var(--color-muted)]">
<Th>Date</Th>
<Th right>Amount</Th>
<Th>Currency</Th>
<Th>Status</Th>
</tr>
</thead>
<tbody>
{model.payouts.map((p, i) => (
<tr key={i} className="border-t border-[var(--color-border)]">
<Td>{new Date(p.createdAt).toLocaleDateString()}</Td>
<Td right mono>
{dollars(p.amountCents)}
</Td>
<Td>{p.currency.toUpperCase()}</Td>
<Td>{p.status}</Td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
}

function Stat({
label,
value,
accent = false,
danger = false,
}: {
label: string;
value: string;
accent?: boolean;
danger?: boolean;
}) {
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 ${
danger ? "text-red-400" : accent ? "text-[var(--color-accent)]" : ""
}`}
>
{value}
</div>
</div>
);
}

function MiniStat({ label, value }: { label: string; value: string }) {
return (
<div className="card p-3">
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">{label}</div>
<div className="mt-1 font-mono font-semibold">{value}</div>
</div>
);
}

function Th({ children, right = false }: { children: React.ReactNode; right?: boolean }) {
return <th className={`px-4 py-2 font-medium ${right ? "text-right" : ""}`}>{children}</th>;
}

function Td({
children,
right = false,
mono = false,
}: {
children: React.ReactNode;
right?: boolean;
mono?: boolean;
}) {
return (
<td className={`px-4 py-2 ${right ? "text-right" : ""} ${mono ? "font-mono font-semibold" : ""}`}>
{children}
</td>
);
}
3 changes: 3 additions & 0 deletions app/(app)/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export default async function AdsPage() {
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Ad campaigns</h1>
<div className="flex items-center gap-2">
<Link href="/ads/earnings" className="btn">
Earnings &amp; reports
</Link>
<Link href="/ads/slots" className="btn">
Monetize a site
</Link>
Expand Down
19 changes: 12 additions & 7 deletions app/(app)/ads/slots/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,19 @@ export default async function SlotsPage() {
<Link href="/ads" className="text-sm text-[var(--color-muted)]">
← Ad campaigns
</Link>
<div className="mt-4 flex items-center justify-between gap-4">
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-bold">Monetize your site</h1>
{/* Always-visible way to add a site to monetize — a site is a project,
so this starts the project-creation flow. Previously the only link
to it was hidden inside the empty state. */}
<Link href="/projects/new?next=/ads/slots" className="btn btn-primary whitespace-nowrap">
Monetize a new site
</Link>
<div className="flex items-center gap-2">
<Link href="/ads/earnings" className="btn whitespace-nowrap">
Earnings &amp; reports
</Link>
{/* Always-visible way to add a site to monetize — a site is a project,
so this starts the project-creation flow. Previously the only link
to it was hidden inside the empty state. */}
<Link href="/projects/new?next=/ads/slots" className="btn btn-primary whitespace-nowrap">
Monetize a new site
</Link>
</div>
</div>
<p className="mt-2 text-[var(--color-muted)]">
Each site you monetize is a CrawlProof <strong>project</strong>. Add a site to
Expand Down
50 changes: 50 additions & 0 deletions app/api/ads/earnings/pdf/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// PDF earnings/spend report for the logged-in account. Runs the same
// RLS-scoped queries as /ads/earnings, renders a self-contained HTML report,
// and hands it to the worker's Playwright /pdf endpoint (html branch).

import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { env } from "@/lib/env";
import { loadEarnings } from "@/lib/ads/earnings-data";
import { buildEarningsReportHtml } from "@/lib/ads/earnings-report";

export const runtime = "nodejs";

export async function GET(req: Request) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });

if (!env.workerUrl) {
return NextResponse.json({ error: "Worker not configured" }, { status: 500 });
}

const daysParam = Number(new URL(req.url).searchParams.get("days"));
const days = Number.isFinite(daysParam) && daysParam >= 1 && daysParam <= 365 ? Math.floor(daysParam) : 30;

const model = await loadEarnings(supabase, user.id, days);
const html = buildEarningsReportHtml({
model,
account: user.email ?? user.id,
generatedAt: new Date().toISOString(),
});

const workerRes = await fetch(`${env.workerUrl}/pdf`, {
method: "POST",
headers: { "content-type": "application/json", "x-worker-secret": env.workerSecret },
body: JSON.stringify({ html }),
});
if (!workerRes.ok) {
return NextResponse.json({ error: "PDF render failed" }, { status: 502 });
}
const buf = await workerRes.arrayBuffer();
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(buf, {
headers: {
"content-type": "application/pdf",
"content-disposition": `attachment; filename="crawlproof-earnings-${stamp}.pdf"`,
},
});
}
19 changes: 19 additions & 0 deletions components/ads/earnings-pdf-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import { useState } from "react";

export function EarningsPdfButton({ days = 30 }: { days?: number }) {
const [busy, setBusy] = useState(false);
return (
<a
className="btn"
href={`/api/ads/earnings/pdf?days=${days}`}
onClick={() => {
setBusy(true);
setTimeout(() => setBusy(false), 4000);
}}
>
{busy ? "Preparing PDF…" : "Download PDF report"}
</a>
);
}
Loading
Loading