Skip to content

chore: partners/sponsors page #7991

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
33dcf64
chore: add partner section on homepage
bjohansebas Jul 21, 2025
5094798
chore: template for partner page
bjohansebas Jul 21, 2025
6a5568c
ui: add name tooltip for partnerIcon
bjohansebas Jul 21, 2025
e646e29
chore: implement list of logos on download and partners page
bjohansebas Jul 22, 2025
8302e8a
chore: add partner list on security blog post
bjohansebas Jul 22, 2025
2f0e2be
feat: enhance PartnersLogoList to filter partners by category and all…
bjohansebas Jul 22, 2025
435eb91
feat: add new partner categories and update partner weights
bjohansebas Jul 22, 2025
5fd31f8
feat: update partner selection logic to use weighted randomization
bjohansebas Jul 22, 2025
91b6535
feat: update button href to include UTM parameters for tracking
bjohansebas Jul 22, 2025
5c79be3
feat: implement padding to a company tooltip
bjohansebas Jul 23, 2025
8c55aaf
chore: implement supporters component
bjohansebas Jul 24, 2025
86cebfa
feat: enhance partners page with detailed descriptions
bjohansebas Jul 24, 2025
9570258
ui-components: add more partner logos
bjohansebas Jul 24, 2025
96bc9f9
ui-components: add more partners logos
bjohansebas Jul 24, 2025
edf5259
clean up
bjohansebas Jul 25, 2025
625ed9c
feat: update supporters and partners sections
bjohansebas Jul 26, 2025
cdaa6d6
feat: enhance partner list functionality with sorting options
bjohansebas Jul 26, 2025
d737364
fix typos and small nits
bjohansebas Jul 30, 2025
4da9fad
refactor: update partners and supporters sections by removing unused …
bjohansebas Jul 30, 2025
26c9f8c
feat: add comprehensive partners documentation outlining addition, re…
bjohansebas Jul 30, 2025
844cd09
style: center-align partner support heading and adjust text balance
bjohansebas Jul 30, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@reference "../../../../styles/index.css";

.partnerIcon {
@apply h-9
w-auto
min-w-9
p-2;

svg {
@apply !h-4
!w-auto;
}
}
30 changes: 30 additions & 0 deletions apps/site/components/Common/Partners/PartnerIcon/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Skeleton from '@node-core/ui-components/Common/Skeleton';
import Tooltip from '@node-core/ui-components/Common/Tooltip';
import type { ComponentProps, FC } from 'react';
import { cloneElement } from 'react';

import Button from '#site/components/Common/Button';
import type { Partners } from '#site/types';

import style from './index.module.css';

type PartnersIconProps = Partners & ComponentProps<typeof Skeleton>;

const PartnersIcon: FC<PartnersIconProps> = ({ name, href, logo, loading }) => (
<Skeleton loading={loading} className="size-9 p-2">
<Tooltip content={<span className="px-2">{name}</span>}>
<Button
kind="secondary"
href={`${href}/?utm_source=nodejs-website&utm_medium=Link`}
className={style.partnerIcon}
>
{cloneElement(logo, {
width: 'auto',
height: '16px',
})}
</Button>
</Tooltip>
</Skeleton>
);

export default PartnersIcon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@reference "../../../../styles/index.css";

.partnerIcon {
@apply flex
h-28
max-h-28
w-auto
min-w-12
items-center
justify-center
rounded-lg
p-6
sm:p-10;

svg {
@apply !h-12
!w-auto;
}
}
27 changes: 27 additions & 0 deletions apps/site/components/Common/Partners/PartnerLogo/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Skeleton from '@node-core/ui-components/Common/Skeleton';
import type { ComponentProps, FC } from 'react';
import { cloneElement } from 'react';

import Button from '#site/components/Common/Button';
import type { Partners } from '#site/types';

import style from './index.module.css';

type PartnersLogoProps = Partners & ComponentProps<typeof Skeleton>;

const PartnersLogo: FC<PartnersLogoProps> = ({ href, logo, loading }) => (
<Skeleton loading={loading} className="h-28 w-full p-2">
<Button
kind="secondary"
href={`${href}/?utm_source=nodejs-website&utm_medium=Link`}
className={style.partnerIcon}
>
{cloneElement(logo, {
width: 'auto',
height: '16px',
})}
</Button>
</Skeleton>
);

export default PartnersLogo;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@reference "../../../../styles/index.css";

.partnersIconList {
@apply flex
flex-row
flex-wrap
items-center
gap-2;
}
69 changes: 69 additions & 0 deletions apps/site/components/Common/Partners/PartnersIconList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import type { FC } from 'react';

import { ICON_PARTNERS } from '#site/next.partners.constants';
import type { PartnerCategory, Partners } from '#site/types';

import PartnerIcon from '../PartnerIcon';
import style from './index.module.css';
import { randomPartnerList } from '../utils';

type PartnersIconListProps = {
maxLength?: number;
categories?: PartnerCategory;
};

const PartnersIconList: FC<PartnersIconListProps> = ({
maxLength = 6,
categories,
}) => {
const initialRenderer = useRef(true);

const [seedList, setSeedList] = useState<Array<Partners>>(
ICON_PARTNERS.slice(0, maxLength)
);

useEffect(() => {
// We intentionally render the initial default "mock" list of sponsors
// to have the Skeletons loading, and then we render the actual list
// after an enough amount of time has passed to give a proper sense of Animation
// We do this client-side effect, to ensure that a random-amount of sponsors is renderered
// on every page load. Since our page is natively static, we need to ensure that
// on the client-side we have a random amount of sponsors rendered.
// Although whilst we are deployed on Vercel or other environment that supports ISR
// (Incremental Static Generation) whose would invalidate the cache every 5 minutes
// We want to ensure that this feature is compatible on a full-static environment
const renderSponsorsAnimation = setTimeout(() => {
initialRenderer.current = false;

setSeedList(
randomPartnerList(ICON_PARTNERS, {
pick: maxLength,
dateSeed: 5,
category: categories,
})
);
}, 0);

return () => clearTimeout(renderSponsorsAnimation);
// We only want this to run once on initial render
// We don't really care if the props change as realistically they shouldn't ever
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div className={style.partnersIconList}>
{seedList.map((partner, index) => (
<PartnerIcon
{...partner}
key={index}
loading={initialRenderer.current}
/>
))}
</div>
);
};

export default PartnersIconList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@reference "../../../../styles/index.css";

.partnersLogoList {
@apply grid
w-full
grid-cols-[repeat(auto-fill,minmax(240px,1fr))]
gap-4;
}
77 changes: 77 additions & 0 deletions apps/site/components/Common/Partners/PartnersLogoList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import type { FC } from 'react';

import { LOGO_PARTNERS } from '#site/next.partners.constants';
import type { PartnerCategory, Partners } from '#site/types';

import PartnerLogo from '../PartnerLogo';
import style from './index.module.css';
import { randomPartnerList } from '../utils';

type PartnersLogoListProps = {
maxLength?: number;
categories?: PartnerCategory;
sort?: 'name' | 'weight';
};

const PartnersLogoList: FC<PartnersLogoListProps> = ({
maxLength = 3,
sort = 'weight',
categories,
}) => {
const initialRenderer = useRef(true);

const [seedList, setSeedList] = useState<Array<Partners>>(() => {
if (maxLength === null) {
return LOGO_PARTNERS.filter(
partner => !categories || partner.categories.includes(categories)
);
}
return LOGO_PARTNERS.slice(0, maxLength);
});

useEffect(() => {
// We intentionally render the initial default "mock" list of sponsors
// to have the Skeletons loading, and then we render the actual list
// after an enough amount of time has passed to give a proper sense of Animation
// We do this client-side effect, to ensure that a random-amount of sponsors is renderered
// on every page load. Since our page is natively static, we need to ensure that
// on the client-side we have a random amount of sponsors rendered.
// Although whilst we are deployed on Vercel or other environment that supports ISR
// (Incremental Static Generation) whose would invalidate the cache every 5 minutes
// We want to ensure that this feature is compatible on a full-static environment
const renderSponsorsAnimation = setTimeout(() => {
initialRenderer.current = false;

setSeedList(
randomPartnerList(LOGO_PARTNERS, {
pick: maxLength,
dateSeed: 5,
category: categories,
sort: sort,
})
);
}, 0);

return () => clearTimeout(renderSponsorsAnimation);
// We only want this to run once on initial render
// We don't really care if the props change as realistically they shouldn't ever
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div className={style.partnersLogoList}>
{seedList.map((partner, index) => (
<PartnerLogo
{...partner}
key={index}
loading={initialRenderer.current}
/>
))}
</div>
);
};

export default PartnersLogoList;
92 changes: 92 additions & 0 deletions apps/site/components/Common/Partners/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { PartnerCategory, Partners } from '#site/types/partners.js';

function randomPartnerList(
partners: Array<Partners>,
config: {
/**
* Number of partners to pick from the list.
* If null, all partners will be returned.
*/
pick?: number | null;
/**
* Date seed to use for the randomization.
* This is used to ensure that the same partners are returned for the same date.
*/
dateSeed?: number;
/**
* Category of partners to filter by.
* If not provided, all partners will be returned.
*/
category?: PartnerCategory;
/**
* Whether to randomize the partners or not.
*/
sort?: 'name' | 'weight' | null;
}
) {
const { pick = 4, dateSeed = 5, category, sort = 'weight' } = config;

const filteredPartners = [...partners].filter(partner => {
return !category || partner.categories.includes(category);
});

if (sort === null) {
return pick !== null ? filteredPartners.slice(0, pick) : filteredPartners;
}

if (sort === 'name') {
const shuffled = [...filteredPartners].sort((a, b) =>
a.name.localeCompare(b.name)
);

return pick !== null ? shuffled.slice(0, pick) : shuffled;
}

const now = new Date();
const minutes = Math.floor(now.getUTCMinutes() / dateSeed) * dateSeed;

const fixedTime = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours(),
minutes,
0,
0
)
);

// We create a seed from the rounded date (timestamp in ms)
const seed = fixedTime.getTime();
const rng = mulberry32(seed);

const weightedPartners = filteredPartners.flatMap(partner => {
const weight = partner.weight;
return Array(weight).fill(partner);
});

// Create a copy of the array to avoid modifying the original
const shuffled = [...weightedPartners].sort(() => rng() - 0.5);

// Remove duplicates while preserving order
const unique = Array.from(new Set(shuffled));

if (pick !== null) {
return unique.slice(0, pick);
}

return unique;
}

// This function returns a random list of partners based on a fixed time seed
function mulberry32(seed: number) {
return function () {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

export { randomPartnerList };
26 changes: 26 additions & 0 deletions apps/site/components/Common/Supporters/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import Avatar from '@node-core/ui-components/Common/AvatarGroup/Avatar';
import type { FC } from 'react';
import { use } from 'react';

import type { Supporters } from '#site/types';

type SupportersProps = {
supporters: Promise<Array<Supporters>>;
};

// TODO: Sort supporters by all time contribution amount and link to their Open Collective page
const SupportersList: FC<SupportersProps> = ({ supporters }) => {
const supportersList = use(supporters);

return (
<div className="flex max-w-full flex-wrap items-center justify-center gap-1">
{supportersList.map(({ name, image }, i) => (
<Avatar nickname={name} image={image} key={`${name}-${i}`} />
))}
</div>
);
};

export default SupportersList;
18 changes: 18 additions & 0 deletions apps/site/components/withSupporters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { FC, PropsWithChildren } from 'react';

import { fetchOpenCollectiveData } from '#site/next-data/generators/supportersData.mjs';
import type { Supporters } from '#site/types';

import SupportersList from './Common/Supporters';

const WithSupporters: FC<PropsWithChildren> = () => {
const supporters = fetchOpenCollectiveData() as Promise<Array<Supporters>>;

return (
<div className="flex max-w-full flex-wrap items-center gap-1">
<SupportersList supporters={supporters} />
</div>
);
};

export default WithSupporters;
Loading
Loading