From 8d5ee549448300304191bc6f302a84d52716e039 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Fri, 5 Jun 2026 18:14:40 -0400 Subject: [PATCH] feat(pages): add sponsors --- .gitignore | 3 +- .lintstagedrc | 4 + .prettierignore | 3 +- .stylelintignore | 7 + .stylelintrc.mjs | 55 + README.md | 16 +- components/Footer/index.module.css | 91 +- components/Icons/Diamond.jsx | 28 + .../Icon.jsx => Icons/Webpack.jsx} | 0 components/Layout.jsx | 2 + components/SectionHeader/index.jsx | 20 + components/SectionHeader/index.module.css | 39 + components/Sponsors/BackerWall/index.jsx | 52 + .../Sponsors/BackerWall/index.module.css | 21 + components/Sponsors/Card/index.jsx | 86 + components/Sponsors/Card/index.module.css | 101 + components/Sponsors/SortToggle/index.jsx | 29 + .../Sponsors/SortToggle/index.module.css | 5 + components/Sponsors/Tier/index.jsx | 60 + components/Sponsors/Tier/index.module.css | 94 + layouts/Sponsors/index.jsx | 128 + layouts/Sponsors/index.module.css | 66 + package-lock.json | 3538 ++++++++++++++++- package.json | 21 +- pages/about/sponsors.md | 3 + pages/site.json | 4 + scripts/data/sponsors.mjs | 174 + scripts/html/doc-kit.config.mjs | 3 +- styles/index.css | 6 + 29 files changed, 4475 insertions(+), 184 deletions(-) create mode 100644 .stylelintignore create mode 100644 .stylelintrc.mjs create mode 100644 components/Icons/Diamond.jsx rename components/{WebpackLogo/Icon.jsx => Icons/Webpack.jsx} (100%) create mode 100644 components/SectionHeader/index.jsx create mode 100644 components/SectionHeader/index.module.css create mode 100644 components/Sponsors/BackerWall/index.jsx create mode 100644 components/Sponsors/BackerWall/index.module.css create mode 100644 components/Sponsors/Card/index.jsx create mode 100644 components/Sponsors/Card/index.module.css create mode 100644 components/Sponsors/SortToggle/index.jsx create mode 100644 components/Sponsors/SortToggle/index.module.css create mode 100644 components/Sponsors/Tier/index.jsx create mode 100644 components/Sponsors/Tier/index.module.css create mode 100644 layouts/Sponsors/index.jsx create mode 100644 layouts/Sponsors/index.module.css create mode 100644 pages/about/sponsors.md create mode 100644 scripts/data/sponsors.mjs diff --git a/.gitignore b/.gitignore index 27769a56..77b103b6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ out /.cache /pages/api /pages/loaders -/pages/plugins \ No newline at end of file +/pages/plugins +/generated diff --git a/.lintstagedrc b/.lintstagedrc index d5c7e41d..e6a08888 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -2,5 +2,9 @@ "**/*.{js,mjs,ts,tsx,md,mdx,json.yml}": [ "eslint --fix", "prettier --check --write" + ], + "**/*.css": [ + "stylelint --allow-empty-input --fix", + "prettier --check --write" ] } diff --git a/.prettierignore b/.prettierignore index 342d8aad..f604605e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,5 @@ out *.generated.* /.cache /pages/api -versions.json \ No newline at end of file +versions.json +/generated \ No newline at end of file diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 00000000..f604605e --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,7 @@ +node_modules +out +*.generated.* +/.cache +/pages/api +versions.json +/generated \ No newline at end of file diff --git a/.stylelintrc.mjs b/.stylelintrc.mjs new file mode 100644 index 00000000..f96a8c88 --- /dev/null +++ b/.stylelintrc.mjs @@ -0,0 +1,55 @@ +// Ref: https://github.com/nodejs/nodejs.org/blob/main/apps/site/.stylelintrc.mjs + +// These are all the custom `@` (at) rules that we use within our custom PostCSS plugins +const CUSTOM_AT_RULES = [ + // Tailwind-specific at-rules + 'apply', + 'layer', + 'responsive', + 'reference', + 'utility', + 'theme', + 'custom-variant', + 'screen', + 'source', + 'tailwind', + 'variants', +]; + +// Enforces certain selectors to be only in camelCase notation +// We use these for id selectors and classname selectors +const ONLY_ALLOW_CAMEL_CASE_SELECTORS = [ + /^(?:[a-z]+(?:[A-Z][a-z]*)*)$/, + { message: s => `Expected '${s}' to be in camelCase` }, +]; + +export default { + extends: ['stylelint-config-standard'], + plugins: [ + 'stylelint-order', + './node_modules/@node-core/ui-components/dist/stylelint/one-utility-class-per-line.mjs', + ], + rules: { + // Enforces Element Class Names to be camelCase + 'selector-class-pattern': ONLY_ALLOW_CAMEL_CASE_SELECTORS, + // Enforces Element IDs to be camelCase + 'selector-id-pattern': ONLY_ALLOW_CAMEL_CASE_SELECTORS, + // Allow Tailwind-based CSS Rules + 'at-rule-no-unknown': [true, { ignoreAtRules: CUSTOM_AT_RULES }], + // Allow the Global CSS Selector + 'selector-pseudo-class-no-unknown': [ + true, + { ignorePseudoClasses: ['global'] }, + ], + // Enforces the order of the CSS properties to be in alphabetical order + 'order/properties-alphabetical-order': true, + 'no-descending-specificity': null, + // Disables the Level-4 Media Queries; Since they're more exotic and less known + 'media-feature-range-notation': 'prefix', + // Adopts the import notation from `postcss-import` + 'import-notation': 'string', + // Allow the `@apply` at rule as its part of Tailwind + 'at-rule-no-deprecated': [true, { ignoreAtRules: CUSTOM_AT_RULES }], + 'nodejs/one-utility-class-per-line': true, + }, +}; diff --git a/README.md b/README.md index e3c24dc2..77a9f633 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,14 @@ The pipeline is split into three stages: `prepare` fetches each webpack tag into ## Scripts -| Script | Description | -| ---------------------- | ----------------------------------------------------------------- | -| `npm run prep` | Fetch every webpack tag in `versions.json` into `.cache/webpack/` | -| `npm run build:md` | Generate Markdown for every prepared webpack source | -| `npm run build:html` | Convert Markdown to HTML | -| `npm run build` | Full pipeline: prepare → Markdown → HTML | -| `npm run lint` | Run ESLint | -| `npm run format:check` | Check Prettier formatting | +| Script | Description | +| ----------------------- | ----------------------------------------------------------------- | +| `npm run build:prepare` | Fetch every webpack tag in `versions.json` into `.cache/webpack/` | +| `npm run build:md` | Generate Markdown for every prepared webpack source | +| `npm run build:html` | Convert Markdown to HTML | +| `npm run build` | Full pipeline: prepare → Markdown → HTML | +| `npm run lint` | Run ESLint | +| `npm run format:check` | Check Prettier formatting | To generate Markdown for a single webpack source, invoke the processor directly: diff --git a/components/Footer/index.module.css b/components/Footer/index.module.css index 85bd7450..c989b000 100644 --- a/components/Footer/index.module.css +++ b/components/Footer/index.module.css @@ -1,61 +1,122 @@ @reference "../../styles/index.css"; .footer { - @apply border-t border-neutral-900 bg-neutral-950 text-neutral-500; + @apply border-t + border-neutral-900 + bg-neutral-950 + text-neutral-500; } .inner { - @apply max-w-7xl mx-auto px-6 pt-12 pb-6; + @apply max-w-7xl + mx-auto + px-6 + pt-12 + pb-6; } .top { - @apply grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-[1.5fr_repeat(4,1fr)] lg:gap-8; + @apply grid + grid-cols-1 + gap-10 + sm:grid-cols-2 + lg:grid-cols-[1.5fr_repeat(4,1fr)] + lg:gap-8; } .brand { - @apply flex flex-col gap-4 max-w-72; + @apply flex + flex-col + gap-4 + max-w-72; } .logo { - @apply inline-flex items-center gap-2 no-underline; + @apply inline-flex + items-center + gap-2 + no-underline; } .wordmark { - @apply text-lg font-semibold text-neutral-50; + @apply text-lg + font-semibold + text-neutral-50; } .tagline { - @apply m-0 text-sm leading-relaxed text-neutral-600; + @apply m-0 + text-sm + leading-relaxed + text-neutral-600; } .section { - @apply flex flex-col gap-3.5; + @apply flex + flex-col + gap-3.5; } .heading { - @apply m-0 text-xs font-semibold tracking-[0.08em] uppercase text-blue-300; + @apply m-0 + text-xs + font-semibold + tracking-[0.08em] + uppercase + text-blue-300; } .list { - @apply flex flex-col gap-2.5 m-0 p-0 list-none; + @apply flex + flex-col + gap-2.5 + m-0 + p-0 + list-none; } .link { - @apply text-[0.9375rem] text-neutral-300 no-underline transition-colors duration-150 ease-in-out hover:text-blue-300; + @apply text-[0.9375rem] + text-neutral-300 + no-underline + transition-colors + duration-150 + ease-in-out + hover:text-blue-300; } .bottom { - @apply flex flex-col gap-4 items-center mt-10 pt-6 border-t border-neutral-900 sm:flex-row sm:justify-between; + @apply flex + flex-col + gap-4 + items-center + mt-10 + pt-6 + border-t + border-neutral-900 + sm:flex-row + sm:justify-between; } .legal { - @apply m-0 text-sm text-neutral-600; + @apply m-0 + text-sm + text-neutral-600; } .social { - @apply flex items-center gap-4 m-0 p-0 list-none; + @apply flex + items-center + gap-4 + m-0 + p-0 + list-none; } .socialLink { - @apply inline-flex text-neutral-500 transition-colors duration-150 hover:text-neutral-50; + @apply inline-flex + text-neutral-500 + transition-colors + duration-150 + hover:text-neutral-50; } diff --git a/components/Icons/Diamond.jsx b/components/Icons/Diamond.jsx new file mode 100644 index 00000000..f6c7fa5f --- /dev/null +++ b/components/Icons/Diamond.jsx @@ -0,0 +1,28 @@ +/** + * Small diamond/gem glyph used to mark a sponsorship tier. + * + * @param {import('react').SVGProps} props + */ +export default props => ( + +); diff --git a/components/WebpackLogo/Icon.jsx b/components/Icons/Webpack.jsx similarity index 100% rename from components/WebpackLogo/Icon.jsx rename to components/Icons/Webpack.jsx diff --git a/components/Layout.jsx b/components/Layout.jsx index 0e144682..66c1b1c7 100644 --- a/components/Layout.jsx +++ b/components/Layout.jsx @@ -1,9 +1,11 @@ import DefaultLayout from '@node-core/doc-kit/src/generators/web/ui/components/Layout/index.jsx'; import HomeLayout from '../layouts/Home/index.jsx'; +import SponsorsLayout from '../layouts/Sponsors/index.jsx'; import '../styles/index.css'; const LAYOUTS = { home: HomeLayout, + sponsors: SponsorsLayout, }; export default function Layout(props) { diff --git a/components/SectionHeader/index.jsx b/components/SectionHeader/index.jsx new file mode 100644 index 00000000..65b6bffb --- /dev/null +++ b/components/SectionHeader/index.jsx @@ -0,0 +1,20 @@ +import classNames from 'classnames'; + +import styles from './index.module.css'; + +/** + * Centered section heading: an uppercase eyebrow, a title and an optional description. + * + * @param {import('react').ComponentProps<'header'> & { + * eyebrow?: import('react').ReactNode, + * title: import('react').ReactNode, + * description?: import('react').ReactNode, + * }} props + */ +export default ({ eyebrow, title, description, className, ...props }) => ( +
+ {eyebrow &&

{eyebrow}

} +

{title}

+ {description &&

{description}

} +
+); diff --git a/components/SectionHeader/index.module.css b/components/SectionHeader/index.module.css new file mode 100644 index 00000000..64ff20ec --- /dev/null +++ b/components/SectionHeader/index.module.css @@ -0,0 +1,39 @@ +@reference "../../styles/index.css"; + +.header { + @apply mx-auto + flex + max-w-2xl + flex-col + items-center + gap-3 + text-center; +} + +.eyebrow { + @apply m-0 + text-xs + font-semibold + tracking-[0.12em] + uppercase + text-blue-600 + dark:text-blue-400; +} + +.title { + @apply m-0 + text-3xl + font-bold + tracking-tight + text-neutral-900 + sm:text-4xl + dark:text-white; +} + +.description { + @apply m-0 + text-base + leading-relaxed + text-neutral-600 + dark:text-neutral-300; +} diff --git a/components/Sponsors/BackerWall/index.jsx b/components/Sponsors/BackerWall/index.jsx new file mode 100644 index 00000000..896920ef --- /dev/null +++ b/components/Sponsors/BackerWall/index.jsx @@ -0,0 +1,52 @@ +import Avatar from '@node-core/ui-components/Common/AvatarGroup/Avatar'; +import classNames from 'classnames'; + +import styles from './index.module.css'; + +const OC_BASE = 'https://opencollective.com'; + +const initialsOf = name => + name + .split(/\s+/) + .slice(0, 2) + .map(word => word[0]) + .join('') + .toUpperCase(); + +const shuffle = arr => [...arr].sort(() => Math.random() - 0.5); + +/** + * Wall of individual backer avatars with an overflow chip, plus a link to the full list + * on Open Collective. Built on the shared {@link Avatar}. + * + * @param {import('react').ComponentProps<'div'> & { + * backers: Array<{ name: string, slug: string, imageUrl: string|null, allTime: { value: number, tier: string|null } }>, + * limit?: number, + * }} props + */ +export default ({ backers, limit = 100, ...props }) => ( +
+
+ {CLIENT && + shuffle(backers) + .slice(0, limit) + .map(backer => ( + + ))} +
+ + See all backers on Open Collective → + +
+); diff --git a/components/Sponsors/BackerWall/index.module.css b/components/Sponsors/BackerWall/index.module.css new file mode 100644 index 00000000..7b391a32 --- /dev/null +++ b/components/Sponsors/BackerWall/index.module.css @@ -0,0 +1,21 @@ +@reference "../../../styles/index.css"; + +.wall { + @apply flex + flex-wrap + justify-center + gap-2; +} + +.link { + @apply mt-6 + block + text-center + text-sm + font-medium + text-blue-600 + no-underline + hover:text-blue-700 + dark:text-blue-400 + dark:hover:text-blue-300; +} diff --git a/components/Sponsors/Card/index.jsx b/components/Sponsors/Card/index.jsx new file mode 100644 index 00000000..4f8782c3 --- /dev/null +++ b/components/Sponsors/Card/index.jsx @@ -0,0 +1,86 @@ +import classNames from 'classnames'; +import Avatar from '@node-core/ui-components/Common/AvatarGroup/Avatar'; + +import styles from './index.module.css'; + +const formatUSD = value => `$${Math.round(value).toLocaleString('en-US')}`; + +const amountLabel = (sponsor, metric) => + metric === 'monthly' + ? `${formatUSD(sponsor.monthly.value)} / mo` + : `${formatUSD(sponsor.allTime.value)} total`; + +/** + * A single sponsor tile. The visual weight scales with `size` so higher tiers read larger, + * matching the sponsor wall in the design. `lg` renders an expanded card (used by the top + * tier); the smaller sizes render compact rows. + * + * @param {import('react').ComponentProps<'a'> & { + * sponsor: { name: string, slug: string, imageUrl: string|null, url: string, monthly: { value: number, tier: string|null }, allTime: { value: number, tier: string|null }, description?: string }, + * size?: 'lg'|'md'|'sm'|'xs', + * metric?: 'monthly'|'allTime', + * }} props + */ +export default function SponsorCard({ + sponsor, + size = 'md', + metric = 'monthly', + className, + ...props +}) { + const linkProps = { + href: sponsor.url, + target: '_blank', + rel: 'noreferrer noopener', + ...props, + }; + + if (size === 'lg') { + return ( + + {CLIENT && ( + + )} + {sponsor.name} + {sponsor.description && ( +

{sponsor.description}

+ )} +
+ {amountLabel(sponsor, metric)} + Visit → +
+
+ ); + } + + return ( + +
+ {CLIENT && ( + + )} +
+ + {sponsor.name} + {size !== 'xs' && ( + {amountLabel(sponsor, metric)} + )} + + {size !== 'xs' && ( + + )} +
+ ); +} diff --git a/components/Sponsors/Card/index.module.css b/components/Sponsors/Card/index.module.css new file mode 100644 index 00000000..41ebebcb --- /dev/null +++ b/components/Sponsors/Card/index.module.css @@ -0,0 +1,101 @@ +@reference "../../../styles/index.css"; + +.card { + @apply flex + rounded-xl + border + border-neutral-200 + bg-white + no-underline + transition-colors + duration-150 + hover:border-blue-300 + hover:bg-blue-50/40 + dark:border-neutral-800 + dark:bg-neutral-900 + dark:hover:border-blue-400/60 + dark:hover:bg-blue-950/40; + + /* Expanded card used by the top tier. */ + &.lg { + @apply flex-col + items-start + gap-3 + p-5; + + & .name { + @apply text-base; + } + } + + /* Compact rows for the lower tiers. */ + &.md { + @apply items-center + gap-3 + p-3.5; + } + + &.sm { + @apply items-center + gap-2.5 + p-3; + } + + &.xs { + @apply items-center + gap-2 + p-2.5; + } +} + +.body { + @apply flex + min-w-0 + flex-col!; +} + +.name { + @apply truncate + text-sm + font-semibold + text-neutral-900 + dark:text-white; +} + +.amount { + @apply text-xs + text-neutral-500 + dark:text-neutral-400; +} + +.description { + @apply m-0 + text-sm + leading-relaxed + text-neutral-600 + dark:text-neutral-300; +} + +.footer { + @apply mt-auto + flex + w-full + items-center + justify-between + pt-1; +} + +.visit { + @apply text-sm + font-medium + text-blue-600 + dark:text-blue-400; +} + +.chevron { + @apply ml-auto + text-xl + leading-none + text-neutral-300 + dark:text-neutral-600; +} diff --git a/components/Sponsors/SortToggle/index.jsx b/components/Sponsors/SortToggle/index.jsx new file mode 100644 index 00000000..dc53f55e --- /dev/null +++ b/components/Sponsors/SortToggle/index.jsx @@ -0,0 +1,29 @@ +import Tabs from '@node-core/ui-components/Common/Tabs'; +import classNames from 'classnames'; + +import styles from './index.module.css'; + +const TABS = [ + { key: 'monthly', label: 'Sort by Monthly' }, + { key: 'allTime', label: 'Sort by All-Time' }, +]; + +/** + * Controlled segmented control that picks the metric used to rank sponsors. Built on the + * shared {@link Tabs} primitive. + * + * @param {import('react').ComponentProps & { + * value: 'monthly'|'allTime', + * onChange: (value: 'monthly'|'allTime') => void, + * }} props + */ +export default ({ value, onChange, className, ...props }) => ( + +); diff --git a/components/Sponsors/SortToggle/index.module.css b/components/Sponsors/SortToggle/index.module.css new file mode 100644 index 00000000..a9a7d09a --- /dev/null +++ b/components/Sponsors/SortToggle/index.module.css @@ -0,0 +1,5 @@ +@reference "../../../styles/index.css"; + +.toggle { + @apply inline-flex; +} diff --git a/components/Sponsors/Tier/index.jsx b/components/Sponsors/Tier/index.jsx new file mode 100644 index 00000000..68243456 --- /dev/null +++ b/components/Sponsors/Tier/index.jsx @@ -0,0 +1,60 @@ +import classNames from 'classnames'; + +import Badge from '@node-core/ui-components/Common/Badge'; + +import DiamondIcon from '../../Icons/Diamond.jsx'; +import SponsorCard from '../Card/index.jsx'; + +import styles from './index.module.css'; + +/** + * One sponsorship tier: a labelled header (icon, name, count, price) above a responsive + * grid of {@link SponsorCard}s. Renders nothing when the tier has no sponsors. + * + * @param {import('react').ComponentProps<'section'> & { + * tier: string, + * label: string, + * price: string, + * cardSize: 'lg'|'md'|'sm'|'xs', + * sponsors: Array, + * metric: 'monthly'|'allTime', + * }} props + */ +export default function SponsorTier({ + tier, + label, + price, + cardSize, + sponsors, + metric, + className, + ...props +}) { + if (!sponsors.length) return null; + + return ( +
+
+ + + {label} + + + {String(sponsors.length)} + + {price} +
+ +
+ {sponsors.map(sponsor => ( + + ))} +
+
+ ); +} diff --git a/components/Sponsors/Tier/index.module.css b/components/Sponsors/Tier/index.module.css new file mode 100644 index 00000000..d901b1d2 --- /dev/null +++ b/components/Sponsors/Tier/index.module.css @@ -0,0 +1,94 @@ +@reference "../../../styles/index.css"; + +.tier { + @apply flex + flex-col + gap-4; +} + +.header { + @apply flex + items-center + gap-3 + border-b + border-neutral-200 + pb-3 + dark:border-neutral-800; +} + +.label { + @apply inline-flex + items-center + gap-2 + text-base + font-semibold + text-neutral-900 + dark:text-white; +} + +.icon { + @apply text-lg; +} + +.price { + @apply ml-auto + text-sm + text-neutral-500 + dark:text-neutral-400; +} + +/* Tier accent colors for the diamond glyph (lightened on dark surfaces). */ +.platinum { + & .icon { + @apply text-blue-400; + } +} + +.gold { + & .icon { + @apply text-amber-500 + dark:text-amber-400; + } +} + +.silver { + & .icon { + @apply text-neutral-400 + dark:text-neutral-300; + } +} + +.bronze { + & .icon { + @apply text-orange-700 + dark:text-orange-400; + } +} + +.grid { + @apply grid + grid-cols-1 + gap-4; + + &.platinum { + @apply sm:grid-cols-2; + } + + &.gold { + @apply sm:grid-cols-2 + lg:grid-cols-3; + } + + &.silver { + @apply grid-cols-2 + sm:grid-cols-3 + lg:grid-cols-4; + } + + &.bronze { + @apply grid-cols-2 + gap-3 + sm:grid-cols-3 + lg:grid-cols-6; + } +} diff --git a/layouts/Sponsors/index.jsx b/layouts/Sponsors/index.jsx new file mode 100644 index 00000000..3c07c66d --- /dev/null +++ b/layouts/Sponsors/index.jsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; + +import BaseButton from '@node-core/ui-components/Common/BaseButton'; + +import NavBar from '../../components/NavBar.jsx'; +import Footer from '../../components/Footer/index.jsx'; +import SectionHeader from '../../components/SectionHeader/index.jsx'; +import SponsorTier from '../../components/Sponsors/Tier/index.jsx'; +import BackerWall from '../../components/Sponsors/BackerWall/index.jsx'; +import SortToggle from '../../components/Sponsors/SortToggle/index.jsx'; +import data from '#theme/sponsors' with { type: 'json' }; + +import styles from './index.module.css'; + +const OC_URL = 'https://opencollective.com/webpack'; +const TIERS = [ + { + tier: 'platinum', + label: 'Platinum', + price: { monthly: '$2,500+ / month', allTime: '$50,000+ all-time' }, + cardSize: 'lg', + }, + { + tier: 'gold', + label: 'Gold', + price: { monthly: '$500 / month', allTime: '$10,000+ all-time' }, + cardSize: 'md', + }, + { + tier: 'silver', + label: 'Silver', + price: { monthly: '$100 / month', allTime: '$2,000+ all-time' }, + cardSize: 'sm', + }, + { + tier: 'bronze', + label: 'Bronze', + price: { monthly: '$10 / month', allTime: '$200+ all-time' }, + cardSize: 'xs', + }, +]; + +const sortByMetric = (list, metric) => + [...list].sort((a, b) => b[metric].value - a[metric].value); + +/** + * Group sponsors into tier buckets using each metric's precomputed tier. + * Sponsors with no tier for the active metric (e.g. one-time backers when + * sorting by monthly) are omitted entirely. + */ +const bucketSponsors = (sponsors, metric) => { + const buckets = { platinum: [], gold: [], silver: [], bronze: [] }; + for (const sponsor of sortByMetric(sponsors, metric)) { + const tier = sponsor[metric].tier; + if (!tier) continue; + buckets[tier].push(sponsor); + } + return buckets; +}; + +/** + * Sponsors page layout. Lists Open Collective sponsors by tier with a control to re-rank + * them by recurring monthly amount or all-time contribution, plus a backer wall and CTA. + * + * @param {{ metadata: object }} props + */ +export default function SponsorsLayout({ metadata }) { + const [metric, setMetric] = useState('monthly'); + + const buckets = bucketSponsors(data.sponsors, metric); + + return ( + <> + + +
+
+
+ +
+ + View on Open Collective + +
+
+ +
+
+ {TIERS.map(tier => ( + + ))} +
+
+
+ +
+
+ +
+ +
+
+
+
+ +