mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-13 13:48:26 +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>
This commit is contained in:
@@ -66,6 +66,7 @@ docs/.vitepress/data/categoriesData.json
|
||||
docs/.vitepress/data/iconDetails
|
||||
docs/.vitepress/data/relatedIcons.json
|
||||
docs/.vitepress/data/brandStopwords.json
|
||||
docs/.vitepress/data/sponsorsData.json
|
||||
docs/.vitepress/data/iconPopularity
|
||||
docs/.vitepress/data/lab/iconNodes
|
||||
docs/.vitepress/data/lab/iconMetaData.ts
|
||||
|
||||
@@ -88,6 +88,7 @@ export default defineConfig({
|
||||
'index.md',
|
||||
'packages.md',
|
||||
'showcase.md',
|
||||
'sponsor.md',
|
||||
'brand-logo-statement.md',
|
||||
'icons/**', // Not working, need investigation
|
||||
],
|
||||
@@ -117,6 +118,7 @@ export default defineConfig({
|
||||
},
|
||||
{ text: 'Packages', link: '/packages' },
|
||||
{ text: 'Showcase', link: '/showcase' },
|
||||
{ text: 'Sponsor', link: '/sponsor' },
|
||||
{ text: 'Merch', link: 'https://merch.lucide.dev/' },
|
||||
],
|
||||
sidebar,
|
||||
|
||||
@@ -79,3 +79,23 @@ export default async function getStructuredData(iconName: string, pageData: Page
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface FaqEntry {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export function getFaqStructuredData(faq: FaqEntry[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faq.map(({ question, answer }) => ({
|
||||
'@type': 'Question',
|
||||
name: question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
import FaqItem from './FaqItem.vue';
|
||||
|
||||
interface FaqEntry {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
/** Defaults to the page's `faq` frontmatter, which also feeds the FAQPage structured data. */
|
||||
items?: FaqEntry[];
|
||||
}>(),
|
||||
{
|
||||
headingLevel: 2,
|
||||
},
|
||||
);
|
||||
|
||||
const { frontmatter } = useData();
|
||||
|
||||
const entries = computed<FaqEntry[]>(() => props.items ?? frontmatter.value.faq ?? []);
|
||||
const headingElement = computed(() => `h${props.headingLevel}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="faq">
|
||||
<component
|
||||
v-if="title"
|
||||
:is="headingElement"
|
||||
class="title"
|
||||
>
|
||||
{{ title }}
|
||||
</component>
|
||||
<div class="items">
|
||||
<FaqItem
|
||||
v-for="entry in entries"
|
||||
:key="entry.question"
|
||||
:question="entry.question"
|
||||
:answer="entry.answer"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.faq {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { chevronDown } from '../../../data/iconNodes';
|
||||
import Icon from '@lucide/vue/src/Icon';
|
||||
|
||||
defineProps<{
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="faq-item">
|
||||
<summary class="question">
|
||||
{{ question }}
|
||||
<Icon
|
||||
:iconNode="chevronDown"
|
||||
name="chevron-down"
|
||||
:size="20"
|
||||
class="chevron"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
<div class="answer">
|
||||
<slot>
|
||||
<p>{{ answer }}</p>
|
||||
</slot>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.faq-item {
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.question {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.question::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.faq-item[open] .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.answer {
|
||||
padding-bottom: 20px;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.answer :slotted(p),
|
||||
.answer p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,7 @@ import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vu
|
||||
height="42"
|
||||
/>
|
||||
<VPButton
|
||||
href="https://opencollective.com/lucide-icons"
|
||||
href="/sponsor"
|
||||
class="sponsor-button"
|
||||
text="Become a sponsor"
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,10 @@ const links = computed(() => [
|
||||
text: 'Contribute',
|
||||
href: '/contribute/',
|
||||
},
|
||||
{
|
||||
text: 'Sponsor',
|
||||
href: '/sponsor',
|
||||
},
|
||||
{
|
||||
text: 'Changelog',
|
||||
href: `${githubLink.value}/releases`,
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import Icon from '@lucide/vue/src/Icon';
|
||||
import { check } from '../../../data/iconNodes';
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
import SponsorSectionHeader from './SponsorSectionHeader.vue';
|
||||
import {
|
||||
GITHUB_SPONSORS_URL,
|
||||
OPEN_COLLECTIVE_URL,
|
||||
TIERS,
|
||||
formatEuro,
|
||||
getPreviousTier,
|
||||
getTierForAmount,
|
||||
} from './tiers';
|
||||
|
||||
type Frequency = 'monthly' | 'once';
|
||||
|
||||
const frequencies: { id: Frequency; label: string }[] = [
|
||||
{ id: 'monthly', label: 'Monthly' },
|
||||
{ id: 'once', label: 'One-time' },
|
||||
];
|
||||
|
||||
const frequency = ref<Frequency>('monthly');
|
||||
const amount = ref(20);
|
||||
// `v-model` on a number input casts to a number, an empty field stays ''.
|
||||
const customAmount = ref<number | ''>('');
|
||||
|
||||
const isMonthly = computed(() => frequency.value === 'monthly');
|
||||
|
||||
const total = computed(() => {
|
||||
const value = customAmount.value === '' ? amount.value : Number(customAmount.value);
|
||||
|
||||
return value > 0 ? value : 0;
|
||||
});
|
||||
|
||||
const tier = computed(() => (isMonthly.value ? getTierForAmount(total.value) : null));
|
||||
|
||||
const amountText = computed(() => formatEuro(total.value));
|
||||
|
||||
const summary = computed(() => {
|
||||
if (total.value === 0) {
|
||||
return { title: 'Choose an amount', perks: 'Pick a preset or enter your own.' };
|
||||
}
|
||||
if (!isMonthly.value) {
|
||||
return {
|
||||
title: `${amountText.value} one-time`,
|
||||
perks: 'A one-time thank-you. Tier perks apply to monthly sponsorships.',
|
||||
};
|
||||
}
|
||||
if (tier.value) {
|
||||
return {
|
||||
title: `${amountText.value} / month · ${tier.value.name}`,
|
||||
perks: tier.value.summary,
|
||||
};
|
||||
}
|
||||
return { title: `${amountText.value} / month`, perks: 'Every bit helps. Thank you!' };
|
||||
});
|
||||
|
||||
const perksPanel = computed(() => {
|
||||
if (tier.value) {
|
||||
const previousTier = getPreviousTier(tier.value.id);
|
||||
|
||||
return {
|
||||
title: tier.value.name,
|
||||
subtitle: `From ${formatEuro(tier.value.monthly)} / month`,
|
||||
perks: [
|
||||
...(previousTier ? [{ text: `Everything in ${previousTier.name}`, inherited: true }] : []),
|
||||
...tier.value.perks.map((text) => ({ text, inherited: false })),
|
||||
],
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
if (!isMonthly.value) {
|
||||
return {
|
||||
title: 'One-time gift',
|
||||
subtitle: total.value > 0 ? `${amountText.value}, once` : 'Any amount',
|
||||
perks: [],
|
||||
message:
|
||||
'Thank you! Tier perks come with monthly sponsorships. Switch to Monthly to see what you would get.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'No tier yet',
|
||||
subtitle: total.value > 0 ? `${amountText.value} / month` : 'Choose an amount',
|
||||
perks: [],
|
||||
message: `Every bit helps. From ${formatEuro(TIERS[0].monthly)} a month you become a ${TIERS[0].name}.`,
|
||||
};
|
||||
});
|
||||
|
||||
const isSelected = (value: number) => customAmount.value === '' && amount.value === value;
|
||||
|
||||
function pickAmount(value: number) {
|
||||
amount.value = value;
|
||||
customAmount.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SponsorSection band>
|
||||
<SponsorSectionHeader
|
||||
title="Choose your support"
|
||||
description="Pick an amount, monthly or one-time. Monthly amounts map to a sponsor tier with its own perks. Lucide stays free and open source either way."
|
||||
/>
|
||||
|
||||
<div class="layout">
|
||||
<div class="card picker">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Frequency"
|
||||
class="frequency"
|
||||
>
|
||||
<button
|
||||
v-for="option in frequencies"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="frequency-option"
|
||||
:class="{ active: frequency === option.id }"
|
||||
:aria-pressed="frequency === option.id"
|
||||
@click="frequency = option.id"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="amounts">
|
||||
<button
|
||||
v-for="option in TIERS"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="amount"
|
||||
:class="{ selected: isSelected(option.monthly) }"
|
||||
:aria-pressed="isSelected(option.monthly)"
|
||||
@click="pickAmount(option.monthly)"
|
||||
>
|
||||
<span class="amount-value">{{ formatEuro(option.monthly) }}</span>
|
||||
<span class="amount-label">{{ isMonthly ? option.name : 'One-time' }}</span>
|
||||
</button>
|
||||
<label
|
||||
class="amount custom"
|
||||
:class="{ selected: customAmount !== '' }"
|
||||
>
|
||||
<span class="amount-label">Custom (€)</span>
|
||||
<input
|
||||
v-model="customAmount"
|
||||
type="number"
|
||||
min="1"
|
||||
inputmode="numeric"
|
||||
placeholder="Other"
|
||||
class="amount-value custom-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-text">
|
||||
<span class="summary-title">{{ summary.title }}</span>
|
||||
<span class="summary-perks">{{ summary.perks }}</span>
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
<VPButton
|
||||
:href="OPEN_COLLECTIVE_URL"
|
||||
text="Open Collective"
|
||||
/>
|
||||
<VPButton
|
||||
:href="GITHUB_SPONSORS_URL"
|
||||
text="GitHub Sponsors"
|
||||
theme="alt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
class="card perks"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="perks-header">
|
||||
<span class="eyebrow">Your perks</span>
|
||||
<h3 class="perks-title">{{ perksPanel.title }}</h3>
|
||||
<span class="perks-subtitle">{{ perksPanel.subtitle }}</span>
|
||||
</div>
|
||||
<ul
|
||||
v-if="perksPanel.perks.length"
|
||||
class="perks-list"
|
||||
>
|
||||
<li
|
||||
v-for="perk in perksPanel.perks"
|
||||
:key="perk.text"
|
||||
class="perk"
|
||||
:class="{ inherited: perk.inherited }"
|
||||
>
|
||||
<Icon
|
||||
:iconNode="check"
|
||||
name="check"
|
||||
:size="18"
|
||||
class="perk-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ perk.text }}
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="perks-message"
|
||||
>
|
||||
{{ perksPanel.message }}
|
||||
</p>
|
||||
<a
|
||||
href="#tiers"
|
||||
class="compare"
|
||||
>
|
||||
Compare all tiers
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</SponsorSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.picker {
|
||||
flex: 999 1 520px;
|
||||
}
|
||||
|
||||
.frequency {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.frequency-option {
|
||||
min-height: 40px;
|
||||
padding: 0 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
transition:
|
||||
color 0.25s,
|
||||
background-color 0.25s;
|
||||
}
|
||||
|
||||
.frequency-option.active {
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
box-shadow: var(--vp-shadow-1);
|
||||
}
|
||||
|
||||
.amounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.amount {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-height: 72px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.25s,
|
||||
background-color 0.25s;
|
||||
}
|
||||
|
||||
.amount:hover {
|
||||
border-color: var(--vp-c-border);
|
||||
}
|
||||
|
||||
.amount.selected {
|
||||
border-color: var(--sponsor-accent);
|
||||
background-color: rgba(245, 101, 101, 0.08);
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.custom {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.custom:focus-within {
|
||||
border-color: var(--sponsor-accent);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
flex: 1 1 280px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.summary-perks {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.perks {
|
||||
flex: 1 1 300px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.perks-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.perks-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.perks-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.perks-list {
|
||||
margin: 0;
|
||||
padding: 16px 0 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.perk {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.perk.inherited {
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.perk-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--sponsor-accent);
|
||||
}
|
||||
|
||||
.perks-message {
|
||||
margin: 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.compare {
|
||||
margin-top: auto;
|
||||
padding: 10px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--sponsor-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.compare:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getSupporters } from './sponsors';
|
||||
import { OPEN_COLLECTIVE_URL } from './tiers';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
||||
/** Maximum number of avatars shown before "and N more". */
|
||||
max?: number;
|
||||
}>(),
|
||||
{
|
||||
title: 'Backers and supporters',
|
||||
headingLevel: 3,
|
||||
max: 48,
|
||||
},
|
||||
);
|
||||
|
||||
const supporters = getSupporters();
|
||||
|
||||
const visibleSupporters = computed(() => supporters.slice(0, props.max));
|
||||
const remainingCount = computed(() => supporters.length - visibleSupporters.value.length);
|
||||
const headingElement = computed(() => `h${props.headingLevel}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="supporters.length"
|
||||
class="avatars"
|
||||
>
|
||||
<component
|
||||
:is="headingElement"
|
||||
class="title"
|
||||
>
|
||||
{{ title }}
|
||||
</component>
|
||||
<div class="list">
|
||||
<a
|
||||
v-for="supporter in visibleSupporters"
|
||||
:key="supporter.slug"
|
||||
:href="supporter.website"
|
||||
:title="supporter.name"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="avatar-link"
|
||||
>
|
||||
<img
|
||||
:src="supporter.avatar"
|
||||
:alt="supporter.name"
|
||||
width="40"
|
||||
height="40"
|
||||
class="avatar"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
v-if="remainingCount > 0"
|
||||
:href="OPEN_COLLECTIVE_URL"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="more"
|
||||
>
|
||||
and {{ remainingCount }} more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-link {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
|
||||
.avatar-link:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border: 2px solid var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.more {
|
||||
padding-left: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.more:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SponsorSection class="cta">
|
||||
<div class="cta-card">
|
||||
<h2 class="title">Built something with Lucide?</h2>
|
||||
<p class="description">
|
||||
Help keep it beautiful and consistent for everyone who comes after you.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<VPButton
|
||||
href="#tiers"
|
||||
text="Become a sponsor"
|
||||
size="big"
|
||||
/>
|
||||
<VPButton
|
||||
href="https://github.com/lucide-icons/lucide"
|
||||
text="Star on GitHub"
|
||||
size="big"
|
||||
theme="alt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SponsorSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cta {
|
||||
padding-top: 0;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.cta-card {
|
||||
padding: 48px 32px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from '@lucide/vue/src/Icon';
|
||||
import { gitPullRequest, server, shapes, users } from '../../../data/iconNodes';
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
import SponsorSectionHeader from './SponsorSectionHeader.vue';
|
||||
|
||||
const uses = [
|
||||
{
|
||||
title: 'Maintenance and reviews',
|
||||
description: 'Reviewing icon PRs, fixing issues and shipping releases across every package.',
|
||||
icon: gitPullRequest,
|
||||
name: 'git-pull-request',
|
||||
},
|
||||
{
|
||||
title: 'New icons and packages',
|
||||
description: 'Designing icons the community asks for and supporting new frameworks.',
|
||||
icon: shapes,
|
||||
name: 'shapes',
|
||||
},
|
||||
{
|
||||
title: 'Contributors',
|
||||
description: 'Supporting the people who design, review and document Lucide.',
|
||||
icon: users,
|
||||
name: 'users',
|
||||
},
|
||||
{
|
||||
title: 'Infrastructure',
|
||||
description: 'Hosting lucide.dev, search, CI and the tools the team relies on.',
|
||||
icon: server,
|
||||
name: 'server',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SponsorSection band>
|
||||
<SponsorSectionHeader
|
||||
title="Where your money goes"
|
||||
description="Lucide holds its funds on Open Collective, so every expense is public."
|
||||
/>
|
||||
<div class="uses">
|
||||
<div
|
||||
v-for="use in uses"
|
||||
:key="use.title"
|
||||
class="use"
|
||||
>
|
||||
<div class="icon-tile">
|
||||
<Icon
|
||||
:iconNode="use.icon"
|
||||
:name="use.name"
|
||||
:size="22"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="use-title">{{ use.title }}</h3>
|
||||
<p class="use-description">{{ use.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SponsorSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.uses {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.use {
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.icon-tile {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-default-soft);
|
||||
color: var(--sponsor-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.use-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.use-description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
import { data } from './SponsorStats.data';
|
||||
import { sponsors } from './sponsors';
|
||||
|
||||
const stats = [
|
||||
...(data.stars != null
|
||||
? [{ value: `${Math.floor(data.stars / 1000)}k+`, label: 'GitHub stars' }]
|
||||
: []),
|
||||
{
|
||||
value: `${(Math.floor(data.iconsCount / 100) * 100).toLocaleString('en-US')}+`,
|
||||
label: 'Icons',
|
||||
},
|
||||
{ value: sponsors.length.toLocaleString('en-US'), label: 'Sponsors' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SponsorSection class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="intro">
|
||||
<h1 class="title">Sponsor <span class="name">Lucide</span></h1>
|
||||
<p class="lead">
|
||||
Lucide is built and maintained by a small team and a big community. Your sponsorship pays
|
||||
for icon reviews, new packages and the hours that keep every release dependable.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<VPButton
|
||||
href="#tiers"
|
||||
text="Become a sponsor"
|
||||
size="big"
|
||||
/>
|
||||
<VPButton
|
||||
href="#sponsors"
|
||||
text="See our sponsors"
|
||||
size="big"
|
||||
theme="alt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="stats">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="stat"
|
||||
>
|
||||
<dt class="stat-label">{{ stat.label }}</dt>
|
||||
<dd class="stat-value">{{ stat.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</SponsorSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
padding-top: 96px;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.intro {
|
||||
flex: 999 1 520px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 40px;
|
||||
line-height: 48px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--vp-c-brand-3);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex: 1 1 340px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
order: -1;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.title {
|
||||
font-size: 56px;
|
||||
line-height: 64px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getSponsorsByTier } from './sponsors';
|
||||
import { getTier } from './tiers';
|
||||
|
||||
type LogoTier = 'platinum' | 'gold' | 'bronze';
|
||||
type Size = 'lg' | 'md' | 'sm';
|
||||
|
||||
const defaultSizes: Record<LogoTier, Size> = {
|
||||
platinum: 'lg',
|
||||
gold: 'md',
|
||||
bronze: 'sm',
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tier: LogoTier;
|
||||
/** Defaults to the tier name. */
|
||||
title?: string;
|
||||
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
||||
size?: Size;
|
||||
/**
|
||||
* Number of "Your logo here" slots to fill up to. Without placeholders the grid
|
||||
* renders nothing while the tier has no sponsors.
|
||||
*/
|
||||
placeholders?: number;
|
||||
}>(),
|
||||
{
|
||||
headingLevel: 3,
|
||||
placeholders: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const sponsors = getSponsorsByTier(props.tier);
|
||||
|
||||
const placeholderCount = computed(() => Math.max(props.placeholders - sponsors.length, 0));
|
||||
const headingElement = computed(() => `h${props.headingLevel}`);
|
||||
const sizeClass = computed(() => props.size ?? defaultSizes[props.tier]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="sponsors.length || placeholderCount"
|
||||
class="logo-grid"
|
||||
>
|
||||
<component
|
||||
:is="headingElement"
|
||||
class="title"
|
||||
>
|
||||
{{ title ?? getTier(tier).name }}
|
||||
</component>
|
||||
<div
|
||||
class="tiles"
|
||||
:class="sizeClass"
|
||||
>
|
||||
<a
|
||||
v-for="sponsor in sponsors"
|
||||
:key="sponsor.slug"
|
||||
:href="sponsor.website"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="tile"
|
||||
>
|
||||
<img
|
||||
:src="sponsor.image"
|
||||
:alt="sponsor.name"
|
||||
class="logo"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
v-for="index in placeholderCount"
|
||||
:key="`placeholder-${index}`"
|
||||
href="/sponsor#tiers"
|
||||
class="tile placeholder"
|
||||
>
|
||||
Your logo here
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tiles.lg {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tiles.md {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.tiles.sm {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.lg .tile {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.md .tile {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.sm .tile {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: 70%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.lg .logo {
|
||||
max-height: 72px;
|
||||
}
|
||||
|
||||
.md .logo {
|
||||
max-height: 56px;
|
||||
}
|
||||
|
||||
.sm .logo {
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
background-color: transparent;
|
||||
border: 2px dashed var(--vp-c-border);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lg .placeholder {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** Full-bleed tinted background with divider borders. */
|
||||
band?: boolean;
|
||||
narrow?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="sponsor-section"
|
||||
:class="{ band }"
|
||||
>
|
||||
<div
|
||||
class="inner"
|
||||
:class="{ narrow }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sponsor-section {
|
||||
--sponsor-accent: var(--vp-c-brand-5);
|
||||
padding: 72px 24px;
|
||||
}
|
||||
|
||||
html.dark .sponsor-section {
|
||||
--sponsor-accent: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.sponsor-section.band {
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: 1152px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.inner.narrow {
|
||||
max-width: 768px;
|
||||
gap: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-header">
|
||||
<h2 class="title">{{ title }}</h2>
|
||||
<p
|
||||
v-if="description"
|
||||
class="description"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import iconNodes from '../../../data/iconNodes';
|
||||
|
||||
async function fetchStars() {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/lucide-icons/lucide');
|
||||
if (!response.ok) return null;
|
||||
|
||||
const { stargazers_count } = (await response.json()) as { stargazers_count: number };
|
||||
|
||||
return stargazers_count;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const loader = {
|
||||
async load() {
|
||||
return {
|
||||
stars: await fetchStars(),
|
||||
iconsCount: Object.keys(iconNodes).length,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
declare const data: Awaited<ReturnType<typeof loader.load>>;
|
||||
|
||||
export { data };
|
||||
export default loader;
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import Icon from '@lucide/vue/src/Icon';
|
||||
import { check } from '../../../data/iconNodes';
|
||||
import {
|
||||
GITHUB_SPONSORS_URL,
|
||||
OPEN_COLLECTIVE_URL,
|
||||
formatEuro,
|
||||
getPreviousTier,
|
||||
type Tier,
|
||||
} from './tiers';
|
||||
|
||||
const props = defineProps<{
|
||||
tier: Tier;
|
||||
}>();
|
||||
|
||||
const previousTier = computed(() => getPreviousTier(props.tier.id));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="tier-card">
|
||||
<div class="header">
|
||||
<h3 class="name">{{ tier.name }}</h3>
|
||||
<div class="price">
|
||||
<span class="amount">{{ formatEuro(tier.monthly) }}</span>
|
||||
<span class="period">/ month</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="tier.yearly"
|
||||
class="yearly"
|
||||
>
|
||||
or {{ formatEuro(tier.yearly) }} / year, two months free
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="perks">
|
||||
<li
|
||||
v-if="previousTier"
|
||||
class="inherited"
|
||||
>
|
||||
Everything in {{ previousTier.name }}, plus
|
||||
</li>
|
||||
<li
|
||||
v-for="perk in tier.perks"
|
||||
:key="perk"
|
||||
class="perk"
|
||||
>
|
||||
<Icon
|
||||
:iconNode="check"
|
||||
name="check"
|
||||
:size="18"
|
||||
class="perk-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ perk }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="actions">
|
||||
<VPButton
|
||||
:href="OPEN_COLLECTIVE_URL"
|
||||
text="Open Collective"
|
||||
:theme="tier.featured ? 'brand' : 'alt'"
|
||||
class="primary-action"
|
||||
/>
|
||||
<a
|
||||
:href="GITHUB_SPONSORS_URL"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="secondary-action"
|
||||
>
|
||||
GitHub Sponsors
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tier-card {
|
||||
flex: 0 0 300px;
|
||||
scroll-snap-align: start;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
.tier-card:last-child {
|
||||
scroll-snap-align: end;
|
||||
}
|
||||
|
||||
.tier-card:hover,
|
||||
.tier-card:focus-within {
|
||||
border-color: var(--vp-c-brand-3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.period,
|
||||
.yearly {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.yearly {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.perks {
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.inherited {
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.perk {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.perk-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--sponsor-accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--sponsor-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.secondary-action:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useScroll } from '@vueuse/core';
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import Icon from '@lucide/vue/src/Icon';
|
||||
import { chevronLeft, chevronRight } from '../../../data/iconNodes';
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
import SponsorSectionHeader from './SponsorSectionHeader.vue';
|
||||
import SponsorTierCard from './SponsorTierCard.vue';
|
||||
import { PARTNER_EMAIL, TIERS } from './tiers';
|
||||
|
||||
// Card width plus the gap between cards.
|
||||
const SCROLL_STEP = 316;
|
||||
|
||||
const track = ref<HTMLElement | null>(null);
|
||||
const { arrivedState } = useScroll(track);
|
||||
|
||||
function scrollTrack(direction: 1 | -1) {
|
||||
track.value?.scrollBy({ left: direction * SCROLL_STEP, behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SponsorSection
|
||||
id="tiers"
|
||||
class="tiers"
|
||||
>
|
||||
<div class="tiers-header">
|
||||
<SponsorSectionHeader
|
||||
title="Sponsor tiers"
|
||||
description="Two tiers for individuals, three for companies. Each step adds one clear placement, and perks stack as you go up. Same tiers on Open Collective (EUR) and GitHub Sponsors (USD)."
|
||||
class="tiers-title"
|
||||
/>
|
||||
<div class="track-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="track-button"
|
||||
aria-label="Previous tiers"
|
||||
:disabled="arrivedState.left"
|
||||
@click="scrollTrack(-1)"
|
||||
>
|
||||
<Icon
|
||||
:iconNode="chevronLeft"
|
||||
name="chevron-left"
|
||||
:size="20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="track-button"
|
||||
aria-label="Next tiers"
|
||||
:disabled="arrivedState.right"
|
||||
@click="scrollTrack(1)"
|
||||
>
|
||||
<Icon
|
||||
:iconNode="chevronRight"
|
||||
name="chevron-right"
|
||||
:size="20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="track-wrapper">
|
||||
<div
|
||||
ref="track"
|
||||
class="track"
|
||||
role="region"
|
||||
aria-label="Sponsor tiers"
|
||||
tabindex="0"
|
||||
>
|
||||
<SponsorTierCard
|
||||
v-for="tier in TIERS"
|
||||
:key="tier.id"
|
||||
:tier="tier"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fade fade-left"
|
||||
:class="{ visible: !arrivedState.left }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="fade fade-right"
|
||||
:class="{ visible: !arrivedState.right }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="partner">
|
||||
<div class="partner-text">
|
||||
<h3 class="partner-title">Partner? Let's talk.</h3>
|
||||
<p class="partner-description">
|
||||
One partner at a time, custom amount. An exclusive "Supported by" slot, your logo in
|
||||
release posts, an annual roadmap call and priority review of your icon PRs.
|
||||
</p>
|
||||
</div>
|
||||
<VPButton
|
||||
:href="`mailto:${PARTNER_EMAIL}`"
|
||||
text="Contact us"
|
||||
theme="alt"
|
||||
target="_self"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="disclaimer">
|
||||
Sponsors never buy icon acceptance or design influence. Every icon meets the same guidelines
|
||||
and community review. Paying by invoice through GitHub Sponsors avoids part of GitHub's
|
||||
organization fee.
|
||||
</p>
|
||||
</SponsorSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tiers {
|
||||
padding-top: 96px;
|
||||
padding-bottom: 48px;
|
||||
/* The track bleeds to the right edge of the viewport. */
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.tiers-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tiers-title {
|
||||
flex: 999 1 480px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.track-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background-color 0.25s,
|
||||
opacity 0.25s;
|
||||
}
|
||||
|
||||
.track-button:not(:disabled):hover {
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.track-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.track-wrapper {
|
||||
position: relative;
|
||||
margin-right: min(-24px, calc((1152px - 100vw) / 2));
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
padding-bottom: 12px;
|
||||
padding-right: 24px;
|
||||
scroll-padding-right: 24px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.track:focus-visible {
|
||||
outline: 2px solid var(--vp-c-brand-1);
|
||||
outline-offset: 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.fade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 12px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.fade.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fade-left {
|
||||
left: 0;
|
||||
width: 96px;
|
||||
background: linear-gradient(to left, transparent, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.fade-right {
|
||||
right: 0;
|
||||
width: 160px;
|
||||
background: linear-gradient(to right, transparent, var(--vp-c-bg) 85%);
|
||||
}
|
||||
|
||||
.partner {
|
||||
padding: 24px 28px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.partner-text {
|
||||
flex: 999 1 480px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.partner-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.partner-description,
|
||||
.disclaimer {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: -8px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
import SponsorSectionHeader from './SponsorSectionHeader.vue';
|
||||
import SponsorLogoGrid from './SponsorLogoGrid.vue';
|
||||
import SponsorAvatars from './SponsorAvatars.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SponsorSection
|
||||
id="sponsors"
|
||||
class="wall"
|
||||
>
|
||||
<SponsorSectionHeader
|
||||
title="Our sponsors"
|
||||
description="Thank you to everyone who keeps Lucide going."
|
||||
class="wall-header"
|
||||
/>
|
||||
<SponsorLogoGrid
|
||||
tier="platinum"
|
||||
:placeholders="1"
|
||||
/>
|
||||
<SponsorLogoGrid
|
||||
tier="gold"
|
||||
:placeholders="2"
|
||||
/>
|
||||
<SponsorLogoGrid tier="bronze" />
|
||||
<SponsorAvatars />
|
||||
</SponsorSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wall {
|
||||
padding-top: 48px;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.wall-header {
|
||||
max-width: none;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import sponsorsData from '../../../data/sponsorsData.json';
|
||||
import { getTier, getTierForAmount, type TierId } from './tiers';
|
||||
|
||||
export interface Sponsor {
|
||||
slug: string;
|
||||
name: string;
|
||||
website: string;
|
||||
image: string;
|
||||
avatar: string;
|
||||
monthlyAmount: number;
|
||||
}
|
||||
|
||||
export const sponsors: Sponsor[] = sponsorsData.sponsors;
|
||||
|
||||
export const getSponsorsByTier = (tierId: TierId) =>
|
||||
sponsors.filter(({ monthlyAmount }) => getTierForAmount(monthlyAmount)?.id === tierId);
|
||||
|
||||
/** Everyone below the first logo tier, shown as avatars. */
|
||||
export const getSupporters = () =>
|
||||
sponsors.filter(({ monthlyAmount }) => monthlyAmount < getTier('bronze').monthly);
|
||||
@@ -0,0 +1,92 @@
|
||||
export const OPEN_COLLECTIVE_URL = 'https://opencollective.com/lucide-icons';
|
||||
export const GITHUB_SPONSORS_URL = 'https://github.com/sponsors/lucide-icons';
|
||||
export const PARTNER_EMAIL = 'info@lucide.dev';
|
||||
|
||||
export type TierId = 'supporter' | 'backer' | 'bronze' | 'gold' | 'platinum';
|
||||
|
||||
export interface Tier {
|
||||
id: TierId;
|
||||
name: string;
|
||||
/** Monthly amount in euros. */
|
||||
monthly: number;
|
||||
/** Yearly amount in euros, for tiers that can be paid yearly. */
|
||||
yearly?: number;
|
||||
audience: string;
|
||||
/** One-sentence summary of the perks, used in the amount picker. */
|
||||
summary: string;
|
||||
/** Perks this tier adds on top of the previous one. */
|
||||
perks: string[];
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export const TIERS: Tier[] = [
|
||||
{
|
||||
id: 'supporter',
|
||||
name: 'Supporter',
|
||||
monthly: 5,
|
||||
audience: 'For individuals',
|
||||
summary: 'Sponsor badge and a Discord sponsor role.',
|
||||
perks: ['Sponsor badge', 'Discord sponsor role'],
|
||||
},
|
||||
{
|
||||
id: 'backer',
|
||||
name: 'Backer',
|
||||
monthly: 20,
|
||||
audience: 'For individuals and freelancers',
|
||||
summary: 'Name and avatar on lucide.dev/sponsor, plus your name in the README.',
|
||||
perks: ['Name and avatar on lucide.dev/sponsor', 'Name in the README'],
|
||||
},
|
||||
{
|
||||
id: 'bronze',
|
||||
name: 'Bronze',
|
||||
monthly: 100,
|
||||
audience: 'For small companies and startups',
|
||||
summary: 'Small logo on lucide.dev/sponsor and in npm READMEs, plus a sticker sheet.',
|
||||
perks: [
|
||||
'Small logo on lucide.dev/sponsor',
|
||||
'Small logo in the README and npm package READMEs',
|
||||
'Lucide sticker sheet',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gold',
|
||||
name: 'Gold',
|
||||
monthly: 500,
|
||||
yearly: 5000,
|
||||
audience: 'For companies shipping Lucide',
|
||||
summary: 'Large logo on the lucide.dev homepage and docs sidebar, plus a welcome t-shirt.',
|
||||
perks: [
|
||||
'Large logo on the lucide.dev homepage and docs sidebar',
|
||||
'Medium logo in npm package READMEs',
|
||||
'One-time welcome t-shirt',
|
||||
],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'platinum',
|
||||
name: 'Platinum',
|
||||
monthly: 1000,
|
||||
yearly: 10000,
|
||||
audience: 'For large companies and dev tools',
|
||||
summary: 'Top homepage placement, logo on icon search and a private maintainer channel.',
|
||||
perks: [
|
||||
'Top placement on the homepage',
|
||||
'Logo on the icon search page',
|
||||
'Large logo in the top row of npm READMEs',
|
||||
'Dedicated welcome post',
|
||||
'Private channel with maintainers',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const getTier = (id: TierId) => TIERS.find((tier) => tier.id === id)!;
|
||||
|
||||
/** The tier before this one, whose perks it inherits. */
|
||||
export const getPreviousTier = (id: TierId) => TIERS[TIERS.findIndex((tier) => tier.id === id) - 1];
|
||||
|
||||
/** The highest tier a monthly amount qualifies for, or `null` below the lowest tier. */
|
||||
export function getTierForAmount(monthly: number) {
|
||||
return [...TIERS].reverse().find((tier) => monthly >= tier.monthly) ?? null;
|
||||
}
|
||||
|
||||
export const formatEuro = (amount: number) => `€${amount.toLocaleString('en-US')}`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PageData } from 'vitepress';
|
||||
import getStructuredData from './getStructuredData';
|
||||
import getStructuredData, { getFaqStructuredData } from './getStructuredData';
|
||||
import { createGeneralOGImage, createIconOGImage } from './createOGImage';
|
||||
|
||||
export async function transformPageData(pageData: PageData) {
|
||||
@@ -53,6 +53,15 @@ export async function transformPageData(pageData: PageData) {
|
||||
]);
|
||||
}
|
||||
|
||||
// Pages can list their FAQ in the `faq` frontmatter, rendered by the `Faq` component.
|
||||
if (Array.isArray(pageData.frontmatter.faq)) {
|
||||
pageData.frontmatter.head.push([
|
||||
'script',
|
||||
{ type: 'application/ld+json' },
|
||||
JSON.stringify(getFaqStructuredData(pageData.frontmatter.faq)),
|
||||
]);
|
||||
}
|
||||
|
||||
if (pageData.relativePath.startsWith('guide/')) {
|
||||
const ogPath = await createGeneralOGImage(pageData);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"prebuild:iconDetails": "node ./scripts/writeIconDetails.mts",
|
||||
"prebuild:iconPopularity": "node --env-file-if-exists=.env ./scripts/writeiconPopularity.mts && node ./scripts/writeiconPopularityIndex.mts",
|
||||
"prebuild:brandStopwords": "node ./scripts/writeBrandStopwords.mts",
|
||||
"prebuild:sponsors": "node --env-file-if-exists=.env ./scripts/writeSponsorsData.mts",
|
||||
"prebuild:writeLabIconNodes": "node ./scripts/writeLabIconNodes.mts",
|
||||
"prebuild:writeLabIconMetaIndex": "node ./scripts/writeLabIconMetaIndex.mts",
|
||||
"postbuild:vercelJson": "node ./scripts/writeVercelOutput.mts",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
|
||||
const COLLECTIVE_SLUG = 'lucide-icons';
|
||||
const OPEN_COLLECTIVE_API = 'https://api.opencollective.com/graphql/v2';
|
||||
|
||||
const currentDir = process.cwd();
|
||||
const dataDirectory = path.resolve(currentDir, '.vitepress/data');
|
||||
const sponsorLogosPath = path.resolve(currentDir, 'public/sponsors');
|
||||
const outputPath = path.resolve(dataDirectory, 'sponsorsData.json');
|
||||
|
||||
try {
|
||||
// Load environment variables from .env file, if it exists.
|
||||
loadEnvFile(`${currentDir}/.env`);
|
||||
} catch {
|
||||
// No .env file, so rely on the environment as-is.
|
||||
}
|
||||
|
||||
interface Account {
|
||||
slug: string;
|
||||
name: string;
|
||||
website: string | null;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
amount: { valueInCents: number | null } | null;
|
||||
frequency: 'MONTHLY' | 'YEARLY' | 'ONETIME' | null;
|
||||
fromAccount: Account;
|
||||
}
|
||||
|
||||
interface CollectiveResponse {
|
||||
data?: {
|
||||
account: {
|
||||
orders: { nodes: Order[] };
|
||||
} | null;
|
||||
};
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
export interface Sponsor {
|
||||
slug: string;
|
||||
name: string;
|
||||
website: string;
|
||||
/** Logo for the logo tiers, preferring a local file in `public/sponsors`. */
|
||||
image: string;
|
||||
/** Square avatar for the backer and supporter tiers. */
|
||||
avatar: string;
|
||||
/** Recurring contribution normalised to a monthly amount, in the collective's currency. */
|
||||
monthlyAmount: number;
|
||||
}
|
||||
|
||||
const query = /* GraphQL */ `
|
||||
query Sponsors($slug: String!) {
|
||||
account(slug: $slug) {
|
||||
orders(filter: INCOMING, status: [ACTIVE], limit: 1000) {
|
||||
nodes {
|
||||
amount {
|
||||
valueInCents
|
||||
}
|
||||
frequency
|
||||
fromAccount {
|
||||
slug
|
||||
name
|
||||
website
|
||||
imageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function fetchOrders() {
|
||||
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.orders.nodes;
|
||||
}
|
||||
|
||||
function getLogo({ slug, imageUrl }: Account) {
|
||||
const localLogo = ['svg', 'png'].find((extension) =>
|
||||
fs.existsSync(path.join(sponsorLogosPath, `${slug}.${extension}`)),
|
||||
);
|
||||
if (localLogo) return `/sponsors/${slug}.${localLogo}`;
|
||||
|
||||
return imageUrl ?? `https://images.opencollective.com/${slug}/logo/256.png`;
|
||||
}
|
||||
|
||||
function getMonthlyAmount({ amount, frequency }: Order) {
|
||||
const value = (amount?.valueInCents ?? 0) / 100;
|
||||
|
||||
return frequency === 'YEARLY' ? value / 12 : value;
|
||||
}
|
||||
|
||||
function toSponsors(orders: Order[]) {
|
||||
const sponsors = new Map<string, Sponsor>();
|
||||
|
||||
orders.forEach((order) => {
|
||||
const { fromAccount } = order;
|
||||
const existing = sponsors.get(fromAccount.slug);
|
||||
|
||||
if (existing) {
|
||||
existing.monthlyAmount += getMonthlyAmount(order);
|
||||
return;
|
||||
}
|
||||
|
||||
sponsors.set(fromAccount.slug, {
|
||||
slug: fromAccount.slug,
|
||||
name: fromAccount.name,
|
||||
website: fromAccount.website || `https://opencollective.com/${fromAccount.slug}`,
|
||||
image: getLogo(fromAccount),
|
||||
avatar:
|
||||
fromAccount.imageUrl ??
|
||||
`https://images.opencollective.com/${fromAccount.slug}/avatar/128.png`,
|
||||
monthlyAmount: getMonthlyAmount(order),
|
||||
});
|
||||
});
|
||||
|
||||
return [...sponsors.values()]
|
||||
.map((sponsor) => ({
|
||||
...sponsor,
|
||||
monthlyAmount: Math.round(sponsor.monthlyAmount * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.monthlyAmount - a.monthlyAmount || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
let sponsors: Sponsor[] = [];
|
||||
|
||||
try {
|
||||
sponsors = toSponsors(await fetchOrders());
|
||||
} catch (error) {
|
||||
console.warn(`Could not fetch sponsors, writing an empty list. ${error}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(dataDirectory, { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify({ sponsors }, null, 2), 'utf-8');
|
||||
console.log(`Wrote ${sponsors.length} sponsors to ${outputPath}`);
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Sponsor
|
||||
description: Sponsor Lucide to fund icon reviews, new packages and dependable releases. Pick a tier on Open Collective or GitHub Sponsors.
|
||||
layout: page
|
||||
faq:
|
||||
- question: Will Lucide stay free?
|
||||
answer: Yes. Every icon stays free and open source under the ISC license, whether or not you sponsor.
|
||||
- question: Open Collective or GitHub Sponsors?
|
||||
answer: Both have the same tiers. Open Collective takes card, bank transfer and PayPal in EUR with a public ledger. GitHub Sponsors bills in USD through your existing GitHub account, with invoicing for organizations.
|
||||
- question: Can I pay yearly?
|
||||
answer: Gold and Platinum can be paid yearly at ten times the monthly price, so you get two months free and one invoice.
|
||||
- question: When does my logo appear?
|
||||
answer: Logos come from your Open Collective or GitHub profile. lucide.dev updates within a day. The npm READMEs use a hosted image, so they update without a new release.
|
||||
- question: Does sponsoring get my icons accepted?
|
||||
answer: No. Partners get their icon PRs reviewed first, but every icon meets the same design guidelines and community review.
|
||||
- question: Can I cancel anytime?
|
||||
answer: Yes, recurring contributions can be cancelled anytime on Open Collective or GitHub Sponsors.
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import Faq from '~/.vitepress/theme/components/base/Faq.vue'
|
||||
import SponsorSection from '~/.vitepress/theme/components/sponsor/SponsorSection.vue'
|
||||
import SponsorHero from '~/.vitepress/theme/components/sponsor/SponsorHero.vue'
|
||||
import SponsorAmountPicker from '~/.vitepress/theme/components/sponsor/SponsorAmountPicker.vue'
|
||||
import SponsorTiers from '~/.vitepress/theme/components/sponsor/SponsorTiers.vue'
|
||||
import SponsorWall from '~/.vitepress/theme/components/sponsor/SponsorWall.vue'
|
||||
import SponsorFundingUses from '~/.vitepress/theme/components/sponsor/SponsorFundingUses.vue'
|
||||
import SponsorCta from '~/.vitepress/theme/components/sponsor/SponsorCta.vue'
|
||||
</script>
|
||||
|
||||
<SponsorHero />
|
||||
<SponsorAmountPicker />
|
||||
<SponsorTiers />
|
||||
<SponsorWall />
|
||||
<SponsorFundingUses />
|
||||
<SponsorSection narrow class="faq-section">
|
||||
<Faq title="Frequently asked questions" />
|
||||
</SponsorSection>
|
||||
<SponsorCta />
|
||||
|
||||
<style scoped>
|
||||
.faq-section {
|
||||
padding-block: 96px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user