SkateSpots/src/pages/SupportersPage.tsx

929 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="rounded-2xl border-2 border-[#f7931a]/30 bg-gradient-to-br from-[#f7931a]/10 via-black to-transparent p-4 sm:p-6">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#f7931a]/15 text-[#f7931a]">
<Handshake className="h-5 w-5" />
</div>
<div className="min-w-0">
<h3 className="text-lg font-black uppercase text-white">{t('walletUi.supporters.grantsTitle')}</h3>
<p className="mt-1 text-sm font-bold text-[#f7931a]">{t('walletUi.supporters.grantsDisclaimer')}</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">{t('walletUi.supporters.grantsDisclaimerDesc')}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="grant-org">{t('walletUi.supporters.grantsOrg')} *</Label>
<Input id="grant-org" value={org} onChange={(e) => setOrg(e.target.value)} placeholder="e.g. OpenSats" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-contact">{t('walletUi.supporters.grantsContact')} *</Label>
<Input id="grant-contact" value={contact} onChange={(e) => setContact(e.target.value)} placeholder="npub, email or @handle" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-website">{t('walletUi.supporters.grantsWebsite')}</Label>
<Input id="grant-website" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="https://…" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-program">{t('walletUi.supporters.grantsProgram')}</Label>
<Input id="grant-program" value={program} onChange={(e) => setProgram(e.target.value)} placeholder="e.g. Q1 2027 grants" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-size">{t('walletUi.supporters.grantsSize')}</Label>
<Input id="grant-size" value={grantSize} onChange={(e) => setGrantSize(e.target.value)} placeholder="e.g. 5–25M sats" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-kyc">{t('walletUi.supporters.grantsKyc')}</Label>
<Input id="grant-kyc" value={kyc} onChange={(e) => setKyc(e.target.value)} placeholder="e.g. KYB + grantee KYC" />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="grant-fund">{t('walletUi.supporters.grantsFund')}</Label>
<Textarea id="grant-fund" value={fund} onChange={(e) => setFund(e.target.value)} rows={3} placeholder="Relay, app development, translation, design, events…" />
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ 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 }) => (
<div key={title} className="flex items-start gap-2 rounded-xl border border-white/10 bg-black/30 p-2.5">
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#f7931a]" />
<div className="min-w-0">
<p className="text-[11px] font-bold text-white">{title}</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-gray-500">{body}</p>
</div>
</div>
))}
</div>
<p className="mt-3 rounded-xl border border-[#f7931a]/15 bg-[#f7931a]/[0.04] px-3 py-2 text-[11px] leading-relaxed text-gray-500">
{t('walletUi.supporters.grantsEthos')}
</p>
<label className="mt-4 flex cursor-pointer items-start gap-2.5 rounded-xl border border-[#f7931a]/20 bg-black/30 p-3 text-xs text-gray-400">
<input type="checkbox" checked={ack} onChange={(e) => setAck(e.target.checked)} className="mt-0.5 h-4 w-4 accent-[#f7931a]" />
<span>{t('walletUi.supporters.grantsAck')}</span>
</label>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
disabled={!canSend}
onClick={() => {
if (!canSend || !owner) return;
navigate(`/messages/${nip19.npubEncode(owner)}?message=${encodeURIComponent(message)}`);
}}
className="w-full justify-center bg-[#f7931a] font-bold text-black hover:bg-[#ffb347] sm:w-auto"
>
<Handshake className="mr-2 h-4 w-4" /> {t('walletUi.supporters.grantsSend')}
</Button>
<p className="flex items-center gap-1.5 text-[11px] text-gray-600">
<Info className="h-3 w-3 shrink-0" />
{t('walletUi.supporters.grantsNote')}
</p>
</div>
</div>
);
}
// ─── 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 (
<div className="mb-3 md:mb-4">
<div className="mb-1.5 flex items-center gap-2.5">
<Icon className="h-4 w-4 flex-shrink-0" style={{ color }} />
<h2 className="text-lg font-black uppercase tracking-wide md:text-xl" style={{ color }}>
{title}
</h2>
<div className="h-px flex-1" style={{ background: `linear-gradient(to right, ${color}40, transparent)` }} />
</div>
{subtitle && <p className="ml-7 text-xs text-gray-400">{subtitle}</p>}
</div>
);
}
// ─── Collaborator card ────────────────────────────────────────────────────────
function CollaboratorCard({ collab }: { collab: Collaborator }) {
const href = collab.npub
? `/${collab.npub}`
: collab.url;
const inner = (
<div className="flex items-center gap-3 p-4 rounded-xl bg-black/60 border border-white/10 hover:border-[#ff0040]/40 transition-all group">
<div className="relative flex-shrink-0">
{collab.avatar ? (
<img
src={collab.avatar}
alt={collab.name}
className="w-12 h-12 rounded-full object-cover border-2 border-white/10 group-hover:border-[#ff0040]/50 transition-all"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#ff0040]/20 to-[#ff6600]/20 border-2 border-white/10 flex items-center justify-center text-xl">
🛹
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm text-white truncate">{collab.name}</p>
<p className="text-xs text-[#ff0040]/80 font-medium">{collab.role}</p>
</div>
{href && <ExternalLink className="h-3.5 w-3.5 text-gray-600 group-hover:text-[#ff0040] flex-shrink-0 transition-colors" />}
</div>
);
if (!href) return inner;
if (collab.npub) return <Link to={`/${collab.npub}`}>{inner}</Link>;
return <a href={href} target="_blank" rel="noopener noreferrer">{inner}</a>;
}
// ─── Partner card ─────────────────────────────────────────────────────────────
function PartnerCard({ partner }: { partner: Partner }) {
return (
<a
href={partner.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 rounded-xl bg-black/60 border border-white/10 hover:border-[#ff6600]/50 hover:bg-[#ff6600]/5 transition-all group"
>
<div className="w-10 h-10 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center flex-shrink-0 overflow-hidden">
{partner.logo ? (
<img
src={partner.logo}
alt={partner.name}
className="w-7 h-7 object-contain brightness-0 invert opacity-70 group-hover:opacity-100 transition-opacity"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<Building2 className="h-5 w-5 text-gray-600 group-hover:text-[#ff6600] transition-colors" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm text-white group-hover:text-[#ff6600] transition-colors">{partner.name}</p>
<p className="text-xs text-gray-500 truncate">{partner.description}</p>
</div>
<ExternalLink className="h-3.5 w-3.5 text-gray-600 group-hover:text-[#ff6600] flex-shrink-0 transition-colors" />
</a>
);
}
// ─── Friend card ──────────────────────────────────────────────────────────────
function FriendCard({ friend }: { friend: Friend }) {
return (
<a
href={friend.url}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 p-5 rounded-xl bg-black/60 border border-white/10 hover:border-[#ccff00]/40 hover:bg-[#ccff00]/5 transition-all group text-center"
>
<div className="w-16 h-16 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden group-hover:border-[#ccff00]/40 transition-all">
{friend.logo ? (
<img
src={friend.logo}
alt={friend.name}
className={`w-12 h-12 object-contain${friend.invertLogo ? ' invert brightness-200' : ''}`}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<span className="text-2xl">🛹</span>
)}
</div>
<div>
<p className="font-bold text-sm text-white group-hover:text-[#ccff00] transition-colors">{friend.name}</p>
{friend.tagline && <p className="text-xs text-gray-500 mt-0.5">{friend.tagline}</p>}
</div>
</a>
);
}
// ─── 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 className="h-6 w-6 ring-1 ring-white/15" title={name}>
{avatar && <AvatarImage src={avatar} alt={name} />}
<AvatarFallback className="text-[10px] font-black text-white/70">
{entry.anonymous ? '🛹' : name.replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || 'S'}
</AvatarFallback>
</Avatar>
);
}
function TierLadder({ supporters }: { supporters: SupporterEntry[] }) {
const { t } = useTranslation();
return (
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-3">
{TIER_ORDER.map((tier) => {
const meta = TIER_META[tier];
const members = supporters.filter((s) => tierOf(s.sats) === tier);
return (
<div
key={tier}
className="rounded-xl border p-3"
style={{
borderColor: `${meta.color}55`,
background: `linear-gradient(135deg, ${meta.color}14, transparent)`,
boxShadow: tier === 'legend' ? `0 0 18px ${meta.color}33` : undefined,
}}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-black uppercase tracking-wide" style={{ color: meta.color }}>
{meta.emoji} {t(meta.key)}
</span>
<span className="text-[10px] text-gray-500">{TIER_RANGE[tier]}</span>
</div>
{members.length > 0 ? (
<div className="mt-2 flex flex-wrap items-center gap-1">
{members.slice(0, 10).map((m) => (
<MemberAvatar key={m.key} entry={m} />
))}
{members.length > 10 && (
<span className="text-[10px] text-gray-500">+{members.length - 10}</span>
)}
</div>
) : (
<p className="mt-2 text-[10px] text-gray-600">{t('walletUi.supporters.tierEmpty')}</p>
)}
</div>
);
})}
</div>
);
}
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 ? (
<Link to={linkHref} className="truncate text-sm font-medium text-white transition-colors hover:text-[#22d3ee]">{name}</Link>
) : (
<a href={linkHref} target="_blank" rel="noopener noreferrer" className="truncate text-sm font-medium text-white transition-colors hover:text-[#22d3ee]">{name}</a>
)
) : (
<span className="truncate text-sm font-medium text-white">{name}</span>
);
return (
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/60 px-3 py-2.5 transition-colors hover:border-[#22d3ee]/50">
<span className="w-6 shrink-0 text-center text-sm font-bold text-gray-500">#{rank}</span>
<Avatar className="h-9 w-9 shrink-0">
{avatar && <AvatarImage src={avatar} alt={name} />}
<AvatarFallback className="font-black text-[#22d3ee]">
{entry.anonymous ? '🛹' : name.replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || 'S'}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{nameEl}
{entry.angor && (
<span className="shrink-0 rounded-full border border-[#22d3ee]/40 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-[#22d3ee]">
{t('walletUi.supporters.sourceAngor')}
</span>
)}
{entry.zap && <Zap className="h-3 w-3 shrink-0 text-[#f7931a]" />}
</div>
<div className="mt-0.5 flex items-center gap-2">
<span
className="rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide"
style={{
background: `${tier.color}22`,
color: tier.color,
border: `1px solid ${tier.color}55`,
boxShadow: entry.sats >= 1_000_000 ? `0 0 10px ${tier.color}66` : undefined,
}}
>
{tier.emoji} {t(tier.key)}
</span>
{entry.note && !entry.anonymous && <span className="text-[11px] text-gray-500">{entry.note}</span>}
{when && <span className="text-[11px] text-gray-600">· {when}</span>}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<span className="font-bold text-[#ccff00]">
{entry.sats.toLocaleString()}<span className="ml-0.5 text-[10px] font-medium text-gray-500">sats</span>
</span>
{proofUrl && (
<a
href={proofUrl}
target="_blank"
rel="noopener noreferrer"
title={entry.txid || entry.receiptId}
className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-emerald-400/80 transition-colors hover:text-emerald-300"
>
<ShieldCheck className="h-3 w-3" />
{t('walletUi.supporters.verifiedOnChain')}
</a>
)}
</div>
</div>
);
}
// ─── 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<SupporterEntry[]>(() => {
const map = new Map<string, SupporterEntry>();
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 (
<>
<div className="container max-w-4xl space-y-6 px-4 py-8 md:py-10">
{/* ── Hero ── */}
<div className="text-center">
<h1 className="text-4xl font-black uppercase leading-none md:text-5xl">
<span className="neon-red">{t('walletUi.supporters.our')}</span>{' '}
<span className="neon-lime">{t('walletUi.supporters.community')}</span>
</h1>
<p className="mx-auto mt-2 max-w-xl text-sm text-gray-400">
{t('walletUi.supporters.heroDesc')}
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<a
href={ANGOR_PROJECT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ff6600] px-5 py-2.5 text-xs font-black uppercase text-white shadow-lg shadow-[#ff0040]/25 transition-opacity hover:opacity-90"
>
<Zap className="h-4 w-4" />
{t('walletUi.supporters.backAngor')}
<ExternalLink className="h-3 w-3 opacity-70" />
</a>
<Link
to="/donate"
className="inline-flex items-center gap-1.5 rounded-full border border-white/15 px-4 py-2.5 text-xs font-bold text-[#ccff00]/80 transition-colors hover:border-[#ccff00]/40 hover:text-[#ccff00]"
>
{t('walletUi.supporters.donatePageLink')} →
</Link>
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-[11px] text-gray-500">
<span>{supporters.length.toLocaleString()} {t('walletUi.supporters.backers')}</span>
<span aria-hidden="true">·</span>
<span>{allSats.toLocaleString()} sats</span>
{typeof totalUsd === 'number' && (
<>
<span aria-hidden="true">·</span>
<span>≈ ${totalUsd.toFixed(2)}</span>
</>
)}
</div>
</div>
{/* ── Compact navigation ── */}
<Tabs
value={tab}
onValueChange={(value) => setTab(value as 'support' | 'backers' | 'community' | 'grants')}
>
<TabsList className="grid h-auto w-full grid-cols-4 gap-1 rounded-2xl border border-white/10 bg-black/70 p-1">
{[
{ 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) => (
<TabsTrigger
key={item.value}
value={item.value}
className="h-auto min-h-10 whitespace-normal rounded-xl px-1.5 py-2 text-center text-[9px] font-black uppercase leading-tight tracking-wide data-[state=active]:bg-[#ccff00] data-[state=active]:text-black data-[state=active]:shadow-none sm:px-2 sm:text-[11px]"
>
{item.label}
</TabsTrigger>
))}
</TabsList>
{/* ── Support rails ── */}
<TabsContent value="support" className="space-y-4 pt-4">
<div>
<h2 className="text-xl font-black uppercase text-white">{t('walletUi.supporters.support')}</h2>
<p className="mt-0.5 text-sm text-gray-400">{t('walletUi.supporters.donateDesc')}</p>
</div>
<WaysToSupport active={rail} onSelect={(id) => setRail(id as typeof rail)} />
<div id={rail} className="scroll-mt-24">
{rail === 'support-lightning' && <SupportSkateSpots />}
{rail === 'support-silent' && <SilentPaymentsCard />}
{rail === 'support-angor' && <AngorProjectCard count={supporters.length} />}
</div>
<section className="pt-1">
<SectionHeader
icon={Heart}
title={t('walletUi.supporters.paysFor')}
subtitle={t('walletUi.supporters.paysForDesc')}
color="#22d3ee"
/>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{PAYS_FOR.map(({ icon: Icon, key, desc }) => (
<div key={key} className="flex items-start gap-2 rounded-xl border border-white/10 bg-black/60 px-3 py-2">
<Icon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#22d3ee]" />
<div className="min-w-0">
<span className="text-xs font-medium text-white">{t(key)}</span>
<p className="text-[10px] leading-snug text-gray-500">{t(desc)}</p>
</div>
</div>
))}
</div>
</section>
</TabsContent>
{/* ── Backers, impact and tiers ── */}
<TabsContent value="backers" className="space-y-5 pt-4">
{impact.length > 0 && (
<section>
<SectionHeader
icon={TrendingUp}
title={t('walletUi.supporters.impact')}
subtitle={t('walletUi.supporters.impactDesc')}
color="#ccff00"
/>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{impact.map(({ key, value }) => (
<div key={key} className="rounded-xl border border-white/10 bg-black/60 px-2 py-2.5 text-center">
<div className="text-lg font-black text-[#ccff00]">{value.toLocaleString()}</div>
<div className="truncate text-[10px] text-gray-400">{t(key)}</div>
</div>
))}
</div>
<p className="mt-2 text-center text-[11px] text-gray-500">
<Link to="/stats" className="text-[#ccff00]/70 underline underline-offset-2 hover:text-[#ccff00]">
{t('walletUi.supporters.impactSeeStats')}
</Link>
{' · '}
{t('walletUi.supporters.impactAsOf')}
</p>
</section>
)}
<section>
<SectionHeader
icon={Zap}
title={t('walletUi.supporters.backers')}
subtitle={t('walletUi.supporters.backersDesc')}
color="#22d3ee"
/>
{supporters.length > 0 ? (
<>
<div className="flex flex-col gap-1.5">
{(showAll ? supporters : supporters.slice(0, VISIBLE_LIMIT)).map((entry, i) => (
<SupporterRow key={entry.key} rank={i + 1} entry={entry} />
))}
</div>
{supporters.length > VISIBLE_LIMIT && (
<div className="mt-2 text-center">
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="min-h-0 min-w-0 text-[11px] font-bold uppercase tracking-wide text-[#22d3ee]/80 transition-colors hover:text-[#22d3ee]"
>
{showAll
? t('walletUi.supporters.showLess')
: t('walletUi.supporters.showAll', { count: supporters.length })}
</button>
</div>
)}
<p className="mt-3 text-center text-xs text-gray-400">
{t('walletUi.supporters.supportersTotals', {
count: supporters.length,
angor: BACKERS_TOTAL_SATS.toLocaleString(),
zap: zapTotal.toLocaleString(),
})}
{typeof totalUsd === 'number' && <span className="text-gray-500"> · ≈ ${totalUsd.toFixed(2)}</span>}
</p>
<p className="mt-1 text-center text-[11px] text-gray-600">{t('walletUi.supporters.quietNote')}</p>
{latestAt > 0 && (
<p className="mt-0.5 text-center text-[10px] text-gray-600">
{t('walletUi.supporters.updatedAt', { when: formatDistanceToNow(new Date(latestAt * 1000), { addSuffix: true }) })}
</p>
)}
<TierLadder supporters={supporters} />
</>
) : (
<div className="rounded-xl border border-dashed border-[#22d3ee]/20 p-6 text-center">
<Zap className="mx-auto mb-2 h-8 w-8 text-[#22d3ee]/30" />
<p className="mb-1 text-sm text-gray-500">{t('walletUi.supporters.beFirstBacker')}</p>
<a href={ANGOR_PROJECT_URL} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 text-xs font-bold text-[#22d3ee]/80 hover:text-[#22d3ee]">
<ExternalLink className="h-3 w-3" /> {t('walletUi.supporters.backAngor')}
</a>
</div>
)}
</section>
</TabsContent>
{/* ── Community, mission, collaborators, partners, friends ── */}
<TabsContent value="community" className="space-y-6 pt-4">
<section className="rounded-2xl border border-[#ff0040]/25 bg-black/70 p-5">
<div className="flex items-center justify-center gap-2">
<Heart className="h-5 w-5 text-[#ff0040]" />
<h2 className="text-center text-lg font-black uppercase text-white">
{t('walletUi.supporters.whyTitle1')}{' '}
<span className="neon-red">{t('walletUi.supporters.whyTitle2')}</span>
</h2>
</div>
<p className="mx-auto mt-2 max-w-2xl text-center text-xs leading-relaxed text-gray-400">
{t('walletUi.supporters.whyDesc')}
</p>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ 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 }) => (
<div key={title} className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-center">
<div className="text-2xl">{emoji}</div>
<p className="mt-1 text-xs font-bold">{title}</p>
<p className="mt-0.5 text-[11px] text-gray-500">{desc}</p>
</div>
))}
</div>
</section>
<section>
<SectionHeader
icon={Users}
title={t('walletUi.supporters.collaborators')}
subtitle={t('walletUi.supporters.collaboratorsDesc')}
color="#ff0040"
/>
{COLLABORATORS.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{COLLABORATORS.map((c) => <CollaboratorCard key={c.name} collab={c} />)}
</div>
) : (
<div className="rounded-xl border border-dashed border-[#ff0040]/20 p-5 text-center">
<Users className="mx-auto mb-2 h-8 w-8 text-[#ff0040]/30" />
<p className="mb-1 text-sm text-gray-500">{t('walletUi.supporters.wantCollaborate')}</p>
<p className="mb-3 text-xs text-gray-600">{t('walletUi.supporters.wantCollaborateDesc')}</p>
<a href="https://nostrhub.io/naddr1qvzqqqrhnypzqg88e9f588nx7wyz4wt0ura23w3yd4hmztqrkr95utwyvgachjepqyd8wumn8ghj7emfwsh8x6rpddjhxur9v9ex2tnyd9uj7qq22d4kzar92dcx7arnhyhdwn" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 text-xs font-bold text-[#ff0040]/70 hover:text-[#ff0040]">
<ExternalLink className="h-3 w-3" /> {t('walletUi.supporters.viewNostrHub')}
</a>
</div>
)}
</section>
<section>
<SectionHeader icon={Handshake} title={t('walletUi.supporters.partners')} subtitle={t('walletUi.supporters.partnersDesc')} color="#ff6600" />
{PARTNERS.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PARTNERS.map((p) => <PartnerCard key={p.name} partner={p} />)}
</div>
) : (
<div className="rounded-xl border border-dashed border-[#ff6600]/20 p-5 text-center text-sm text-gray-500">
{t('walletUi.supporters.wantPartner')}
</div>
)}
</section>
<section>
<SectionHeader icon={Star} title={t('walletUi.supporters.friends')} subtitle={t('walletUi.supporters.friendsDesc')} color="#ccff00" />
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{FRIENDS.map((f) => <FriendCard key={f.name} friend={f} />)}
</div>
<div className="mt-3 rounded-xl border border-dashed border-[#ccff00]/20 p-4 text-center">
<p className="text-xs text-gray-500">{t('walletUi.supporters.wantListed')}</p>
<p className="mt-0.5 text-[11px] text-gray-600">{t('walletUi.supporters.wantListedDesc')}</p>
</div>
</section>
</TabsContent>
{/* ── Grantmakers ── */}
<TabsContent value="grants" className="space-y-4 pt-4">
<SectionHeader
icon={Handshake}
title={t('walletUi.supporters.grantsSection')}
subtitle={t('walletUi.supporters.grantsSectionDesc')}
color="#f7931a"
/>
<GrantmakerForm />
</TabsContent>
</Tabs>
{/* ── Footer ── */}
<div className="border-t border-gray-800 pt-5 text-center text-xs text-gray-500">
<p>{t('walletUi.supporters.thankYou')}</p>
<p className="mt-1.5">
<Link to="/license" className="text-[#ccff00]/70 underline transition-colors hover:text-[#ccff00]">{t('walletUi.supporters.license')}</Link>
{' '}&middot;{' '}
<Link to="/about" className="text-[#ccff00]/70 underline transition-colors hover:text-[#ccff00]">{t('nav.about')}</Link>
{' '}&middot;{' '}
<Link to="/faq" className="text-[#ccff00]/70 underline transition-colors hover:text-[#ccff00]">{t('walletUi.supporters.faq')}</Link>
</p>
</div>
</div>
</>
);
}