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
42 changes: 42 additions & 0 deletions app/api/affiliate/join/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { findOrCreateAccountByEmail, enrollAffiliate } from "@/lib/db";
import { safeDomain } from "@/lib/config";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

function baseUrl(): string {
return (process.env.APP_BASE_URL || "https://moshcoding.com").replace(/\/+$/, "");
}

/**
* Public affiliate signup for a specific domain. Email-only — no session needed.
* Creates a passwordless account (claimable later from the dashboard) and enrolls
* it as an 80% affiliate, then hands back a domain-targeted referral link. That
* link drops the 90-day first-touch `mc_ref` cookie (see middleware) so waitlist
* signups on <dn> are credited to the affiliate.
*/
export async function POST(req: NextRequest) {
let body: any = {};
try { body = await req.json(); } catch { /* empty */ }

const email = String(body?.email || "").trim().toLowerCase();
const dn = safeDomain(body?.dn);
if (!EMAIL_RE.test(email)) return NextResponse.json({ error: "Enter a valid email." }, { status: 400 });
if (!dn) return NextResponse.json({ error: "Missing domain." }, { status: 400 });

const account = await findOrCreateAccountByEmail(email);
const aff = await enrollAffiliate(account.id);

return NextResponse.json({
ok: true,
dn,
code: aff.code,
commission_pct: aff.commission_pct,
// Domain-scoped share link: lands on <dn>'s page and sets the 90-day cookie.
shareUrl: `${baseUrl()}/?dn=${encodeURIComponent(dn)}&ref=${encodeURIComponent(aff.code)}`,
manageUrl: `${baseUrl()}/dashboard`,
});
}
14 changes: 14 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,20 @@ h2 { font-family: var(--display); font-weight: 400; text-transform: uppercase; f
.t-bid { display: inline-block; margin: 26px auto 0; font-family: var(--mono); font-size: 13px; letter-spacing: 0.06em;
color: var(--tenant-accent, var(--acid)); border: 1px solid var(--line); border-radius: 999px; padding: 9px 16px; transition: background .15s, color .15s; }
.t-bid:hover { background: var(--tenant-accent, var(--acid)); color: #0a0c08; }

/* tenant affiliate join */
.t-aff { margin: 44px auto 0; max-width: 520px; padding: 22px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--panel, #101210); }
.t-aff-h { font-family: var(--display); font-weight: 400; text-transform: uppercase; font-size: clamp(22px, 4vw, 32px); line-height: 1; margin: 0 0 10px; color: var(--tenant-accent, var(--acid)); }
.t-aff-sub { font-family: var(--mono); font-size: 12.5px; line-height: 1.6; color: var(--ash); margin: 0 0 16px; }
.t-aff-sub b { color: var(--bone); }
.t-aff-sub a { color: var(--tenant-accent, var(--acid)); text-decoration: underline; }
.t-aff-out { text-align: left; }
.t-aff-label { font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ash); margin: 0 0 8px; }
.t-aff-row { display: flex; gap: 10px; flex-wrap: wrap; }
.t-aff-link { flex: 1 1 240px; font-family: var(--mono); font-size: 13px; color: var(--bone); background: #0b0d0a; border: 1px solid var(--line); border-radius: 8px; padding: 11px 12px; }
.t-aff-link:focus { border-color: var(--tenant-accent, var(--acid)); outline: none; }
.t-aff .wform { margin: 0; }
.t-aff .fmsg { text-align: center; }
.bid-stats { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px; margin: 20px auto; max-width: 460px; }
.bid-stats > div { flex: 1 1 120px; background: var(--panel, #101210); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
.bid-stats dt { font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ash); margin: 0 0 4px; }
Expand Down
87 changes: 87 additions & 0 deletions components/AffiliateJoin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { copyText } from "@/lib/clipboard";

/**
* Public "become an affiliate for this domain" widget shown on a tenant page.
* Email-only signup → returns a domain-scoped referral link (80% free, 90-day
* cookie). Mirrors WaitlistForm's UX.
*/
export default function AffiliateJoin({ dn }: { dn: string }) {
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const [result, setResult] = useState<{ shareUrl: string; commission_pct: number } | null>(null);

async function submit(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.trim())) {
setMsg({ text: "That doesn't look like an email.", ok: false });
return;
}
setBusy(true);
try {
const res = await fetch("/api/affiliate/join", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: email.trim(), dn }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Something broke.");
setResult({ shareUrl: data.shareUrl, commission_pct: data.commission_pct });
setMsg({ text: `You're an affiliate for ${dn}. 🤘`, ok: true });
setEmail("");
} catch (err: any) {
setMsg({ text: err.message || "Network died. Try again.", ok: false });
} finally {
setBusy(false);
}
}

const copy = () => {
if (!result) return;
copyText(result.shareUrl).then((ok) =>
setMsg({ text: ok ? "Link copied. 🤘" : "Couldn't copy — select the link and copy it.", ok }),
);
};

const pct = result?.commission_pct ?? 80;
return (
<section className="t-aff" aria-label="Affiliate program">
<h2 className="t-aff-h">Promote {dn} — earn {pct}%</h2>
<p className="t-aff-sub">
Join free, grab your link, and earn <b>{pct}% commission</b> on fees from everyone you refer.
<b> 90-day cookie</b> — you get credited if they convert within 90 days.
</p>

{!result ? (
<form className="wform" onSubmit={submit} noValidate>
<input
type="email" name="email" placeholder="you@dev.null" required autoComplete="email"
value={email} onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit" className="btn btn-acid" disabled={busy}>
{busy ? "Summoning…" : "Become an affiliate"}
</button>
</form>
) : (
<div className="t-aff-out">
<p className="t-aff-label">Your referral link for {dn}:</p>
<div className="t-aff-row">
<input
className="t-aff-link" readOnly value={result.shareUrl}
onFocus={(e) => e.currentTarget.select()}
/>
<button type="button" className="btn btn-ghost" onClick={copy}>Copy</button>
</div>
<p className="t-aff-sub">Share it anywhere. Add a payout wallet in your <a href="/dashboard">dashboard</a> to get paid.</p>
</div>
)}

<p className={`fmsg${msg ? (msg.ok ? " ok" : " err") : ""}`} role="status" aria-live="polite">
{msg?.text ?? ""}
</p>
</section>
);
}
3 changes: 3 additions & 0 deletions components/Tenant.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TenantConfig } from "@/lib/config";
import WaitlistForm from "./WaitlistForm";
import AffiliateJoin from "./AffiliateJoin";
import SharePost from "./SharePost";
import LinkIcon, { kindFromUrl } from "./LinkIcon";
import CrawlProofAd from "./CrawlProofAd";
Expand Down Expand Up @@ -105,6 +106,8 @@ export default function Tenant({ cfg }: { cfg: TenantConfig }) {
</div>
)}

<AffiliateJoin dn={cfg.dn} />

<a className="t-bid" href={`/?bid=${encodeURIComponent(cfg.dn)}`}>💰 Bid on this domain</a>

{cfg.adSlot && <CrawlProofAd slot={cfg.adSlot} format={cfg.adFormat} />}
Expand Down
Loading