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
40 changes: 40 additions & 0 deletions .github/workflows/sponsorship.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Sponsorship regressions

on:
pull_request:
paths:
- 'app/api/create-partner-checkout/**'
- 'app/api/cancel-subscription/**'
- 'app/partner/**'
- 'app/sponsor/**'
- 'public/sponsors/**'
- 'components/partner-*.tsx'
- 'components/afterpack-banner.tsx'
- 'components/website-reverse-page.tsx'
- 'components/reverse-prompt-home.tsx'
- 'lib/sponsor*.ts'
- 'lib/premium-subscription.ts'
- 'tests/sponsor*.test.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/sponsorship.yml'

permissions:
contents: read

jobs:
sponsorship:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.11.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: node --import tsx --test tests/sponsor*.test.ts
- run: pnpm exec tsc --noEmit
- run: pnpm exec eslint components/partner-page.tsx components/partner-checkout-form.tsx components/afterpack-banner.tsx lib/sponsor*.ts lib/premium-subscription.ts app/api/create-partner-checkout/route.ts app/api/cancel-subscription/route.ts tests/sponsor*.test.ts
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,4 @@ pnpm test:library

`test:library` runs credential-free regression tests for library filtering, source fallbacks, and outage handling.

[Sponsor this readme](https://buy.stripe.com/dRmdRaaGu3es6Dz5sgg7e03)
[Sponsor GitReverse](https://gitreverse.com/sponsor)
8 changes: 5 additions & 3 deletions app/api/cancel-subscription/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import Stripe from "stripe";
import { getAuthenticatedUser } from "@/lib/auth-request";
import { isPremiumOnlySubscription } from "@/lib/premium-subscription";

export const runtime = "nodejs";

Expand Down Expand Up @@ -133,12 +134,13 @@ export async function POST(req: NextRequest) {
let primaryCustomerId: string | null = null;

for (const customer of customers) {
const subscriptions = await stripe.subscriptions.list({
const subscriptions = stripe.subscriptions.list({
customer: customer.id,
status: "active",
limit: 100,
});
for (const sub of subscriptions.data) {
for await (const sub of subscriptions) {
if (!isPremiumOnlySubscription(sub)) continue;
const canceled = await stripe.subscriptions.cancel(sub.id);
canceledIds.push(canceled.id);
primaryCustomerId ??= customer.id;
Expand All @@ -147,7 +149,7 @@ export async function POST(req: NextRequest) {

if (canceledIds.length === 0) {
return NextResponse.json(
{ error: "No active subscription found" },
{ error: "No active Premium subscription found" },
{ status: 400 }
);
}
Expand Down
175 changes: 21 additions & 154 deletions app/api/create-partner-checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,165 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { STRIPE_PRICE_IDS } from "@/lib/billing-config";
import { parseSponsorshipInput, createSponsorshipSession, safeStripeFailure } from "@/lib/sponsorship-checkout";

export const runtime = "nodejs";

const BUTTON_LABELS = {
design_this: "Design This",
build_this: "Build This",
} as const;

type ButtonLabelType = keyof typeof BUTTON_LABELS | "custom";

function getStripeClient(): Stripe | null {
const key = process.env.STRIPE_SECRET_KEY?.trim();
if (!key) return null;
return new Stripe(key, {
apiVersion: "2025-02-24.acacia",
});
}

function normalizeWebsite(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
const withProtocol = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const url = new URL(withProtocol);
if (!url.hostname.includes(".")) return null;
return url.toString();
} catch {
return null;
}
}

function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}

export async function POST(req: NextRequest) {
const stripe = getStripeClient();
if (!stripe) {
return NextResponse.json(
{
error: "stripe_not_configured",
message: "STRIPE_SECRET_KEY is not set",
},
{ status: 503 }
);
}

const priceId = STRIPE_PRICE_IDS.partner;
if (!priceId) {
return NextResponse.json(
{
error: "stripe_not_configured",
message: "Partner Stripe price is not configured",
},
{ status: 503 }
);
}

let body: unknown;
let input;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
const text = await req.text();
if (text.length > 4096) return NextResponse.json({ message: "Request is too large." }, { status: 413 });
input = parseSponsorshipInput(JSON.parse(text));
} catch (error) {
return NextResponse.json({ message: error instanceof SyntaxError ? "Invalid request." : error instanceof Error ? error.message : "Invalid request." }, { status: 400 });
}

const raw = body as Record<string, unknown>;
const website = normalizeWebsite(String(raw.website ?? ""));
const email = String(raw.email ?? "").trim().toLowerCase();
const buttonLabelType = String(raw.buttonLabelType ?? "") as ButtonLabelType;
const customButtonLabel = String(raw.customButtonLabel ?? "").trim();

if (!website) {
return NextResponse.json(
{ error: "invalid_website", message: "Enter a valid website URL." },
{ status: 400 }
);
}

if (!isValidEmail(email)) {
return NextResponse.json(
{ error: "invalid_email", message: "Enter a valid email address." },
{ status: 400 }
);
}

if (
buttonLabelType !== "design_this" &&
buttonLabelType !== "build_this" &&
buttonLabelType !== "custom"
) {
return NextResponse.json(
{ error: "invalid_button_label", message: "Choose a button label." },
{ status: 400 }
);
}

const buttonLabel =
buttonLabelType === "custom"
? customButtonLabel
: BUTTON_LABELS[buttonLabelType];

if (!buttonLabel || buttonLabel.length < 2 || buttonLabel.length > 40) {
return NextResponse.json(
{
error: "invalid_button_label",
message: "Custom button text must be between 2 and 40 characters.",
},
{ status: 400 }
);
}

const origin =
req.headers.get("origin")?.trim() || "https://gitreverse.com";

const key = process.env.STRIPE_SECRET_KEY?.trim();
if (!key) return NextResponse.json({ message: "Checkout is temporarily unavailable. Email contact@gitmvp.com." }, { status: 503 });
// Match the existing Stripe sync integration's API version; upgrade both together.
const stripe = new Stripe(key, { apiVersion: "2025-02-24.acacia" });
try {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/partner?checkout=success`,
cancel_url: `${origin}/partner?checkout=cancelled`,
allow_promotion_codes: true,
customer_email: email,
metadata: {
type: "partner",
partner_website: website,
partner_email: email,
button_label_type: buttonLabelType,
button_label: buttonLabel,
},
subscription_data: {
metadata: {
type: "partner",
partner_website: website,
partner_email: email,
button_label_type: buttonLabelType,
button_label: buttonLabel,
},
},
});

const url = session.url;
if (!url) {
return NextResponse.json(
{ error: "checkout_failed", message: "Stripe returned no checkout URL" },
{ status: 502 }
);
}

return NextResponse.json({ url });
} catch (err) {
console.error("[create-partner-checkout]", err);
return NextResponse.json(
{
error: "checkout_failed",
message: err instanceof Error ? err.message : "Unknown error",
},
{ status: 502 }
);
// Exact, immutable server-owned price IDs are validated in our catalog tests.
// Checkout validates the selected price itself; a separate Prices-read call
// would unnecessarily require broader permissions on the existing key.
const session = await createSponsorshipSession(stripe, input);
if (!session.url) throw new Error("Missing checkout URL");
return NextResponse.json({ url: session.url });
} catch (error) {
// Log only fixed classifications, never messages, credentials, or customer data.
console.error("[sponsorship-checkout]", safeStripeFailure(error));
return NextResponse.json({ message: "Could not start checkout. Try again or email contact@gitmvp.com." }, { status: 502 });
}
}
33 changes: 11 additions & 22 deletions app/partner/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
import type { Metadata } from "next";
import { PartnerPage } from "@/components/partner-page";
import { permanentRedirect } from "next/navigation";

export const metadata: Metadata = {
title: "Partner",
description:
"Partner with GitReverse and get high-intent visitors to your site.",
robots: { index: false, follow: false },
};

type PartnerRouteProps = {
searchParams: Promise<{ checkout?: string }>;
};

export default async function PartnerRoute({ searchParams }: PartnerRouteProps) {
/** Keep old bookmarks and in-flight Stripe returns working. */
export default async function LegacyPartnerRoute({ searchParams }: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const checkoutStatus =
params.checkout === "success"
? "success"
: params.checkout === "cancelled"
? "cancelled"
: undefined;

return <PartnerPage checkoutStatus={checkoutStatus} />;
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) value.forEach((item) => query.append(key, item));
else if (value !== undefined) query.set(key, value);
}
permanentRedirect(`/sponsor${query.size ? `?${query.toString()}` : ""}`);
}
2 changes: 1 addition & 1 deletion app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/auth/", "/history", "/partner"],
disallow: ["/api/", "/auth/", "/history", "/partner", "/sponsor"],
},
],
sitemap: "https://gitreverse.com/sitemap.xml",
Expand Down
45 changes: 45 additions & 0 deletions app/sponsor/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { unstable_cache } from "next/cache";
import { getSupabaseAdmin } from "@/lib/supabase";
import { ownerTrafficSnapshot, parseSponsorTraffic } from "@/lib/sponsorship-analytics";
import { PartnerPage } from "@/components/partner-page";

export const metadata: Metadata = {
title: "Sponsor GitReverse",
description:
"Sponsor GitReverse with a small placement in codebase reverse, website reverse, or the GitHub README.",
alternates: { canonical: "/sponsor" },
robots: { index: false, follow: false },
};

// Server route only: credentials and raw analytics never enter the client bundle.
// Return only aggregates; failed/unavailable reads are cached too, avoiding retry storms.
const getCachedAudience = unstable_cache(async () => {
const supabase = getSupabaseAdmin();
if (!supabase) return null;
try {
const { data, error } = await supabase.rpc("get_sponsorship_audience").abortSignal(AbortSignal.timeout(15000));
if (error) return null;
return data;
} catch {
return null;
}
}, ["sponsorship-audience-v2"], { revalidate: 3600 });

type PartnerRouteProps = {
searchParams: Promise<{ checkout?: string }>;
};

export default async function PartnerRoute({ searchParams }: PartnerRouteProps) {
const params = await searchParams;
const checkoutStatus =
params.checkout === "success"
? "success"
: params.checkout === "cancelled"
? "cancelled"
: undefined;

const now = new Date();
const traffic = parseSponsorTraffic(await getCachedAudience(), now);
return <PartnerPage checkoutStatus={checkoutStatus} traffic={traffic ?? ownerTrafficSnapshot(now)} liveTraffic={traffic !== null} />;
}
24 changes: 24 additions & 0 deletions components/afterpack-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { track } from "@vercel/analytics";

export function AfterpackBanner({ className = "" }: { className?: string }) {
return (
<a
href="https://afterpack.dev"
target="_blank"
rel="sponsored noopener noreferrer"
onClick={() => track("Sponsor Click", { sponsor: "afterpack", placement: "website-card" })}
className={`group flex items-center gap-3 rounded-lg border-[2px] border-zinc-900/15 bg-white/70 px-3 py-2.5 transition-colors hover:bg-white ${className}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/sponsors/afterpack-mark.png" alt="Afterpack" width={28} height={28} className="h-7 w-7 shrink-0 object-contain" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-zinc-900">Wanna make your site irreversible?</p>
<p className="text-[11px] text-zinc-600">Make sure you use Afterpack</p>
</div>
<span className="shrink-0 rounded border-[2px] border-zinc-900 bg-[#FF570A] px-2.5 py-1 text-[11px] font-semibold text-white">Try free</span>
<span className="sr-only">Sponsored — opens Afterpack in a new tab</span>
</a>
);
}
Loading
Loading