From 66a0c508356d8f0f6a7ffa1cc9789381ff11b714 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 8 Sep 2026 22:28:29 -0700 Subject: [PATCH 1/2] Add donor acknowledgments powered by HCB --- docs/donors.mdx | 27 +++++ docs/sponsor.md | 2 + sidebars.ts | 5 +- src/components/DonorList/donations.mjs | 62 ++++++++++ src/components/DonorList/index.jsx | 129 +++++++++++++++++++++ src/components/DonorList/styles.module.css | 45 +++++++ 6 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 docs/donors.mdx create mode 100644 src/components/DonorList/donations.mjs create mode 100644 src/components/DonorList/index.jsx create mode 100644 src/components/DonorList/styles.module.css diff --git a/docs/donors.mdx b/docs/donors.mdx new file mode 100644 index 0000000..dec3cf7 --- /dev/null +++ b/docs/donors.mdx @@ -0,0 +1,27 @@ +--- +title: Donors +description: Thank you to the people and organizations supporting D2. +--- + +import DonorList from "@site/src/components/DonorList"; +import styles from "@site/src/components/DonorList/styles.module.css"; + +# Donors + +

+ Thank you, truly. +

+ +
+ + Donate to D2 + + + How donations are used + +
+ + diff --git a/docs/sponsor.md b/docs/sponsor.md index 17283d5..8b7873f 100644 --- a/docs/sponsor.md +++ b/docs/sponsor.md @@ -23,6 +23,8 @@ ## Where the money goes +Thank you to [our donors](/donors/) for supporting D2. + D2 is free and open source. Donations support: diff --git a/sidebars.ts b/sidebars.ts index a6951a5..1181f03 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -158,9 +158,10 @@ const sidebars: SidebarsConfig = { "tour/troubleshoot", "tour/help", { - type: "doc", - id: "sponsor", + type: "category", label: "Sponsor D2", + link: { type: "doc", id: "sponsor" }, + items: ["donors"], }, ], releasesSidebar: [ diff --git a/src/components/DonorList/donations.mjs b/src/components/DonorList/donations.mjs new file mode 100644 index 0000000..c176737 --- /dev/null +++ b/src/components/DonorList/donations.mjs @@ -0,0 +1,62 @@ +export const DONATIONS_URL = + "https://hcb.hackclub.com/api/v3/organizations/d2/donations"; + +export async function fetchDonations(signal, request = fetch) { + const donations = new Map(); + const perPage = 50; + let page = 1; + + while (true) { + const response = await request( + `${DONATIONS_URL}?page=${page}&per_page=${perPage}`, + { signal, credentials: "omit" }, + ); + if (!response.ok) throw new Error("Could not load donations"); + const records = await response.json(); + if (!Array.isArray(records)) + throw new Error("Unexpected donation response"); + + for (const donation of records) { + if (!["deposited", "in_transit"].includes(donation.status)) continue; + if ( + typeof donation.id !== "string" || + !Number.isSafeInteger(donation.amount_cents) || + donation.amount_cents <= 0 || + typeof donation.date !== "string" || + !Number.isFinite(Date.parse(donation.date)) + ) { + throw new Error("Unexpected donation record"); + } + donations.set(donation.id, { + id: donation.id, + name: + donation.donor?.anonymous === false && donation.donor?.name?.trim() + ? donation.donor.name.trim() + : "Anonymous", + date: donation.date, + amount: donation.amount_cents, + recurring: donation.recurring === true, + processing: donation.status === "in_transit", + }); + } + + // HCB exposes pagination headers to browsers. Fall back to page size if + // those headers are unavailable, so the list never silently stops at 50. + const next = response.headers.get("X-Next-Page"); + if (next !== null) { + if (!next.trim()) break; + const nextPage = Number(next); + if (!Number.isInteger(nextPage) || nextPage <= page) { + throw new Error("Unexpected pagination response"); + } + page = nextPage; + } else { + if (records.length < perPage) break; + page += 1; + } + } + + return [...donations.values()].sort( + (a, b) => Date.parse(b.date) - Date.parse(a.date), + ); +} diff --git a/src/components/DonorList/index.jsx b/src/components/DonorList/index.jsx new file mode 100644 index 0000000..4ce709a --- /dev/null +++ b/src/components/DonorList/index.jsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from "react"; +import { fetchDonations } from "./donations.mjs"; +import styles from "./styles.module.css"; + +const dateFormat = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", +}); +const amountFormat = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}); +const fundUrl = "https://hcb.hackclub.com/d2/donations"; + +export default function DonorList() { + const [donations, setDonations] = useState(null); + const [error, setError] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let active = true; + const timeout = setTimeout(() => controller.abort(), 20000); + setError(false); + + fetchDonations(controller.signal) + .then((records) => { + if (active) setDonations(records); + }) + .catch(() => { + if (active) setError(true); + }) + .finally(() => clearTimeout(timeout)); + + return () => { + active = false; + clearTimeout(timeout); + controller.abort(); + }; + }, [attempt]); + + if (error) { + return ( +
+

+ We couldn’t load the donor list. You can still view all donations on{" "} + HCB. +

+ +
+ ); + } + + if (donations === null) { + return ( +

+ Loading donations… +

+ ); + } + + if (donations.length === 0) { + return ( +

+ Our donor list will appear here as donations arrive. +

+ ); + } + + const years = [ + ...new Set(donations.map((d) => new Date(d.date).getUTCFullYear())), + ]; + + return ( +
+ {years.map((year) => ( +
+

{year}

+
+ + + + + + + + + + + {donations + .filter((d) => new Date(d.date).getUTCFullYear() === year) + .map((donation) => ( + + + + + + + ))} + +
DateDonor + Amount (USD) + Type
+ + {donation.name} + {amountFormat.format(donation.amount / 100)} + + {donation.recurring ? "Monthly" : "One-time"} +
+
+
+ ))} +

+ Updated automatically from D2’s public HCB fund. +

+
+ ); +} diff --git a/src/components/DonorList/styles.module.css b/src/components/DonorList/styles.module.css new file mode 100644 index 0000000..7299100 --- /dev/null +++ b/src/components/DonorList/styles.module.css @@ -0,0 +1,45 @@ +.list { + margin: 2rem 0; +} + +.tableContainer { + overflow-x: auto; + margin-bottom: var(--ifm-spacing-vertical); +} + +.table { + display: table; + width: 100%; + margin-bottom: 0; +} + +.table .amount { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.date, +.type { + white-space: nowrap; +} + +.name { + overflow-wrap: anywhere; + min-width: 10rem; +} + +.source { + font-size: 0.875rem; + color: var(--ifm-color-emphasis-700); +} + +.notice { + margin: 2rem 0; +} + +@media (min-width: 577px) { + .actions :global(.button) { + flex-basis: auto; + } +} From cc23b7e781c28be3f4144216159cf833bb764b4d Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 8 Sep 2026 22:30:47 -0700 Subject: [PATCH 2/2] Format donor list component --- src/components/DonorList/index.jsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/components/DonorList/index.jsx b/src/components/DonorList/index.jsx index 4ce709a..aee4371 100644 --- a/src/components/DonorList/index.jsx +++ b/src/components/DonorList/index.jsx @@ -73,9 +73,7 @@ export default function DonorList() { ); } - const years = [ - ...new Set(donations.map((d) => new Date(d.date).getUTCFullYear())), - ]; + const years = [...new Set(donations.map((d) => new Date(d.date).getUTCFullYear()))]; return (
@@ -83,10 +81,7 @@ export default function DonorList() {

{year}

- +
Date