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
229 changes: 229 additions & 0 deletions app/(app)/dashboard/affiliate/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { ensureMembershipForUser, ledgerFor, profileUrlForMembership } from "@/lib/affiliate/memberships";
import { listDirectory, listJoins } from "@/lib/affiliate/directory";
import { HOLD_DAYS, PAYOUT_MIN_CENTS, WINDOW_DAYS, termsLine } from "@/lib/affiliate/program";
import { AddProgramForm, CopyField, JoinButton, PayForm, PayoutButton, SyncButton, TokenButton, WebhookForm } from "@/components/affiliate/controls";

export const metadata = { title: "Affiliate" };
export const dynamic = "force-dynamic";

const money = (n: number) => `$${n.toFixed(2)}`;

function describePays(pays: Array<{ event: string; kind: string; value: number; months?: number }>): string {
return pays
.map((p) => `${p.kind === "percent" ? `${p.value}%` : `$${p.value}`} per ${p.event}${p.months ? ` for ${p.months} months` : ""}`)
.join(", ");
}

export default async function AffiliatePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;

const membership = await ensureMembershipForUser({ id: user.id, email: user.email ?? null });
if (!membership) {
return (
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold">Affiliate</h1>
<p className="card mt-6 p-6 text-[var(--color-muted)]">The affiliate program is not set up on this deployment yet.</p>
</div>
);
}
const [ledger, joins, directory] = await Promise.all([ledgerFor(membership), listJoins(user.id), listDirectory()]);
const joinedKeys = new Set(joins.filter((j) => j.status !== "refused" && j.status !== "ended").map((j) => `${j.origin.toLowerCase()}|${j.programId}`));
const site = process.env.NEXT_PUBLIC_SITE_URL ?? "";
const others = directory.filter((p) => !site || p.origin.replace(/\/$/, "") !== site.replace(/\/$/, ""));

return (
<div className="mx-auto max-w-4xl">
<div className="flex items-center justify-between gap-4">
<h1 className="text-3xl font-bold">Affiliate</h1>
<Link href="/affiliate" className="btn">
How it works
</Link>
</div>
<p className="mt-2 text-[var(--color-muted)]">
Earn {termsLine().toLowerCase()} And join any other merchant that runs an{" "}
<a href="https://logicsrc.com/openaffiliate" className="underline">
OpenAffiliate
</a>{" "}
program, from here, with one profile.
</p>

<section className="card mt-6 p-5">
<h2 className="text-lg font-semibold">Your link</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Any page on this site works with <code className="font-mono">?oa={membership.code}</code> added. A click starts a {WINDOW_DAYS}-day window; a purchase in it is yours.
</p>
<div className="mt-3">
<CopyField value={ledger.link} label="affiliate link" />
</div>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Clicks, all time" value={String(ledger.clicks.total)} />
<Stat label={`Clicks, ${WINDOW_DAYS}d`} value={String(ledger.clicks.window)} />
<Stat label="Pending" value={money(ledger.balance.pending)} hint={`held ${HOLD_DAYS} days`} />
<Stat label="Approved" value={money(ledger.balance.approved)} hint={`paid ${money(ledger.balance.paid)} so far`} />
</div>
</section>

<section className="card mt-4 p-5">
<h2 className="text-lg font-semibold">Getting paid</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
USDC on Polygon, weekly, once the approved balance reaches ${(PAYOUT_MIN_CENTS / 100).toFixed(0)}. Or send it now.
</p>
<div className="mt-3 space-y-3">
<PayForm initial={membership.payAddress ?? ""} />
<PayoutButton approved={ledger.balance.approved} min={PAYOUT_MIN_CENTS / 100} hasAddress={!!membership.payAddress} />
</div>
</section>

<section className="card mt-4 p-5">
<h2 className="text-lg font-semibold">Conversions</h2>
{ledger.conversions.length === 0 ? (
<p className="mt-2 text-sm text-[var(--color-muted)]">None yet. Every purchase attributed to your link lands here with its status and hold.</p>
) : (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-[var(--color-muted)]">
<tr>
<th className="py-1 pr-3">When</th>
<th className="py-1 pr-3">Event</th>
<th className="py-1 pr-3">Amount</th>
<th className="py-1 pr-3">Commission</th>
<th className="py-1 pr-3">Status</th>
</tr>
</thead>
<tbody>
{ledger.conversions.slice(0, 50).map((c) => (
<tr key={c.id} className="border-t border-[var(--color-border,rgba(127,127,127,.2))]">
<td className="py-1 pr-3 whitespace-nowrap">{c.at.slice(0, 10)}</td>
<td className="py-1 pr-3">{c.event}</td>
<td className="py-1 pr-3">{money(c.amount)}</td>
<td className="py-1 pr-3">{money(c.commission)}</td>
<td className="py-1 pr-3">
<span className="badge">{c.status}</span>
{c.status === "pending" && c.held_until ? <span className="ml-2 text-[var(--color-muted)]">until {c.held_until.slice(0, 10)}</span> : null}
{c.reason ? <span className="ml-2 text-[var(--color-muted)]">{c.reason}</span> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{ledger.payouts.length > 0 && (
<ul className="mt-3 space-y-1 text-sm text-[var(--color-muted)]">
{ledger.payouts.slice(0, 10).map((p) => (
<li key={p.id}>
{p.at.slice(0, 10)}: {money(p.amount)} {p.status}
{p.tx ? (
<>
{" "}
<a className="underline" href={`https://polygonscan.com/tx/${p.tx}`} target="_blank" rel="noreferrer">
tx
</a>
</>
) : null}
</li>
))}
</ul>
)}
</section>

<section className="card mt-4 p-5">
<h2 className="text-lg font-semibold">Programs you have joined</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Other merchants&apos; programs, joined with your profile at <code className="font-mono text-xs">{profileUrlForMembership(membership)}</code>. Their ledgers are read daily and on demand.
</p>
{joins.length === 0 ? (
<p className="mt-2 text-sm text-[var(--color-muted)]">None yet. Pick one below, or paste a merchant&apos;s URL.</p>
) : (
<ul className="mt-3 space-y-2">
{joins.map((j) => {
const bal = (j.ledger?.balance ?? null) as { pending?: number; approved?: number; paid?: number } | null;
return (
<li key={j.id} className="rounded border border-[var(--color-border,rgba(127,127,127,.2))] p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold">
{j.origin.replace(/^https?:\/\//, "")} <span className="text-[var(--color-muted)]">· {j.programId}</span>
</div>
<div className="text-sm text-[var(--color-muted)]">
<span className="badge">{j.status}</span>
{bal ? ` pending ${money(bal.pending ?? 0)} · approved ${money(bal.approved ?? 0)} · paid ${money(bal.paid ?? 0)}` : " no ledger read yet"}
{j.error ? ` · ${j.error}` : ""}
</div>
</div>
<SyncButton id={j.id} />
</div>
{j.link ? (
<div className="mt-2">
<CopyField value={j.link} label="link" />
</div>
) : null}
</li>
);
})}
</ul>
)}
</section>

<section className="card mt-4 p-5">
<h2 className="text-lg font-semibold">Programs to join</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Every merchant here was read from its own <code className="font-mono text-xs">/.well-known/openaffiliate.json</code>. Add one by URL.
</p>
<div className="mt-3">
<AddProgramForm />
</div>
{others.length === 0 ? (
<p className="mt-3 text-sm text-[var(--color-muted)]">No other merchants read yet.</p>
) : (
<ul className="mt-3 space-y-2">
{others.map((p) => (
<li key={`${p.origin}|${p.program.id}`} className="flex flex-wrap items-center justify-between gap-2 rounded border border-[var(--color-border,rgba(127,127,127,.2))] p-3">
<div className="min-w-0">
<div className="font-semibold">
{p.merchant.name} <span className="text-[var(--color-muted)]">· {p.program.title}</span>
{p.verified ? <span className="badge badge-pass ml-2">verified</span> : <span className="badge ml-2">claimed</span>}
</div>
<div className="text-sm text-[var(--color-muted)]">
{describePays(p.program.pays)}
{p.program.window ? ` · ${p.program.window}-day window` : ""}
{p.program.hold_days ? ` · ${p.program.hold_days}-day hold` : ""}
{` · ${p.program.approval}`}
</div>
</div>
<JoinButton origin={p.origin} program={p.program.id} joined={joinedKeys.has(`${p.origin.toLowerCase()}|${p.program.id}`)} />
</li>
))}
</ul>
)}
</section>

<section className="card mt-4 p-5">
<h2 className="text-lg font-semibold">For agents and scripts</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Your API token works on every <code className="font-mono text-xs">/api/affiliate/v1/*</code> route and in <code className="font-mono text-xs">crawlproof affiliate</code>. The affiliate token below reads only the ledger, which is what you hand to a third party.
</p>
<div className="mt-3 space-y-3">
<TokenButton prefix={membership.tokenPrefix} />
<WebhookForm initial={membership.webhookUrl ?? ""} />
</div>
</section>
</div>
);
}

function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
<div className="rounded border border-[var(--color-border,rgba(127,127,127,.2))] p-3">
<div className="text-xs uppercase tracking-wide text-[var(--color-muted)]">{label}</div>
<div className="text-xl font-semibold">{value}</div>
{hint ? <div className="text-xs text-[var(--color-muted)]">{hint}</div> : null}
</div>
);
}
1 change: 1 addition & 0 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<Link href="/dashboard/projects/new">New</Link>
<Link href="/dashboard/ads">Ads</Link>
<Link href="/dashboard/promote">Promote</Link>
<Link href="/dashboard/affiliate">Affiliate</Link>
<Link href="/dashboard/settings/integrations/github">GitHub</Link>
<a href="/blog" target="_blank" rel="noreferrer">Blog ↗</a>
{profile?.is_admin && <Link href="/dashboard/admin">Admin</Link>}
Expand Down
99 changes: 99 additions & 0 deletions app/(marketing)/affiliate/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { HOLD_DAYS, PAYOUT_MIN_CENTS, PAYS, WINDOW_DAYS, termsLine } from "@/lib/affiliate/program";

export const metadata = {
title: "Affiliate program — no network, no application, paid in USDC",
description:
"Send people to CrawlProof and earn a share of what they buy. The terms are a public file, joining is a profile, the link is one parameter, and the money goes from us to your wallet with nobody in between.",
alternates: { canonical: "/affiliate" },
openGraph: {
title: "CrawlProof affiliate program",
description: "Public terms, no application, one link parameter, paid in USDC on Polygon. An OpenAffiliate program.",
url: "/affiliate",
},
};

/**
* The public page for the program. Every figure comes from lib/affiliate/program
* so the pitch cannot drift from what the descriptor and the ledger pay.
*/
export default async function AffiliateMarketingPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (user) redirect("/dashboard/affiliate");

const sale = PAYS.find((p) => p.event === "sale");
const rate = sale?.kind === "percent" ? `${sale.value}%` : sale ? `$${sale.value}` : "a share";

return (
<main className="mx-auto max-w-5xl px-4 py-16 sm:px-6">
<section className="text-center">
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-[var(--color-accent)]">CrawlProof partners</p>
<h1 className="text-4xl font-extrabold sm:text-5xl">Earn {rate} of what you send us. No network in the way.</h1>
<p className="mx-auto mt-4 max-w-2xl text-lg text-[var(--color-muted)]">{termsLine()}</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Link href="/signup" className="btn btn-primary">
Get your link
</Link>
<Link href="/affiliate/programs" className="btn">
Programs you can join
</Link>
</div>
</section>

<section className="mt-16 grid gap-6 sm:grid-cols-3">
<div className="card p-5">
<h2 className="text-lg font-semibold">The terms are a file</h2>
<p className="mt-2 text-sm text-[var(--color-muted)]">
Everything on this page is also at <code className="font-mono text-xs">/.well-known/openaffiliate.json</code>, in a shape any script or agent can read. What the file says is what the ledger pays, and the terms only ever change forward.
</p>
</div>
<div className="card p-5">
<h2 className="text-lg font-semibold">No application</h2>
<p className="mt-2 text-sm text-[var(--color-muted)]">
Sign in and your link exists. Outside CrawlProof, POST an OpenProfile.md URL to the join endpoint and get a link and a ledger token back at once.
</p>
</div>
<div className="card p-5">
<h2 className="text-lg font-semibold">Nobody in the money</h2>
<p className="mt-2 text-sm text-[var(--color-muted)]">
A conversion waits out the {HOLD_DAYS}-day refund window, then it is approved, then it is sent to your wallet in USDC on Polygon from ${(PAYOUT_MIN_CENTS / 100).toFixed(0)}. No network fee, because there is no network.
</p>
</div>
</section>

<section className="mt-16">
<h2 className="text-2xl font-bold">How a click becomes money</h2>
<ol className="mt-4 list-decimal space-y-2 pl-6 text-[var(--color-muted)]">
<li>
Add <code className="font-mono">?oa=yourcode</code> to any CrawlProof page. The visitor lands on that page; the parameter is stripped and a {WINDOW_DAYS}-day window starts.
</li>
<li>They sign in and buy credits inside the window. The purchase is recorded to you, pending, with the day it will be approved.</li>
<li>After {HOLD_DAYS} days with no refund it is approved. A reversal always says why.</li>
<li>Weekly, the approved balance goes to your wallet. Or press pay out now.</li>
</ol>
<p className="mt-4 text-sm text-[var(--color-muted)]">
Only a real page visit sets the window. A parameter on an image, a frame or a script sets nothing, and your own purchases do not pay you.
</p>
</section>

<section className="mt-16">
<h2 className="text-2xl font-bold">Join other programs from the same place</h2>
<p className="mt-2 text-[var(--color-muted)]">
Any merchant that serves an OpenAffiliate file can be joined from your dashboard with one profile, and every ledger shows on one page. That is the point of the spec: one shape, so an affiliate does not need ten dashboards, and a merchant does not need a network.
</p>
<p className="mt-2 text-sm text-[var(--color-muted)]">
Read the spec at{" "}
<a className="underline" href="https://logicsrc.com/openaffiliate">
logicsrc.com/openaffiliate
</a>
. Full terms at <Link className="underline" href="/affiliate/terms">/affiliate/terms</Link>.
</p>
</section>
</main>
);
}
Loading
Loading