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.
+
+
+
+
+
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..aee4371
--- /dev/null
+++ b/src/components/DonorList/index.jsx
@@ -0,0 +1,124 @@
+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}
+
+
+
+
+ | Date |
+ Donor |
+
+ Amount (USD)
+ |
+ Type |
+
+
+
+ {donations
+ .filter((d) => new Date(d.date).getUTCFullYear() === year)
+ .map((donation) => (
+
+ |
+
+ |
+ {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;
+ }
+}