Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ out
*.generated.*
/.cache
/pages/api
/generated
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ out
*.generated.*
/.cache
/pages/api
versions.json
versions.json
/generated
30 changes: 30 additions & 0 deletions components/Icons/Diamond.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Small diamond/gem glyph used to mark a sponsorship tier.
*
* @param {import('react').SVGProps<SVGSVGElement>} props
*/
export default function DiamondIcon(props) {
return (
<svg
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
aria-hidden="true"
{...props}
>
<path
d="M5.5 3h13l3 5.2L12 21 2.5 8.2 5.5 3Z"
fill="currentColor"
opacity="0.18"
/>
<path
d="M5.5 3h13l3 5.2L12 21 2.5 8.2 5.5 3Zm0 0 6.5 5.2L18.5 3M2.5 8.2h19M12 21V8.2"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
2 changes: 2 additions & 0 deletions components/Layout.jsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions components/SectionHeader/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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 function SectionHeader({
eyebrow,
title,
description,
className,
...props
}) {
return (
<header {...props} className={classNames(styles.header, className)}>
{eyebrow && <p className={styles.eyebrow}>{eyebrow}</p>}
<h2 className={styles.title}>{title}</h2>
{description && <p className={styles.description}>{description}</p>}
</header>
);
}
17 changes: 17 additions & 0 deletions components/SectionHeader/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@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;
}
52 changes: 52 additions & 0 deletions components/Sponsors/BackerWall/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AvatarGroup from '@node-core/ui-components/Common/AvatarGroup';
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();

/**
* Wall of individual backer avatars with an overflow chip, plus a link to the full list
* on Open Collective. Built on the shared {@link AvatarGroup}.
*
* @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 function BackerWall({
backers,
limit = 48,
className,
...props
}) {
const avatars = backers.map(backer => ({
image: backer.imageUrl ?? undefined,
name: backer.name,
nickname: backer.slug,
fallback: initialsOf(backer.name),
url: `${OC_BASE}/${backer.slug}`,
}));

return (
<div {...props} className={classNames(styles.wall, className)}>
{CLIENT && <AvatarGroup avatars={avatars} limit={limit} size="medium" />}
Comment thread
avivkeller marked this conversation as resolved.
<a
href={`${OC_BASE}/webpack/contributors`}
target="_blank"
rel="noreferrer noopener"
className={styles.link}
>
See all backers on Open Collective &rarr;
</a>
</div>
);
}
9 changes: 9 additions & 0 deletions components/Sponsors/BackerWall/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@reference "../../../styles/index.css";

.wall {
@apply flex flex-col items-center gap-6;
}

.link {
@apply text-sm font-medium text-blue-600 no-underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300;
}
86 changes: 86 additions & 0 deletions components/Sponsors/Card/index.jsx
Original file line number Diff line number Diff line change
@@ -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,
};
Comment thread
avivkeller marked this conversation as resolved.

if (size === 'lg') {
return (
<a
{...linkProps}
className={classNames(styles.card, styles.lg, className)}
>
{CLIENT && (
Comment thread
avivkeller marked this conversation as resolved.
<Avatar
name={sponsor.name}
image={sponsor.imageUrl}
size={'medium'}
/>
)}
<span className={styles.name}>{sponsor.name}</span>
{sponsor.description && (
<p className={styles.description}>{sponsor.description}</p>
)}
<div className={styles.footer}>
<span className={styles.amount}>{amountLabel(sponsor, metric)}</span>
<span className={styles.visit}>Visit &rarr;</span>
</div>
</a>
);
}

return (
<a
{...linkProps}
className={classNames(styles.card, styles[size], className)}
>
<div>
{CLIENT && (
Comment thread
avivkeller marked this conversation as resolved.
<Avatar name={sponsor.name} image={sponsor.imageUrl} size={'small'} />
)}
</div>
<span className={styles.body}>
<span className={styles.name}>{sponsor.name}</span>
{size !== 'xs' && (
<span className={styles.amount}>{amountLabel(sponsor, metric)}</span>
)}
</span>
{size !== 'xs' && (
<span className={styles.chevron} aria-hidden="true">
&rsaquo;
</span>
)}
</a>
);
}
55 changes: 55 additions & 0 deletions components/Sponsors/Card/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
@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;
}

/* 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;
}

.lg .name {
@apply text-base;
}

.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;
}
31 changes: 31 additions & 0 deletions components/Sponsors/SortToggle/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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<typeof Tabs> & {
* value: 'monthly'|'allTime',
* onChange: (value: 'monthly'|'allTime') => void,
* }} props
*/
export default function SortToggle({ value, onChange, className, ...props }) {
return (
<Tabs
aria-label="Sort sponsors by"
{...props}
tabs={TABS}
value={value}
onValueChange={onChange}
className={classNames(styles.toggle, className)}
/>
);
}
5 changes: 5 additions & 0 deletions components/Sponsors/SortToggle/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@reference "../../../styles/index.css";

.toggle {
@apply inline-flex;
}
Loading