mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-10 13:18:25 +00:00
* feat(docs): add sponsor page Add a /sponsor page to lucide.dev with sponsor tiers, an amount picker, a sponsor wall and a FAQ. - Fetch active Open Collective sponsors at build time into sponsorsData.json, falling back to an empty list when the API fails - Group sponsors by monthly amount so the wall keeps working while the Open Collective tiers are renamed - Add reusable SponsorLogoGrid and SponsorAvatars components - Add general Faq/FaqItem components fed by `faq` frontmatter, which also emits FAQPage structured data - Link the page from the nav, footer and the home page sponsor card Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(docs): add navbar sponsor button, more sponsor stats and tier links - Replace the Sponsor nav text link with a dedicated heart button in the navbar, shown icon-only at tablet widths where the menu is crowded - Show weekly npm downloads across all Lucide packages and the all-time Open Collective sponsor count in the sponsor page hero - Add tierLinks.ts for direct Open Collective and GitHub Sponsors tier checkout links, falling back to the general pages while empty Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(docs): update team member titles and add funding manifest * update tier links * Adjust hero * Add motion counter to hero * Add custom option * Add scroll padding * Fix reduce motion sponsor page * Refactor sponsor tier links into tier data Store only the Open Collective tier slug and GitHub Sponsors tier id on each tier and derive the checkout URLs from them, so amounts are no longer duplicated in the links. Removes tierLinks.ts and the circular import between it and tiers.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G9u8FefqMHXb8HoeQ8LhcU * Set transition duration to zero * Add social links * Fix stikcybar * Format code * Refactor SponsorAmountPicker and SponsorHeroAnimation Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix sponsor animation * Adjust tiers * Update readmes * Update sponsors * Update sponsors * Update sponsors * Update logo gris size * Add breaking space * Update logos --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
165 lines
4.7 KiB
TypeScript
165 lines
4.7 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { getTierForYearlyAmount } from '../../.vitepress/theme/components/sponsor/tiers.ts';
|
|
|
|
const COLLECTIVE_SLUG = 'lucide-icons';
|
|
const OPEN_COLLECTIVE_API = 'https://api.opencollective.com/graphql/v2';
|
|
|
|
const sponsorLogosPath = join(import.meta.dirname, '../../public/sponsors');
|
|
|
|
const GRACE_PERIOD_DAYS = 31;
|
|
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
|
|
export interface Account {
|
|
slug: string;
|
|
name: string;
|
|
website: string | null;
|
|
imageUrl: string | null;
|
|
description: string | null;
|
|
}
|
|
|
|
interface Order {
|
|
status: 'ACTIVE' | 'CANCELLED' | 'ERROR' | 'PAUSED';
|
|
amount: { valueInCents: number | null } | null;
|
|
frequency: 'MONTHLY' | 'YEARLY' | 'ONETIME' | null;
|
|
nextChargeDate: string | null;
|
|
fromAccount: Account;
|
|
}
|
|
|
|
interface CollectiveResponse {
|
|
data?: {
|
|
account: {
|
|
stats: { contributorsCount: number | null } | null;
|
|
orders: { nodes: Order[] };
|
|
} | null;
|
|
};
|
|
errors?: { message: string }[];
|
|
}
|
|
|
|
const query = /* GraphQL */ `
|
|
query Sponsors($slug: String!) {
|
|
account(slug: $slug) {
|
|
stats {
|
|
contributorsCount
|
|
}
|
|
orders(filter: INCOMING, status: [ACTIVE, CANCELLED, ERROR, PAUSED], limit: 1000) {
|
|
nodes {
|
|
status
|
|
amount {
|
|
valueInCents
|
|
}
|
|
frequency
|
|
nextChargeDate
|
|
fromAccount {
|
|
slug
|
|
name
|
|
website
|
|
imageUrl
|
|
description
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
export async function fetchCollective() {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (process.env.OPENCOLLECTIVE_TOKEN) {
|
|
headers['Personal-Token'] = process.env.OPENCOLLECTIVE_TOKEN;
|
|
}
|
|
|
|
const response = await fetch(OPEN_COLLECTIVE_API, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ query, variables: { slug: COLLECTIVE_SLUG } }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Open Collective API responded with ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const { data, errors } = (await response.json()) as CollectiveResponse;
|
|
if (errors?.length) {
|
|
throw new Error(
|
|
`Open Collective API errors: ${errors.map(({ message }) => message).join(', ')}`,
|
|
);
|
|
}
|
|
if (!data?.account) {
|
|
throw new Error(`Could not find Open Collective account "${COLLECTIVE_SLUG}"`);
|
|
}
|
|
|
|
return data.account;
|
|
}
|
|
|
|
/**
|
|
* Active contributions, plus recurring ones that stopped (cancelled, failed or paused) less than
|
|
* a month after their next payment was due. A failed payment doesn't drop a sponsor right away.
|
|
*/
|
|
function isCurrentSponsor({ status, nextChargeDate }: Order) {
|
|
if (status === 'ACTIVE') return true;
|
|
if (!nextChargeDate) return false;
|
|
|
|
return Date.now() < new Date(nextChargeDate).getTime() + GRACE_PERIOD_DAYS * DAY_IN_MS;
|
|
}
|
|
|
|
function getYearlyAmount({ amount, frequency }: Order) {
|
|
const value = (amount?.valueInCents ?? 0) / 100;
|
|
|
|
return frequency === 'YEARLY' ? value : value * 12;
|
|
}
|
|
|
|
/**
|
|
* The current sponsors with everything they contribute in a year and the tier that qualifies
|
|
* for, highest amount first.
|
|
*/
|
|
export function getCurrentSponsors(orders: Order[]) {
|
|
const yearlyAmounts = new Map<string, { account: Account; yearlyAmount: number }>();
|
|
|
|
orders.filter(isCurrentSponsor).forEach((order) => {
|
|
const sponsor = yearlyAmounts.get(order.fromAccount.slug);
|
|
|
|
if (sponsor) {
|
|
sponsor.yearlyAmount += getYearlyAmount(order);
|
|
} else {
|
|
yearlyAmounts.set(order.fromAccount.slug, {
|
|
account: order.fromAccount,
|
|
yearlyAmount: getYearlyAmount(order),
|
|
});
|
|
}
|
|
});
|
|
|
|
return [...yearlyAmounts.values()]
|
|
.map(({ account, yearlyAmount }) => {
|
|
const roundedAmount = Math.round(yearlyAmount * 100) / 100;
|
|
|
|
return {
|
|
account,
|
|
yearlyAmount: roundedAmount,
|
|
tier: getTierForYearlyAmount(roundedAmount)?.id ?? null,
|
|
};
|
|
})
|
|
.sort(
|
|
(a, b) => b.yearlyAmount - a.yearlyAmount || a.account.name.localeCompare(b.account.name),
|
|
);
|
|
}
|
|
|
|
const findLogo = (fileName: string) =>
|
|
['svg', 'png']
|
|
.map((extension) => `${fileName}.${extension}`)
|
|
.find((file) => existsSync(join(sponsorLogosPath, file)));
|
|
|
|
/**
|
|
* Custom logos in docs/public/sponsors, as paths relative to that directory.
|
|
* A logo in docs/public/sponsors/dark is used in dark mode.
|
|
*/
|
|
export function getLocalLogos(slug: string) {
|
|
return { light: findLogo(slug), dark: findLogo(`dark/${slug}`) };
|
|
}
|
|
|
|
export const getWebsite = ({ slug, website }: Account) =>
|
|
website || `https://opencollective.com/${slug}`;
|
|
|
|
export const getOpenCollectiveLogo = ({ slug, imageUrl }: Account) =>
|
|
imageUrl ?? `https://images.opencollective.com/${slug}/logo/256.png`;
|