import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link, useNavigate } 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, Info, Lock, EyeOff } 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';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
// 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 = 12;
/** 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) ──────────────
// ─── Bitcoin grantmaker inquiry ──────────────────────────────────────────────
function GrantmakerForm() {
const { t } = useTranslation();
const navigate = useNavigate();
const owner = PLATFORM_OWNER_PUBKEYS[0];
const [org, setOrg] = useState('');
const [contact, setContact] = useState('');
const [website, setWebsite] = useState('');
const [program, setProgram] = useState('');
const [grantSize, setGrantSize] = useState('');
const [kyc, setKyc] = useState('');
const [fund, setFund] = useState('');
const [ack, setAck] = useState(false);
const canSend = !!org.trim() && !!contact.trim() && ack && !!owner;
const message = [
'Bitcoin grantmaker inquiry — SkateSpots',
'',
`Organization: ${org.trim()}`,
`Contact: ${contact.trim()}`,
website.trim() ? `Website: ${website.trim()}` : '',
program.trim() ? `Program / round: ${program.trim()}` : '',
grantSize.trim() ? `Grant size: ${grantSize.trim()}` : '',
kyc.trim() ? `KYC / KYB process: ${kyc.trim()}` : '',
fund.trim() ? `What we would like to fund: ${fund.trim()}` : '',
'',
'I confirm this is a grantmaker inquiry and we KYC/KYB our grantees.',
].filter(Boolean).join('\n');
return (
{t('walletUi.supporters.grantsTitle')}
{t('walletUi.supporters.grantsDisclaimer')}
{t('walletUi.supporters.grantsDisclaimerDesc')}
{[
{ icon: ShieldCheck, title: t('walletUi.supporters.grantsNoKycTitle'), body: t('walletUi.supporters.grantsNoKycDesc') },
{ icon: EyeOff, title: t('walletUi.supporters.grantsNoTrackingTitle'), body: t('walletUi.supporters.grantsNoTrackingDesc') },
{ icon: Lock, title: t('walletUi.supporters.grantsPrivateTitle'), body: t('walletUi.supporters.grantsPrivateDesc') },
].map(({ icon: Icon, title, body }) => (
))}
{t('walletUi.supporters.grantsEthos')}
{t('walletUi.supporters.grantsNote')}
);
}
// ─── 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 (
);
}
// ─── Collaborator card ────────────────────────────────────────────────────────
function CollaboratorCard({ collab }: { collab: Collaborator }) {
const href = collab.npub
? `/${collab.npub}`
: collab.url;
const inner = (
{collab.avatar ? (

) : (
🛹
)}
{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 ? (

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

{ (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}}
);
}
// ─── 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);
const [tab, setTab] = useState<'support' | 'backers' | 'community' | 'grants'>('support');
const [rail, setRail] = useState<'support-lightning' | 'support-silent' | 'support-angor'>('support-lightning');
// 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) + num(stats.totalTransport) + num(stats.totalDesigners)
: 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: t('seoUi.supportersTitle'),
description: t('seoUi.supportersDesc'),
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')}
{supporters.length.toLocaleString()} {t('walletUi.supporters.backers')}
·
{allSats.toLocaleString()} sats
{typeof totalUsd === 'number' && (
<>
·
≈ ${totalUsd.toFixed(2)}
>
)}
{/* ── Compact navigation ── */}
setTab(value as 'support' | 'backers' | 'community' | 'grants')}
>
{[
{ value: 'support', label: t('walletUi.supporters.support') },
{ value: 'backers', label: t('walletUi.supporters.backers') },
{ value: 'community', label: t('walletUi.supporters.community') },
{ value: 'grants', label: t('walletUi.supporters.grantsSection') },
].map((item) => (
{item.label}
))}
{/* ── Support rails ── */}
{t('walletUi.supporters.support')}
{t('walletUi.supporters.donateDesc')}
setRail(id as typeof rail)} />
{rail === 'support-lightning' &&
}
{rail === 'support-silent' &&
}
{rail === 'support-angor' &&
}
{PAYS_FOR.map(({ icon: Icon, key, desc }) => (
))}
{/* ── Backers, impact and tiers ── */}
{impact.length > 0 && (
{impact.map(({ key, value }) => (
{value.toLocaleString()}
{t(key)}
))}
{t('walletUi.supporters.impactSeeStats')}
{' · '}
{t('walletUi.supporters.impactAsOf')}
)}
{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 }) })}
)}
>
) : (
)}
{/* ── Community, mission, collaborators, partners, friends ── */}
{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 }) => (
))}
{COLLABORATORS.length > 0 ? (
{COLLABORATORS.map((c) => )}
) : (
)}
{PARTNERS.length > 0 ? (
) : (
{t('walletUi.supporters.wantPartner')}
)}
{FRIENDS.map((f) => )}
{t('walletUi.supporters.wantListed')}
{t('walletUi.supporters.wantListedDesc')}
{/* ── Grantmakers ── */}
{/* ── Footer ── */}
{t('walletUi.supporters.thankYou')}
{t('walletUi.supporters.license')}
{' '}·{' '}
{t('nav.about')}
{' '}·{' '}
{t('walletUi.supporters.faq')}
>
);
}