import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star, ShieldCheck, Server, Globe, Bot, Code2, Briefcase, Megaphone, TrendingUp } from 'lucide-react'; import { SupportSkateSpots } from '@/components/SupportSkateSpots'; import { SilentPaymentsCard } from '@/components/SilentPaymentsCard'; import { AngorProjectCard, ANGOR_PROJECT_URL } from '@/components/AngorProjectCard'; import { WaysToSupport } from '@/components/WaysToSupport'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { BACKERS, BACKERS_TOTAL_SATS, tierOf } from '@/lib/backers'; import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { useAuthor } from '@/hooks/useAuthor'; import { useGrowthStats } from '@/hooks/useGrowthStats'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { useSatsToUsd } from '@/hooks/useExchangeRate'; import { useZapLeaderboard } from '@/hooks/useZapLeaderboard'; import { useTranslation } from '@/contexts/language'; // Only count support received from this point (the project's public launch era). // Zaps to the platform before this are ignored. NOTE: as of 2026-10-05 every // platform zap is already ≥ 2026-01, so this doesn't currently trim the list — // the visible cap below is what keeps it short. const SUPPORTERS_SINCE = Math.floor(Date.UTC(2025, 11, 1) / 1000); // 2025-12-01 const VISIBLE_LIMIT = 21; /** npub → hex pubkey (useAuthor queries relays by HEX authors). */ function npubToHex(npub?: string): string | undefined { if (!npub) return undefined; try { const d = nip19.decode(npub); return d.type === 'npub' ? d.data : undefined; } catch { return undefined; } } // ─── Static data — edit these arrays to add/remove entries ────────────────── interface Collaborator { name: string; role: string; avatar?: string; // URL or leave blank for placeholder npub?: string; // Nostr npub — links to their profile url?: string; // fallback link } interface Partner { name: string; logo?: string; // URL to logo (SVG/PNG transparent preferred) url: string; description: string; } interface Friend { name: string; logo?: string; // URL to logo (SVG/PNG transparent preferred, min 200×200px) invertLogo?: boolean; // true if logo is dark and needs inversion for dark backgrounds url: string; tagline?: string; } const COLLABORATORS: Collaborator[] = [ // Add collaborators here — mix of Nostr users and external people // Example: // { name: 'Alice', role: 'Developer', npub: 'npub1...', avatar: 'https://...' }, // { name: 'Bob', role: 'Relay Operator', url: 'https://...' }, ]; const PARTNERS: Partner[] = [ { name: 'SNT4EVER', logo: 'https://blossom.ditto.pub/f6e1d75ed8123b860ca139ee2d43a0a1238b89a0bec24752d9fe48cc41aa5d4c.png', url: 'https://www.snt4ever.cat/', description: 'Bringing back up Sants Estació', }, // Add partners here // Format: { name: 'Example', logo: 'https://...', url: 'https://...', description: 'Short description' } // Logo: PNG/WebP transparent background preferred ]; const FRIENDS: Friend[] = [ { name: 'No Teeth Skateboarding', logo: 'https://blossom.dreamith.to/61bb7055930c442dfe1dfe5b8a8be5417f9b62f68ed79dc4ffbbddce5d4f122c.png', url: 'https://www.instagram.com/noteethskateboarding/', tagline: 'Hand-made Long Lasting Skateboards', }, { name: 'SNT4EVER', logo: 'https://blossom.ditto.pub/f6e1d75ed8123b860ca139ee2d43a0a1238b89a0bec24752d9fe48cc41aa5d4c.png', invertLogo: true, url: 'https://www.snt4ever.cat/', tagline: 'Bringing back up Sants Estació', }, { name: 'RK69 DIY Skatepark', logo: 'https://blossom.ditto.pub/0d1d3855e7922b4ad7c10332e14723a16525e7aa26d95c58ea1dc1ec75514c3c.png', url: 'https://www.instagram.com/rk69diyskatepark', tagline: 'DIY Skatepark 101%', }, { name: 'Suan Luang Backyard', logo: 'https://blossom.ditto.pub/8ac40ffbf9db9c9bff20e9b34d627a31b9c2044fe4f534948292516319684523.jpeg', url: 'https://www.slbskate.com/', tagline: "Bangkok's most famous Backyard DIY Spot", }, // Add more friends here ]; // ─── "What your support pays for" (words only — no figures) ────────────────── const PAYS_FOR = [ { icon: Server, key: 'walletUi.supporters.payRelay', desc: 'walletUi.supporters.payRelayDesc' }, { icon: Globe, key: 'walletUi.supporters.payDomain', desc: 'walletUi.supporters.payDomainDesc' }, { icon: Bot, key: 'walletUi.supporters.payAi', desc: 'walletUi.supporters.payAiDesc' }, { icon: Code2, key: 'walletUi.supporters.payDev', desc: 'walletUi.supporters.payDevDesc' }, { icon: Briefcase, key: 'walletUi.supporters.payOps', desc: 'walletUi.supporters.payOpsDesc' }, { icon: Megaphone, key: 'walletUi.supporters.payMarketing', desc: 'walletUi.supporters.payMarketingDesc' }, ] as const; // ─── Silent-payments donation card (BIP-353 name → BIP-321 URI) ────────────── // ─── Section header ─────────────────────────────────────────────────────────── function SectionHeader({ icon: Icon, title, subtitle, color = '#ccff00', }: { icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; title: string; subtitle?: string; color?: string; }) { return (

{title}

{subtitle &&

{subtitle}

}
); } // ─── Collaborator card ──────────────────────────────────────────────────────── function CollaboratorCard({ collab }: { collab: Collaborator }) { const href = collab.npub ? `/${collab.npub}` : collab.url; const inner = (
{collab.avatar ? ( {collab.name} ) : (
🛹
)}

{collab.name}

{collab.role}

{href && }
); if (!href) return inner; if (collab.npub) return {inner}; return {inner}; } // ─── Partner card ───────────────────────────────────────────────────────────── function PartnerCard({ partner }: { partner: Partner }) { return (
{partner.logo ? ( {partner.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{partner.name}

{partner.description}

); } // ─── Friend card ────────────────────────────────────────────────────────────── function FriendCard({ friend }: { friend: Friend }) { return (
{friend.logo ? ( {friend.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( 🛹 )}

{friend.name}

{friend.tagline &&

{friend.tagline}

}
); } // ─── Supporter types + row (unified: Angor + Lightning) ────────────────────── interface SupporterEntry { key: string; pubkey?: string; npub?: string; name?: string; logo?: string; url?: string; sats: number; angorSats: number; angor: boolean; zap: boolean; txid?: string; receiptId?: string; note?: string; anonymous?: boolean; at?: number; } const TIER_META = { supporter: { color: '#22d3ee', key: 'walletUi.supporters.tierSupporter', emoji: '🛹' }, patron: { color: '#f7931a', key: 'walletUi.supporters.tierPatron', emoji: '🔥' }, legend: { color: '#facc15', key: 'walletUi.supporters.tierLegend', emoji: '👑💎✨' }, } as const; const TIER_ORDER = ['legend', 'patron', 'supporter'] as const; const TIER_RANGE: Record<(typeof TIER_ORDER)[number], string> = { legend: '1M+', patron: '100k–1M', supporter: '< 100k', }; function MemberAvatar({ entry }: { entry: SupporterEntry }) { const { data } = useAuthor(entry.anonymous ? undefined : entry.pubkey); const meta = data?.metadata; const name = entry.anonymous ? '★' : entry.name || meta?.display_name || meta?.name || entry.npub || ''; const avatar = entry.anonymous ? undefined : (entry.logo || meta?.picture); return ( {avatar && } {entry.anonymous ? '🛹' : name.replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || 'S'} ); } function TierLadder({ supporters }: { supporters: SupporterEntry[] }) { const { t } = useTranslation(); return (
{TIER_ORDER.map((tier) => { const meta = TIER_META[tier]; const members = supporters.filter((s) => tierOf(s.sats) === tier); return (
{meta.emoji} {t(meta.key)} {TIER_RANGE[tier]}
{members.length > 0 ? (
{members.slice(0, 10).map((m) => ( ))} {members.length > 10 && ( +{members.length - 10} )}
) : (

{t('walletUi.supporters.tierEmpty')}

)}
); })}
); } function SupporterRow({ rank, entry }: { rank: number; entry: SupporterEntry }) { const { t } = useTranslation(); const { data } = useAuthor(entry.anonymous ? undefined : entry.pubkey); const meta = data?.metadata; const name = entry.anonymous ? t('walletUi.supporters.anonymousBacker') : entry.name || meta?.display_name || meta?.name || (entry.npub ? `${entry.npub.slice(0, 12)}…${entry.npub.slice(-6)}` : t('walletUi.supporters.anonymousBacker')); const avatar = entry.anonymous ? undefined : (entry.logo || meta?.picture); const tier = TIER_META[tierOf(entry.sats)]; const when = entry.at ? formatDistanceToNow(new Date(entry.at * 1000), { addSuffix: true }) : undefined; const profileHref = !entry.anonymous && entry.npub ? `/${entry.npub}` : undefined; const linkHref = profileHref || entry.url; const proofUrl = entry.txid ? `https://mempool.space/tx/${entry.txid}` : entry.receiptId ? `https://njump.me/${nip19.neventEncode({ id: entry.receiptId })}` : undefined; const nameEl = linkHref ? ( profileHref ? ( {name} ) : ( {name} ) ) : ( {name} ); return (
#{rank} {avatar && } {entry.anonymous ? '🛹' : name.replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || 'S'}
{nameEl} {entry.angor && ( {t('walletUi.supporters.sourceAngor')} )} {entry.zap && }
= 1_000_000 ? `0 0 10px ${tier.color}66` : undefined, }} > {tier.emoji} {t(tier.key)} {entry.note && !entry.anonymous && {entry.note}} {when && · {when}}
{entry.sats.toLocaleString()}sats {proofUrl && ( {t('walletUi.supporters.verifiedOnChain')} )}
); } // ─── Placeholder card (empty slot) ─────────────────────────────────────────── function PlaceholderCard({ label }: { label: string }) { return (
?

{label}

); } // ─── Main page ──────────────────────────────────────────────────────────────── export default function SupportersPage() { const { t } = useTranslation(); const { data: zaps } = useZapLeaderboard(SUPPORTERS_SINCE, PLATFORM_OWNER_PUBKEYS); const zappers = useMemo(() => zaps?.senders ?? [], [zaps]); const zapTotal = zappers.reduce((sum, z) => sum + z.sats, 0); const allSats = BACKERS_TOTAL_SATS + zapTotal; const totalUsd = useSatsToUsd(allSats); const { data: stats } = useGrowthStats(); const [showAll, setShowAll] = useState(false); // One ranked wall: Angor backers + Lightning zaps to the project, merged by npub. const supporters = useMemo(() => { const map = new Map(); BACKERS.forEach((b, i) => { const npub = b.anonymous ? undefined : b.npub; const key = (npub || b.url || b.name || `anon-${i}`).toLowerCase(); map.set(key, { key, pubkey: npub ? npubToHex(npub) : undefined, npub, name: b.anonymous ? undefined : b.name, logo: b.anonymous ? undefined : b.logo, url: b.url, sats: b.amount ?? 0, angorSats: b.amount ?? 0, angor: true, zap: false, txid: b.txid, note: b.note, anonymous: b.anonymous, at: b.at, }); }); for (const z of zappers) { const npub = nip19.npubEncode(z.pubkey); const existing = map.get(npub); if (existing) { existing.sats += z.sats; existing.zap = true; existing.receiptId = z.receiptId; if (z.createdAt && (!existing.at || z.createdAt > existing.at)) existing.at = z.createdAt; } else { map.set(npub, { key: npub, pubkey: z.pubkey, npub, sats: z.sats, angorSats: 0, angor: false, zap: true, receiptId: z.receiptId, at: z.createdAt, }); } } return [...map.values()].sort((a, b) => b.sats - a.sats); }, [zappers]); const latestAt = supporters.reduce((m, s) => Math.max(m, s.at ?? 0), 0); // `growth-stats` is persisted, so an older cache may lack newer fields — guard every read. const num = (v: number | undefined) => v ?? 0; const labTotal = stats ? num(stats.totalCoaches) + num(stats.totalFilmers) + num(stats.totalEditors) + num(stats.totalPhotographers) + num(stats.totalBuilders) + num(stats.totalArtists) + num(stats.totalGraffiti) + num(stats.totalTattoo) + num(stats.totalTrainers) + num(stats.totalMusicians) + num(stats.totalJudges) + num(stats.totalOrganizers) + num(stats.totalBarbers) : 0; const impact = stats ? [ { key: 'walletUi.supporters.impactSpots', value: num(stats.totalSpots) }, { key: 'walletUi.supporters.impactCrews', value: num(stats.totalCrews) }, { key: 'walletUi.supporters.impactEvents', value: num(stats.totalEvents) }, { key: 'walletUi.supporters.impactLab', value: labTotal }, { key: 'walletUi.supporters.impactShops', value: num(stats.totalShops) }, { key: 'walletUi.supporters.impactMappers', value: num(stats.totalMappers) }, ].filter((s) => s.value > 0) : []; useSeoMeta({ title: 'Supporters - SkateSpots', description: 'The community keeping SkateSpots alive — supporters, collaborators, partners and friends.', ogTitle: 'Supporters — SkateSpots', ogDescription: 'Built by skaters, funded by skaters. Back the map on Angor or zap it.', ogImage: 'https://skate-spots.org/images/icon-1024.png', }); return ( <>
{/* ── Hero ── */}
⚡

{t('walletUi.supporters.our')}{' '} {t('walletUi.supporters.community')}

{t('walletUi.supporters.heroDesc')}

{t('walletUi.supporters.backAngor')} {t('walletUi.supporters.donatePageLink')} →
{/* ── 1. Support ── */}
{/* Lightning rail — the in-app way to chip in (zap publicly or pay quietly). */}
{/* Silent payments — private on-chain (and Lightning) via a BIP-353 name. */}
{/* ── 2. What your support pays for (words only) ── */}
{PAYS_FOR.map(({ icon: Icon, key, desc }) => (
{t(key)}

{t(desc)}

))}
{/* ── 3. Impact so far (what the community built) ── */} {impact.length > 0 && (
{impact.map(({ key, value }) => (
{value.toLocaleString()}
{t(key)}
))}

{t('walletUi.supporters.impactSeeStats')} {' · '} {t('walletUi.supporters.impactAsOf')}

)} {/* ── 4. Supporters (Angor + Lightning zaps) ── */}
{supporters.length > 0 ? ( <>
{(showAll ? supporters : supporters.slice(0, VISIBLE_LIMIT)).map((entry, i) => ( ))}
{supporters.length > VISIBLE_LIMIT && (
)}

{t('walletUi.supporters.supportersTotals', { count: supporters.length, angor: BACKERS_TOTAL_SATS.toLocaleString(), zap: zapTotal.toLocaleString(), })} {typeof totalUsd === 'number' && ( · ≈ ${totalUsd.toFixed(2)} )}

{t('walletUi.supporters.quietNote')}

{latestAt > 0 && (

{t('walletUi.supporters.updatedAt', { when: formatDistanceToNow(new Date(latestAt * 1000), { addSuffix: true }), })}

)} ) : (

{t('walletUi.supporters.beFirstBacker')}

{t('walletUi.supporters.backAngor')}
)}
{/* ── 6. Collaborators ── */}
{COLLABORATORS.length > 0 ? (
{COLLABORATORS.map((c) => ( ))}
) : (

{t('walletUi.supporters.wantCollaborate')}

{t('walletUi.supporters.wantCollaborateDesc')}

{t('walletUi.supporters.viewNostrHub')}
)}
{/* ── 7. Partners ── */}
{PARTNERS.length > 0 ? (
{PARTNERS.map((p) => ( ))}
) : ( <>
{Array.from({ length: 4 }).map((_, i) => ( ))}

{t('walletUi.supporters.wantPartner')}

)}
{/* ── 8. Friends ── */}
{FRIENDS.map((f) => ( ))} {/* Fill remaining slots up to 5 with placeholders */} {FRIENDS.length < 5 && Array.from({ length: 5 - FRIENDS.length }).map((_, i) => ( ))}

{t('walletUi.supporters.wantListed')}

{t('walletUi.supporters.wantListedDesc')}

{/* ── Mission ── */}

{t('walletUi.supporters.whyTitle1')} {t('walletUi.supporters.whyTitle2')}

{t('walletUi.supporters.whyDesc')}

{[ { emoji: '🛹', title: t('walletUi.supporters.why1Title'), desc: t('walletUi.supporters.why1Desc') }, { emoji: '⚡', title: t('walletUi.supporters.why2Title'), desc: t('walletUi.supporters.why2Desc') }, { emoji: '🌍', title: t('walletUi.supporters.why3Title'), desc: t('walletUi.supporters.why3Desc') }, ].map(({ emoji, title, desc }) => (
{emoji}

{title}

{desc}

))}
{/* ── Footer ── */}

{t('walletUi.supporters.thankYou')}

{t('walletUi.supporters.license')} {' '}·{' '} {t('nav.about')} {' '}·{' '} {t('walletUi.supporters.faq')}

); }