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
27 changes: 27 additions & 0 deletions docs/donors.mdx
Original file line number Diff line number Diff line change
@@ -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

<p className="hero__subtitle sponsorSubtitle margin-bottom--lg">
Thank you, truly.
</p>

<div className={`sponsorActions margin-top--lg margin-bottom--lg ${styles.actions}`}>
<a
className="button button--primary button--lg"
href="https://hcb.hackclub.com/donations/start/d2"
>
<span>Donate to D2</span>
</a>
<a className="button button--primary button--outline button--lg" href="/sponsor/">
<span>How donations are used</span>
</a>
</div>

<DonorList />
2 changes: 2 additions & 0 deletions docs/sponsor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
62 changes: 62 additions & 0 deletions src/components/DonorList/donations.mjs
Original file line number Diff line number Diff line change
@@ -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),
);
}
124 changes: 124 additions & 0 deletions src/components/DonorList/index.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.notice} role="status">
<p>
We couldn’t load the donor list. You can still view all donations on{" "}
<a href={fundUrl}>HCB</a>.
</p>
<button
className="button button--secondary button--outline"
onClick={() => setAttempt((value) => value + 1)}
>
<span>Try again</span>
</button>
</div>
);
}

if (donations === null) {
return (
<p className={styles.notice} role="status">
Loading donations…
</p>
);
}

if (donations.length === 0) {
return (
<p className={styles.notice}>
Our donor list will appear here as donations arrive.
</p>
);
}

const years = [...new Set(donations.map((d) => new Date(d.date).getUTCFullYear()))];

return (
<div className={styles.list}>
{years.map((year) => (
<section key={year} aria-labelledby={`donations-${year}`}>
<h2 id={`donations-${year}`}>{year}</h2>
<div className={styles.tableContainer}>
<table className={styles.table} aria-labelledby={`donations-${year}`}>
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Donor</th>
<th scope="col" className={styles.amount}>
Amount (USD)
</th>
<th scope="col">Type</th>
</tr>
</thead>
<tbody>
{donations
.filter((d) => new Date(d.date).getUTCFullYear() === year)
.map((donation) => (
<tr key={donation.id}>
<td className={styles.date}>
<time dateTime={donation.date}>
{dateFormat.format(new Date(donation.date))}
</time>
</td>
<td className={styles.name}>{donation.name}</td>
<td className={styles.amount}>
{amountFormat.format(donation.amount / 100)}
</td>
<td className={styles.type}>
{donation.recurring ? "Monthly" : "One-time"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
<p className={styles.source}>
Updated automatically from <a href={fundUrl}>D2’s public HCB fund</a>.
</p>
</div>
);
}
45 changes: 45 additions & 0 deletions src/components/DonorList/styles.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading