From 0ac8215b9f7d9a546cfeeef0ed7a4c3ccacf95d2 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 18 Sep 2026 21:40:10 +0400 Subject: [PATCH 1/5] feat(web): load GitHub sponsors with ISR --- .env.example | 1 + .../__tests__/sponsors-content.test.ts | 114 +++++++++ .../components/sections/sponsors-content.ts | 218 ++++++++++++++++-- .../components/sections/sponsors-section.tsx | 26 ++- 4 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/sections/__tests__/sponsors-content.test.ts diff --git a/.env.example b/.env.example index 8a921ea..55086b1 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ E2E_STRIPE_WHSEC= # web RESEND_API_KEY= +GITHUB_SPONSORS_TOKEN= # demo APP_URL=http://localhost:3000 diff --git a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts new file mode 100644 index 0000000..97c89fb --- /dev/null +++ b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/cache", () => ({ + unstable_cache: unknown>(callback: T) => callback, +})); + +import { + createGitHubSponsors, + getSponsors, + MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, +} from "../sponsors-content"; + +function createGitHubSponsorNode(login: string, price: number, isOneTimePayment: boolean) { + return { + sponsorEntity: { + avatarUrl: `https://avatars.githubusercontent.com/${login}`, + login, + name: login, + url: `https://github.com/${login}`, + }, + isOneTimePayment, + tier: { monthlyPriceInDollars: price }, + }; +} + +function createGitHubResponse(nodes: unknown[]) { + return { + data: { + user: { + sponsorshipsAsMaintainer: { nodes }, + }, + }, + }; +} + +describe("createGitHubSponsors", () => { + it("formats payment cadence reported by GitHub", () => { + const monthlyAmount = MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS; + const oneTimeAmount = monthlyAmount + 5; + const sponsors = createGitHubSponsors( + createGitHubResponse([ + createGitHubSponsorNode("monthly-sponsor", monthlyAmount, false), + createGitHubSponsorNode("one-time-sponsor", oneTimeAmount, true), + ]), + ); + + expect(sponsors?.map((sponsor) => sponsor.amount)).toEqual([ + `$${monthlyAmount} monthly`, + `$${oneTimeAmount} one-time`, + ]); + }); + + it("rejects an invalid GraphQL response", () => { + expect(createGitHubSponsors({ data: { user: null } })).toBeNull(); + }); +}); + +describe("getSponsors", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("filters below-minimum sponsors and orders monthly first when amounts match", async () => { + vi.stubEnv("GITHUB_SPONSORS_TOKEN", "test-token"); + const monthlyLogin = "monthly-at-minimum"; + const oneTimeLogin = "one-time-at-minimum"; + const excludedLogin = "below-minimum"; + const fetch = vi.fn().mockResolvedValue({ + json: vi + .fn() + .mockResolvedValue( + createGitHubResponse([ + createGitHubSponsorNode(oneTimeLogin, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, true), + createGitHubSponsorNode(monthlyLogin, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, false), + createGitHubSponsorNode( + excludedLogin, + MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS - 1, + false, + ), + ]), + ), + ok: true, + }); + vi.stubGlobal("fetch", fetch); + + const sponsors = await getSponsors(); + const monthlyIndex = sponsors.findIndex((sponsor) => sponsor.href.endsWith(`/${monthlyLogin}`)); + const oneTimeIndex = sponsors.findIndex((sponsor) => sponsor.href.endsWith(`/${oneTimeLogin}`)); + + expect(monthlyIndex).toBeGreaterThanOrEqual(0); + expect(oneTimeIndex).toBeGreaterThan(monthlyIndex); + expect(sponsors.some((sponsor) => sponsor.href.endsWith(`/${excludedLogin}`))).toBe(false); + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/graphql", + expect.objectContaining({ + body: expect.stringContaining("isOneTimePayment"), + method: "POST", + headers: expect.objectContaining({ Authorization: "Bearer test-token" }), + }), + ); + }); + + it("does not fetch without a GitHub token", async () => { + vi.stubEnv("GITHUB_SPONSORS_TOKEN", ""); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await getSponsors(); + + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/sections/sponsors-content.ts b/apps/web/src/components/sections/sponsors-content.ts index c97e5e0..759678a 100644 --- a/apps/web/src/components/sections/sponsors-content.ts +++ b/apps/web/src/components/sections/sponsors-content.ts @@ -1,20 +1,59 @@ +import { unstable_cache } from "next/cache"; + export interface Sponsor { name: string; href: string; image: string; imageAlt: string; amount: string; + amountInDollars: number; + paymentCadence: "monthly" | "one-time" | null; kind: "company" | "individual"; - hideInSingleColumn?: boolean; } -export const sponsors: Sponsor[] = [ +export const SPONSORS_REVALIDATE_SECONDS = 60 * 60 * 6; +export const MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS = 10; + +const GITHUB_SPONSORS_API_URL = "https://api.github.com/graphql"; +const GITHUB_SPONSORABLE_LOGIN = "maxktz"; +const GITHUB_SPONSORS_QUERY = ` + query PayKitSponsors($login: String!) { + user(login: $login) { + sponsorshipsAsMaintainer(first: 100, includePrivate: false) { + nodes { + sponsorEntity { + ... on User { + login + name + avatarUrl + url + } + ... on Organization { + login + name + avatarUrl + url + } + } + isOneTimePayment + tier { + monthlyPriceInDollars + } + } + } + } + } +`; + +const hardCodedSponsors: Sponsor[] = [ { name: "Vercel", href: "https://vercel.com/home", image: "/companies/vercel-mark.svg", imageAlt: "Vercel logo", amount: "$10,000 credits", + amountInDollars: 10_000, + paymentCadence: null, kind: "company", }, { @@ -23,6 +62,8 @@ export const sponsors: Sponsor[] = [ image: "/companies/efferd.svg", imageAlt: "Efferd logo", amount: "$250 credits", + amountInDollars: 250, + paymentCadence: null, kind: "company", }, { @@ -30,7 +71,9 @@ export const sponsors: Sponsor[] = [ href: "https://x.com/mrpancakes39", image: "https://pbs.twimg.com/profile_images/1991510200386207744/2Bfvjltn_200x200.jpg", imageAlt: "MrPancakes39's X avatar", - amount: "$100", + amount: "$100 one-time", + amountInDollars: 100, + paymentCadence: "one-time", kind: "individual", }, { @@ -38,7 +81,9 @@ export const sponsors: Sponsor[] = [ href: "https://github.com/smorimoto", image: "https://github.com/smorimoto.png?size=160", imageAlt: "smorimoto's GitHub avatar", - amount: "$100", + amount: "$100 one-time", + amountInDollars: 100, + paymentCadence: "one-time", kind: "individual", }, { @@ -46,15 +91,9 @@ export const sponsors: Sponsor[] = [ href: "https://github.com/tedbrine", image: "https://github.com/tedbrine.png?size=160", imageAlt: "Ted Brine's GitHub avatar", - amount: "$20", - kind: "individual", - }, - { - name: "Leo", - href: "https://github.com/leoisadev1", - image: "https://github.com/leoisadev1.png?size=160", - imageAlt: "Leo's GitHub avatar", - amount: "$20", + amount: "$20 one-time", + amountInDollars: 20, + paymentCadence: "one-time", kind: "individual", }, { @@ -62,7 +101,9 @@ export const sponsors: Sponsor[] = [ href: "https://github.com/lassejlv", image: "https://github.com/lassejlv.png?size=160", imageAlt: "Lasse's GitHub avatar", - amount: "$10", + amount: "$10 one-time", + amountInDollars: 10, + paymentCadence: "one-time", kind: "individual", }, { @@ -70,8 +111,153 @@ export const sponsors: Sponsor[] = [ href: "https://github.com/Coobyk", image: "https://github.com/Coobyk.png?size=160", imageAlt: "Coobyk's GitHub avatar", - amount: "$5", + amount: "$5 one-time", + amountInDollars: 5, + paymentCadence: "one-time", kind: "individual", - hideInSingleColumn: true, }, ]; + +const githubSponsorOverrides: Record< + string, + Partial> & { priceInDollars?: number } +> = { + belk124: { + priceInDollars: 20, + image: "https://pbs.twimg.com/profile_images/2075769195908726784/okRA2bt9_400x400.jpg", + imageAlt: "boden elk's X avatar", + }, + leoisadev1: { + priceInDollars: 20, + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function createGitHubSponsor(value: unknown): Sponsor | null { + if (!isRecord(value) || !isRecord(value.sponsorEntity)) return null; + + const { avatarUrl, login, name, url } = value.sponsorEntity; + if ( + typeof avatarUrl !== "string" || + typeof login !== "string" || + typeof url !== "string" || + !avatarUrl || + !login || + !url + ) { + return null; + } + + const displayName = typeof name === "string" && name.trim() ? name.trim() : login; + const overrides = githubSponsorOverrides[login.toLowerCase()]; + const tierPrice = isRecord(value.tier) ? value.tier.monthlyPriceInDollars : null; + const priceInDollars = + overrides?.priceInDollars ?? + (typeof tierPrice === "number" && Number.isSafeInteger(tierPrice) && tierPrice > 0 + ? tierPrice + : null); + const cadence = + value.isOneTimePayment === true + ? "one-time" + : value.isOneTimePayment === false + ? "monthly" + : null; + + return { + name: displayName, + href: url, + image: overrides?.image ?? avatarUrl, + imageAlt: overrides?.imageAlt ?? `${displayName}'s GitHub avatar`, + amount: + priceInDollars === null + ? "GitHub Sponsor" + : `$${priceInDollars.toLocaleString("en-US")}${cadence ? ` ${cadence}` : ""}`, + amountInDollars: priceInDollars ?? 0, + paymentCadence: cadence, + kind: "individual", + }; +} + +/** Converts a GitHub Sponsors GraphQL response into display-ready sponsors. */ +export function createGitHubSponsors(value: unknown): Sponsor[] | null { + if (!isRecord(value) || !isRecord(value.data) || !isRecord(value.data.user)) return null; + + const connection = value.data.user.sponsorshipsAsMaintainer; + if (!isRecord(connection) || !Array.isArray(connection.nodes)) return null; + + return connection.nodes + .map(createGitHubSponsor) + .filter((sponsor): sponsor is Sponsor => sponsor !== null); +} + +async function fetchGitHubSponsors(): Promise { + const token = process.env.GITHUB_SPONSORS_TOKEN; + if (!token) return null; + + try { + const response = await fetch(GITHUB_SPONSORS_API_URL, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ + query: GITHUB_SPONSORS_QUERY, + variables: { login: GITHUB_SPONSORABLE_LOGIN }, + }), + }); + + if (!response.ok) { + console.error(`GitHub sponsors fetch failed with status ${response.status}`); + return null; + } + + return createGitHubSponsors(await response.json()); + } catch (error) { + console.error("GitHub sponsors fetch failed", error); + return null; + } +} + +const getCachedGitHubSponsors = unstable_cache(fetchGitHubSponsors, ["github-sponsors"], { + revalidate: SPONSORS_REVALIDATE_SECONDS, +}); + +function getGitHubSponsors(): Promise { + return process.env.NODE_ENV === "development" ? fetchGitHubSponsors() : getCachedGitHubSponsors(); +} + +function orderSponsorsByAmount(sponsors: Sponsor[]): Sponsor[] { + return sponsors.reduce((ordered, sponsor) => { + const insertionIndex = ordered.findIndex( + (candidate) => + sponsor.amountInDollars > candidate.amountInDollars || + (sponsor.amountInDollars === candidate.amountInDollars && + sponsor.paymentCadence === "monthly" && + candidate.paymentCadence !== "monthly"), + ); + + return insertionIndex === -1 + ? [...ordered, sponsor] + : [...ordered.slice(0, insertionIndex), sponsor, ...ordered.slice(insertionIndex)]; + }, []); +} + +/** Returns hard-coded sponsors plus the six-hour cached GitHub sponsor list. */ +export async function getSponsors(): Promise { + const githubSponsors = (await getGitHubSponsors()) ?? []; + const sponsors = [...hardCodedSponsors, ...githubSponsors].filter( + (sponsor) => sponsor.amountInDollars >= MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, + ); + const companies = sponsors.filter((sponsor) => sponsor.kind === "company"); + const individuals = orderSponsorsByAmount( + sponsors.filter((sponsor) => sponsor.kind === "individual"), + ); + + return [...companies, ...individuals]; +} diff --git a/apps/web/src/components/sections/sponsors-section.tsx b/apps/web/src/components/sections/sponsors-section.tsx index 614ade8..4f1f74e 100644 --- a/apps/web/src/components/sections/sponsors-section.tsx +++ b/apps/web/src/components/sections/sponsors-section.tsx @@ -1,5 +1,5 @@ import { Section, SectionContent } from "@/components/layout/section"; -import { sponsors } from "@/components/sections/sponsors-content"; +import { getSponsors } from "@/components/sections/sponsors-content"; import type { Sponsor } from "@/components/sections/sponsors-content"; import { cn } from "@/lib/utils"; @@ -44,7 +44,6 @@ function IndividualSponsorLink({ sponsor }: { sponsor: Sponsor }) { className={cn( sponsorLinkClassName, "flex items-center gap-2.5 px-3 py-3 sm:gap-3 sm:px-5 sm:py-4", - sponsor.hideInSingleColumn && "hidden min-[360px]:flex", )} href={sponsor.href} rel="noopener noreferrer" @@ -69,9 +68,12 @@ function IndividualSponsorLink({ sponsor }: { sponsor: Sponsor }) { ); } -export function SponsorsSection() { +export async function SponsorsSection() { + const sponsors = await getSponsors(); const companySponsors = sponsors.filter((sponsor) => sponsor.kind === "company"); const individualSponsors = sponsors.filter((sponsor) => sponsor.kind === "individual"); + const twoColumnFillers = (2 - (individualSponsors.length % 2)) % 2; + const threeColumnFillers = (3 - (individualSponsors.length % 3)) % 3; return (
@@ -89,12 +91,26 @@ export function SponsorsSection() {
{companySponsors.map((sponsor) => ( - + ))}
{individualSponsors.map((sponsor) => ( - + + ))} + {Array.from({ length: twoColumnFillers }, (_, index) => ( + From bced62e941ff3ead5585fc0e9007b35323c1083d Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 18 Sep 2026 22:01:01 +0400 Subject: [PATCH 2/5] codex: address PR review feedback (#212) --- .../__tests__/sponsors-content.test.ts | 78 ++++++++++---- .../components/sections/sponsors-content.ts | 100 ++++++++++++++---- 2 files changed, 137 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts index 97c89fb..6091ab2 100644 --- a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts +++ b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts @@ -23,11 +23,17 @@ function createGitHubSponsorNode(login: string, price: number, isOneTimePayment: }; } -function createGitHubResponse(nodes: unknown[]) { +function createGitHubResponse( + nodes: unknown[], + pageInfo: { endCursor: string | null; hasNextPage: boolean } = { + endCursor: null, + hasNextPage: false, + }, +) { return { data: { user: { - sponsorshipsAsMaintainer: { nodes }, + sponsorshipsAsMaintainer: { nodes, pageInfo }, }, }, }; @@ -51,7 +57,18 @@ describe("createGitHubSponsors", () => { }); it("rejects an invalid GraphQL response", () => { - expect(createGitHubSponsors({ data: { user: null } })).toBeNull(); + expect(() => createGitHubSponsors({ data: { user: null } })).toThrow( + "Invalid GitHub sponsors response", + ); + }); + + it("rejects GraphQL errors outside the optional tier field", () => { + expect(() => + createGitHubSponsors({ + ...createGitHubResponse([]), + errors: [{ path: ["user"], type: "FORBIDDEN" }], + }), + ).toThrow("GitHub sponsors response contained GraphQL errors"); }); }); @@ -67,22 +84,34 @@ describe("getSponsors", () => { const monthlyLogin = "monthly-at-minimum"; const oneTimeLogin = "one-time-at-minimum"; const excludedLogin = "below-minimum"; - const fetch = vi.fn().mockResolvedValue({ - json: vi - .fn() - .mockResolvedValue( - createGitHubResponse([ - createGitHubSponsorNode(oneTimeLogin, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, true), - createGitHubSponsorNode(monthlyLogin, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, false), - createGitHubSponsorNode( - excludedLogin, - MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS - 1, - false, + const fetch = vi + .fn() + .mockResolvedValueOnce({ + json: vi + .fn() + .mockResolvedValue( + createGitHubResponse( + [createGitHubSponsorNode(oneTimeLogin, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, true)], + { endCursor: "next-page", hasNextPage: true }, ), - ]), - ), - ok: true, - }); + ), + ok: true, + }) + .mockResolvedValueOnce({ + json: vi + .fn() + .mockResolvedValue( + createGitHubResponse([ + createGitHubSponsorNode(monthlyLogin, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, false), + createGitHubSponsorNode( + excludedLogin, + MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS - 1, + false, + ), + ]), + ), + ok: true, + }); vi.stubGlobal("fetch", fetch); const sponsors = await getSponsors(); @@ -92,6 +121,7 @@ describe("getSponsors", () => { expect(monthlyIndex).toBeGreaterThanOrEqual(0); expect(oneTimeIndex).toBeGreaterThan(monthlyIndex); expect(sponsors.some((sponsor) => sponsor.href.endsWith(`/${excludedLogin}`))).toBe(false); + expect(fetch).toHaveBeenCalledTimes(2); expect(fetch).toHaveBeenCalledWith( "https://api.github.com/graphql", expect.objectContaining({ @@ -100,6 +130,10 @@ describe("getSponsors", () => { headers: expect.objectContaining({ Authorization: "Bearer test-token" }), }), ); + expect(fetch).toHaveBeenLastCalledWith( + "https://api.github.com/graphql", + expect.objectContaining({ body: expect.stringContaining('"after":"next-page"') }), + ); }); it("does not fetch without a GitHub token", async () => { @@ -111,4 +145,12 @@ describe("getSponsors", () => { expect(fetch).not.toHaveBeenCalled(); }); + + it("falls back when GitHub fails", async () => { + vi.stubEnv("GITHUB_SPONSORS_TOKEN", "test-token"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500 })); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(getSponsors()).resolves.not.toHaveLength(0); + }); }); diff --git a/apps/web/src/components/sections/sponsors-content.ts b/apps/web/src/components/sections/sponsors-content.ts index 759678a..553a577 100644 --- a/apps/web/src/components/sections/sponsors-content.ts +++ b/apps/web/src/components/sections/sponsors-content.ts @@ -17,9 +17,9 @@ export const MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS = 10; const GITHUB_SPONSORS_API_URL = "https://api.github.com/graphql"; const GITHUB_SPONSORABLE_LOGIN = "maxktz"; const GITHUB_SPONSORS_QUERY = ` - query PayKitSponsors($login: String!) { + query PayKitSponsors($login: String!, $after: String) { user(login: $login) { - sponsorshipsAsMaintainer(first: 100, includePrivate: false) { + sponsorshipsAsMaintainer(first: 100, after: $after, includePrivate: false) { nodes { sponsorEntity { ... on User { @@ -40,6 +40,10 @@ const GITHUB_SPONSORS_QUERY = ` monthlyPriceInDollars } } + pageInfo { + endCursor + hasNextPage + } } } } @@ -136,6 +140,13 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function isInaccessibleTierError(value: unknown): boolean { + if (!isRecord(value) || value.type !== "FORBIDDEN" || !Array.isArray(value.path)) return false; + + // Fine-grained tokens expose sponsors and cadence but can redact the optional tier per node. + return value.path[value.path.length - 1] === "tier"; +} + function createGitHubSponsor(value: unknown): Sponsor | null { if (!isRecord(value) || !isRecord(value.sponsorEntity)) return null; @@ -181,23 +192,62 @@ function createGitHubSponsor(value: unknown): Sponsor | null { }; } -/** Converts a GitHub Sponsors GraphQL response into display-ready sponsors. */ -export function createGitHubSponsors(value: unknown): Sponsor[] | null { - if (!isRecord(value) || !isRecord(value.data) || !isRecord(value.data.user)) return null; +interface GitHubSponsorsPage { + endCursor: string | null; + hasNextPage: boolean; + sponsors: Sponsor[]; +} + +function createGitHubSponsorsPage(value: unknown): GitHubSponsorsPage { + if (!isRecord(value)) throw new Error("Invalid GitHub sponsors response"); + + if ( + value.errors !== undefined && + (!Array.isArray(value.errors) || value.errors.some((error) => !isInaccessibleTierError(error))) + ) { + throw new Error("GitHub sponsors response contained GraphQL errors"); + } + + if (!isRecord(value.data) || !isRecord(value.data.user)) { + throw new Error("Invalid GitHub sponsors response"); + } const connection = value.data.user.sponsorshipsAsMaintainer; - if (!isRecord(connection) || !Array.isArray(connection.nodes)) return null; + if (!isRecord(connection) || !Array.isArray(connection.nodes) || !isRecord(connection.pageInfo)) { + throw new Error("Invalid GitHub sponsors response"); + } + + const { endCursor, hasNextPage } = connection.pageInfo; + if ( + typeof hasNextPage !== "boolean" || + (endCursor !== null && typeof endCursor !== "string") || + (hasNextPage && !endCursor) + ) { + throw new Error("Invalid GitHub sponsors pagination data"); + } - return connection.nodes - .map(createGitHubSponsor) - .filter((sponsor): sponsor is Sponsor => sponsor !== null); + return { + endCursor, + hasNextPage, + sponsors: connection.nodes + .map(createGitHubSponsor) + .filter((sponsor): sponsor is Sponsor => sponsor !== null), + }; } -async function fetchGitHubSponsors(): Promise { +/** Converts a GitHub Sponsors GraphQL response into display-ready sponsors. */ +export function createGitHubSponsors(value: unknown): Sponsor[] { + return createGitHubSponsorsPage(value).sponsors; +} + +async function fetchGitHubSponsors(): Promise { const token = process.env.GITHUB_SPONSORS_TOKEN; - if (!token) return null; + if (!token) return []; - try { + const sponsors: Sponsor[] = []; + let after: string | null = null; + + for (;;) { const response = await fetch(GITHUB_SPONSORS_API_URL, { method: "POST", headers: { @@ -208,19 +258,17 @@ async function fetchGitHubSponsors(): Promise { }, body: JSON.stringify({ query: GITHUB_SPONSORS_QUERY, - variables: { login: GITHUB_SPONSORABLE_LOGIN }, + variables: { after, login: GITHUB_SPONSORABLE_LOGIN }, }), }); - if (!response.ok) { - console.error(`GitHub sponsors fetch failed with status ${response.status}`); - return null; - } + if (!response.ok) + throw new Error(`GitHub sponsors fetch failed with status ${response.status}`); - return createGitHubSponsors(await response.json()); - } catch (error) { - console.error("GitHub sponsors fetch failed", error); - return null; + const page = createGitHubSponsorsPage(await response.json()); + sponsors.push(...page.sponsors); + if (!page.hasNextPage) return sponsors; + after = page.endCursor; } } @@ -228,7 +276,7 @@ const getCachedGitHubSponsors = unstable_cache(fetchGitHubSponsors, ["github-spo revalidate: SPONSORS_REVALIDATE_SECONDS, }); -function getGitHubSponsors(): Promise { +function getGitHubSponsors(): Promise { return process.env.NODE_ENV === "development" ? fetchGitHubSponsors() : getCachedGitHubSponsors(); } @@ -250,7 +298,13 @@ function orderSponsorsByAmount(sponsors: Sponsor[]): Sponsor[] { /** Returns hard-coded sponsors plus the six-hour cached GitHub sponsor list. */ export async function getSponsors(): Promise { - const githubSponsors = (await getGitHubSponsors()) ?? []; + let githubSponsors: Sponsor[] = []; + try { + githubSponsors = await getGitHubSponsors(); + } catch (error) { + console.error("GitHub sponsors fetch failed", error); + } + const sponsors = [...hardCodedSponsors, ...githubSponsors].filter( (sponsor) => sponsor.amountInDollars >= MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, ); From 247cc90b6c733d721b62dad83c9a9184969646eb Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 18 Sep 2026 22:26:31 +0400 Subject: [PATCH 3/5] codex: address PR review feedback (#212) --- .../__tests__/sponsors-content.test.ts | 45 +++++++++++++++++-- .../components/sections/sponsors-content.ts | 20 +++++++-- .../components/sections/sponsors-section.tsx | 8 +++- apps/web/turbo.json | 8 +++- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts index 6091ab2..b33b7d2 100644 --- a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts +++ b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts @@ -1,18 +1,27 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("next/cache", () => ({ - unstable_cache: unknown>(callback: T) => callback, +const { unstableCache } = vi.hoisted(() => ({ + unstableCache: vi.fn((callback: (...args: never[]) => unknown) => callback), })); +vi.mock("next/cache", () => ({ unstable_cache: unstableCache })); + import { createGitHubSponsors, getSponsors, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, + SPONSORS_REVALIDATE_SECONDS, } from "../sponsors-content"; -function createGitHubSponsorNode(login: string, price: number, isOneTimePayment: boolean) { +function createGitHubSponsorNode( + login: string, + price: number, + isOneTimePayment: boolean, + type: "Organization" | "User" = "User", +) { return { sponsorEntity: { + __typename: type, avatarUrl: `https://avatars.githubusercontent.com/${login}`, login, name: login, @@ -45,7 +54,7 @@ describe("createGitHubSponsors", () => { const oneTimeAmount = monthlyAmount + 5; const sponsors = createGitHubSponsors( createGitHubResponse([ - createGitHubSponsorNode("monthly-sponsor", monthlyAmount, false), + createGitHubSponsorNode("monthly-sponsor", monthlyAmount, false, "Organization"), createGitHubSponsorNode("one-time-sponsor", oneTimeAmount, true), ]), ); @@ -54,6 +63,7 @@ describe("createGitHubSponsors", () => { `$${monthlyAmount} monthly`, `$${oneTimeAmount} one-time`, ]); + expect(sponsors[0]?.kind).toBe("company"); }); it("rejects an invalid GraphQL response", () => { @@ -70,6 +80,33 @@ describe("createGitHubSponsors", () => { }), ).toThrow("GitHub sponsors response contained GraphQL errors"); }); + + it("uses valid sponsor data when GitHub redacts tier fields", () => { + const response = { + ...createGitHubResponse([ + createGitHubSponsorNode("valid-sponsor", MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, false), + { sponsorEntity: null }, + ]), + errors: [ + { + path: ["user", "sponsorshipsAsMaintainer", "nodes", 0, "tier"], + type: "FORBIDDEN", + }, + ], + }; + + expect(createGitHubSponsors(response).map((sponsor) => sponsor.name)).toEqual([ + "valid-sponsor", + ]); + }); +}); + +describe("sponsor cache", () => { + it("uses the GitHub sponsor cache key and six-hour revalidation", () => { + expect(unstableCache).toHaveBeenCalledWith(expect.any(Function), ["github-sponsors"], { + revalidate: SPONSORS_REVALIDATE_SECONDS, + }); + }); }); describe("getSponsors", () => { diff --git a/apps/web/src/components/sections/sponsors-content.ts b/apps/web/src/components/sections/sponsors-content.ts index 553a577..2f65686 100644 --- a/apps/web/src/components/sections/sponsors-content.ts +++ b/apps/web/src/components/sections/sponsors-content.ts @@ -7,6 +7,7 @@ export interface Sponsor { imageAlt: string; amount: string; amountInDollars: number; + invertImageInDarkMode?: boolean; paymentCadence: "monthly" | "one-time" | null; kind: "company" | "individual"; } @@ -22,6 +23,7 @@ const GITHUB_SPONSORS_QUERY = ` sponsorshipsAsMaintainer(first: 100, after: $after, includePrivate: false) { nodes { sponsorEntity { + __typename ... on User { login name @@ -57,6 +59,7 @@ const hardCodedSponsors: Sponsor[] = [ imageAlt: "Vercel logo", amount: "$10,000 credits", amountInDollars: 10_000, + invertImageInDarkMode: true, paymentCadence: null, kind: "company", }, @@ -67,6 +70,7 @@ const hardCodedSponsors: Sponsor[] = [ imageAlt: "Efferd logo", amount: "$250 credits", amountInDollars: 250, + invertImageInDarkMode: true, paymentCadence: null, kind: "company", }, @@ -150,8 +154,9 @@ function isInaccessibleTierError(value: unknown): boolean { function createGitHubSponsor(value: unknown): Sponsor | null { if (!isRecord(value) || !isRecord(value.sponsorEntity)) return null; - const { avatarUrl, login, name, url } = value.sponsorEntity; + const { __typename, avatarUrl, login, name, url } = value.sponsorEntity; if ( + (__typename !== "User" && __typename !== "Organization") || typeof avatarUrl !== "string" || typeof login !== "string" || typeof url !== "string" || @@ -188,7 +193,7 @@ function createGitHubSponsor(value: unknown): Sponsor | null { : `$${priceInDollars.toLocaleString("en-US")}${cadence ? ` ${cadence}` : ""}`, amountInDollars: priceInDollars ?? 0, paymentCadence: cadence, - kind: "individual", + kind: __typename === "Organization" ? "company" : "individual", }; } @@ -296,6 +301,15 @@ function orderSponsorsByAmount(sponsors: Sponsor[]): Sponsor[] { }, []); } +function deduplicateSponsors(sponsors: Sponsor[]): Sponsor[] { + const sponsorsByHref = new Map(); + for (const sponsor of sponsors) { + const key = sponsor.href.replace(/\/$/, "").toLowerCase(); + if (!sponsorsByHref.has(key)) sponsorsByHref.set(key, sponsor); + } + return [...sponsorsByHref.values()]; +} + /** Returns hard-coded sponsors plus the six-hour cached GitHub sponsor list. */ export async function getSponsors(): Promise { let githubSponsors: Sponsor[] = []; @@ -305,7 +319,7 @@ export async function getSponsors(): Promise { console.error("GitHub sponsors fetch failed", error); } - const sponsors = [...hardCodedSponsors, ...githubSponsors].filter( + const sponsors = deduplicateSponsors([...hardCodedSponsors, ...githubSponsors]).filter( (sponsor) => sponsor.amountInDollars >= MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, ); const companies = sponsors.filter((sponsor) => sponsor.kind === "company"); diff --git a/apps/web/src/components/sections/sponsors-section.tsx b/apps/web/src/components/sections/sponsors-section.tsx index 4f1f74e..0601b47 100644 --- a/apps/web/src/components/sections/sponsors-section.tsx +++ b/apps/web/src/components/sections/sponsors-section.tsx @@ -20,7 +20,7 @@ function CompanySponsorLink({ sponsor }: { sponsor: Sponsor }) { {sponsor.imageAlt} sponsor.kind === "company"); const individualSponsors = sponsors.filter((sponsor) => sponsor.kind === "individual"); + const companyFillers = companySponsors.length % 2; const twoColumnFillers = (2 - (individualSponsors.length % 2)) % 2; const threeColumnFillers = (3 - (individualSponsors.length % 3)) % 3; @@ -89,10 +90,13 @@ export async function SponsorsSection() {
-
+
{companySponsors.map((sponsor) => ( ))} + {Array.from({ length: companyFillers }, (_, index) => ( +
{individualSponsors.map((sponsor) => ( diff --git a/apps/web/turbo.json b/apps/web/turbo.json index 089b562..699d629 100644 --- a/apps/web/turbo.json +++ b/apps/web/turbo.json @@ -3,7 +3,13 @@ "extends": ["//"], "tasks": { "build": { - "env": ["RESEND_API_KEY", "RESEND_FROM_EMAIL", "RESEND_TO_EMAIL", "SKIP_ENV_VALIDATION"] + "env": [ + "GITHUB_SPONSORS_TOKEN", + "RESEND_API_KEY", + "RESEND_FROM_EMAIL", + "RESEND_TO_EMAIL", + "SKIP_ENV_VALIDATION" + ] } } } From 2e9d034fc1a55fc46082d24d62f3ea64d39caa8d Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 18 Sep 2026 22:36:10 +0400 Subject: [PATCH 4/5] codex: address PR review feedback (#212) --- .../__tests__/sponsors-content.test.ts | 23 +++++++++++++++++++ .../components/sections/sponsors-content.ts | 5 ++++ 2 files changed, 28 insertions(+) diff --git a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts index b33b7d2..880a9d3 100644 --- a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts +++ b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts @@ -190,4 +190,27 @@ describe("getSponsors", () => { await expect(getSponsors()).resolves.not.toHaveLength(0); }); + + it("stops pagination when GitHub repeats a cursor", async () => { + vi.stubEnv("GITHUB_SPONSORS_TOKEN", "test-token"); + const repeatedPage = { + json: vi + .fn() + .mockResolvedValue( + createGitHubResponse([], { endCursor: "same-cursor", hasNextPage: true }), + ), + ok: true, + }; + const fetch = vi.fn().mockResolvedValue(repeatedPage); + vi.stubGlobal("fetch", fetch); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + await getSponsors(); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(console.error).toHaveBeenCalledWith( + "GitHub sponsors fetch failed", + expect.objectContaining({ message: "GitHub sponsors pagination cursor did not advance" }), + ); + }); }); diff --git a/apps/web/src/components/sections/sponsors-content.ts b/apps/web/src/components/sections/sponsors-content.ts index 2f65686..34834ef 100644 --- a/apps/web/src/components/sections/sponsors-content.ts +++ b/apps/web/src/components/sections/sponsors-content.ts @@ -250,6 +250,7 @@ async function fetchGitHubSponsors(): Promise { if (!token) return []; const sponsors: Sponsor[] = []; + const seenCursors = new Set(); let after: string | null = null; for (;;) { @@ -273,6 +274,10 @@ async function fetchGitHubSponsors(): Promise { const page = createGitHubSponsorsPage(await response.json()); sponsors.push(...page.sponsors); if (!page.hasNextPage) return sponsors; + if (page.endCursor === null || seenCursors.has(page.endCursor)) { + throw new Error("GitHub sponsors pagination cursor did not advance"); + } + seenCursors.add(page.endCursor); after = page.endCursor; } } From 116db9836af0368d086f720083d8ee20944dd8bb Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 18 Sep 2026 22:47:17 +0400 Subject: [PATCH 5/5] test(web): remove sponsor tests --- .../__tests__/sponsors-content.test.ts | 216 ------------------ 1 file changed, 216 deletions(-) delete mode 100644 apps/web/src/components/sections/__tests__/sponsors-content.test.ts diff --git a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts b/apps/web/src/components/sections/__tests__/sponsors-content.test.ts deleted file mode 100644 index 880a9d3..0000000 --- a/apps/web/src/components/sections/__tests__/sponsors-content.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -const { unstableCache } = vi.hoisted(() => ({ - unstableCache: vi.fn((callback: (...args: never[]) => unknown) => callback), -})); - -vi.mock("next/cache", () => ({ unstable_cache: unstableCache })); - -import { - createGitHubSponsors, - getSponsors, - MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, - SPONSORS_REVALIDATE_SECONDS, -} from "../sponsors-content"; - -function createGitHubSponsorNode( - login: string, - price: number, - isOneTimePayment: boolean, - type: "Organization" | "User" = "User", -) { - return { - sponsorEntity: { - __typename: type, - avatarUrl: `https://avatars.githubusercontent.com/${login}`, - login, - name: login, - url: `https://github.com/${login}`, - }, - isOneTimePayment, - tier: { monthlyPriceInDollars: price }, - }; -} - -function createGitHubResponse( - nodes: unknown[], - pageInfo: { endCursor: string | null; hasNextPage: boolean } = { - endCursor: null, - hasNextPage: false, - }, -) { - return { - data: { - user: { - sponsorshipsAsMaintainer: { nodes, pageInfo }, - }, - }, - }; -} - -describe("createGitHubSponsors", () => { - it("formats payment cadence reported by GitHub", () => { - const monthlyAmount = MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS; - const oneTimeAmount = monthlyAmount + 5; - const sponsors = createGitHubSponsors( - createGitHubResponse([ - createGitHubSponsorNode("monthly-sponsor", monthlyAmount, false, "Organization"), - createGitHubSponsorNode("one-time-sponsor", oneTimeAmount, true), - ]), - ); - - expect(sponsors?.map((sponsor) => sponsor.amount)).toEqual([ - `$${monthlyAmount} monthly`, - `$${oneTimeAmount} one-time`, - ]); - expect(sponsors[0]?.kind).toBe("company"); - }); - - it("rejects an invalid GraphQL response", () => { - expect(() => createGitHubSponsors({ data: { user: null } })).toThrow( - "Invalid GitHub sponsors response", - ); - }); - - it("rejects GraphQL errors outside the optional tier field", () => { - expect(() => - createGitHubSponsors({ - ...createGitHubResponse([]), - errors: [{ path: ["user"], type: "FORBIDDEN" }], - }), - ).toThrow("GitHub sponsors response contained GraphQL errors"); - }); - - it("uses valid sponsor data when GitHub redacts tier fields", () => { - const response = { - ...createGitHubResponse([ - createGitHubSponsorNode("valid-sponsor", MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, false), - { sponsorEntity: null }, - ]), - errors: [ - { - path: ["user", "sponsorshipsAsMaintainer", "nodes", 0, "tier"], - type: "FORBIDDEN", - }, - ], - }; - - expect(createGitHubSponsors(response).map((sponsor) => sponsor.name)).toEqual([ - "valid-sponsor", - ]); - }); -}); - -describe("sponsor cache", () => { - it("uses the GitHub sponsor cache key and six-hour revalidation", () => { - expect(unstableCache).toHaveBeenCalledWith(expect.any(Function), ["github-sponsors"], { - revalidate: SPONSORS_REVALIDATE_SECONDS, - }); - }); -}); - -describe("getSponsors", () => { - afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it("filters below-minimum sponsors and orders monthly first when amounts match", async () => { - vi.stubEnv("GITHUB_SPONSORS_TOKEN", "test-token"); - const monthlyLogin = "monthly-at-minimum"; - const oneTimeLogin = "one-time-at-minimum"; - const excludedLogin = "below-minimum"; - const fetch = vi - .fn() - .mockResolvedValueOnce({ - json: vi - .fn() - .mockResolvedValue( - createGitHubResponse( - [createGitHubSponsorNode(oneTimeLogin, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, true)], - { endCursor: "next-page", hasNextPage: true }, - ), - ), - ok: true, - }) - .mockResolvedValueOnce({ - json: vi - .fn() - .mockResolvedValue( - createGitHubResponse([ - createGitHubSponsorNode(monthlyLogin, MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS, false), - createGitHubSponsorNode( - excludedLogin, - MINIMUM_SPONSORSHIP_AMOUNT_IN_DOLLARS - 1, - false, - ), - ]), - ), - ok: true, - }); - vi.stubGlobal("fetch", fetch); - - const sponsors = await getSponsors(); - const monthlyIndex = sponsors.findIndex((sponsor) => sponsor.href.endsWith(`/${monthlyLogin}`)); - const oneTimeIndex = sponsors.findIndex((sponsor) => sponsor.href.endsWith(`/${oneTimeLogin}`)); - - expect(monthlyIndex).toBeGreaterThanOrEqual(0); - expect(oneTimeIndex).toBeGreaterThan(monthlyIndex); - expect(sponsors.some((sponsor) => sponsor.href.endsWith(`/${excludedLogin}`))).toBe(false); - expect(fetch).toHaveBeenCalledTimes(2); - expect(fetch).toHaveBeenCalledWith( - "https://api.github.com/graphql", - expect.objectContaining({ - body: expect.stringContaining("isOneTimePayment"), - method: "POST", - headers: expect.objectContaining({ Authorization: "Bearer test-token" }), - }), - ); - expect(fetch).toHaveBeenLastCalledWith( - "https://api.github.com/graphql", - expect.objectContaining({ body: expect.stringContaining('"after":"next-page"') }), - ); - }); - - it("does not fetch without a GitHub token", async () => { - vi.stubEnv("GITHUB_SPONSORS_TOKEN", ""); - const fetch = vi.fn(); - vi.stubGlobal("fetch", fetch); - - await getSponsors(); - - expect(fetch).not.toHaveBeenCalled(); - }); - - it("falls back when GitHub fails", async () => { - vi.stubEnv("GITHUB_SPONSORS_TOKEN", "test-token"); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500 })); - vi.spyOn(console, "error").mockImplementation(() => undefined); - - await expect(getSponsors()).resolves.not.toHaveLength(0); - }); - - it("stops pagination when GitHub repeats a cursor", async () => { - vi.stubEnv("GITHUB_SPONSORS_TOKEN", "test-token"); - const repeatedPage = { - json: vi - .fn() - .mockResolvedValue( - createGitHubResponse([], { endCursor: "same-cursor", hasNextPage: true }), - ), - ok: true, - }; - const fetch = vi.fn().mockResolvedValue(repeatedPage); - vi.stubGlobal("fetch", fetch); - vi.spyOn(console, "error").mockImplementation(() => undefined); - - await getSponsors(); - - expect(fetch).toHaveBeenCalledTimes(2); - expect(console.error).toHaveBeenCalledWith( - "GitHub sponsors fetch failed", - expect.objectContaining({ message: "GitHub sponsors pagination cursor did not advance" }), - ); - }); -});