917 lines
40 KiB
TypeScript
917 lines
40 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { Link } from 'react-router-dom';
|
||
import { nip19 } from 'nostr-tools';
|
||
import QRCode from 'qrcode';
|
||
import { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star, ShieldCheck, Server, Globe, Bot, Code2, Briefcase, Megaphone, TrendingUp, Check, Copy } from 'lucide-react';
|
||
import { SupportSkateSpots } from '@/components/SupportSkateSpots';
|
||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||
import { BACKERS, BACKERS_TOTAL_SATS, tierOf } from '@/lib/backers';
|
||
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';
|
||
|
||
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
|
||
|
||
// Silent-payments "payment name" (BIP-353) → BIP-321 URI with a silent payment
|
||
// address (sp) + a BOLT 12 Lightning offer (lno). Resolved from
|
||
// skatespots.user._bitcoin-payment.silentpayments.net (2026-10-05). If the name
|
||
// is re-pointed, update SILENT_URI from the DNS TXT record.
|
||
const SILENT_NAME = 'skatespots@silentpayments.net';
|
||
const SILENT_URI = 'bitcoin:?sp=sp1qq2xlxmvh5tpkjw460cfjf5ndz87fc45k0fm3glyalqxq4m436u8nyqa745dyh55pz4vchhjzam5tmlvvlp6whhkqmdk3e7xvfp4y80jhvq40lyl3&lno=lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878s8y79jwe96rpahf567zyt0lhe26sxpsace8n9u5l359n254e639mqqgpas2cxvhc6ftcr6wrh6nv36mz53qhsfvwrxvgf8mnvq4tsg0z2plqqxvv8geddvc60pys4ja0ajvhhhka2zzfdsnmkvygu872fa0awyqvv8rrpg6k0kg8zpxh8hy3hm9pr35hfa73s92mk38xfqfgfz98v0w34c4z8un6ljxlfjcu69m3de0nqarvh8k8kqqey5kgfmvyfhrg3wltewtj8xn35aevy96dws40hygx7xpfu2k0ty8rm4mv262df8f9q3z2strn4cmh8svss';
|
||
|
||
// 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) ──────────────
|
||
|
||
function SilentPaymentsCard({ className }: { className?: string }) {
|
||
const { t } = useTranslation();
|
||
const [qr, setQr] = useState<string | null>(null);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
QRCode.toDataURL(SILENT_URI, { width: 200, margin: 1 })
|
||
.then((url) => { if (active) setQr(url); })
|
||
.catch(() => { if (active) setQr(null); });
|
||
return () => { active = false; };
|
||
}, []);
|
||
|
||
const copy = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(SILENT_NAME);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1500);
|
||
} catch { /* clipboard blocked */ }
|
||
};
|
||
|
||
return (
|
||
<div className={`rounded-2xl border-2 border-white/15 bg-black/60 p-6 ${className ?? ''}`}>
|
||
<div className="flex items-center gap-2.5">
|
||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/5 text-lg">🕵️</span>
|
||
<div>
|
||
<h3 className="text-lg font-black uppercase leading-tight text-white">
|
||
{t('walletUi.supporters.silentTitle')}
|
||
</h3>
|
||
<p className="text-xs text-gray-400">{t('walletUi.supporters.silentDesc')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-col items-center gap-4 sm:flex-row sm:items-start">
|
||
{qr && (
|
||
<img src={qr} alt={SILENT_NAME} className="h-[200px] w-[200px] shrink-0 rounded-xl bg-white p-2" />
|
||
)}
|
||
<div className="min-w-0 text-center sm:text-left">
|
||
<p className="break-all font-mono text-sm text-white">{SILENT_NAME}</p>
|
||
<button
|
||
onClick={copy}
|
||
className="mt-2 inline-flex min-h-0 min-w-0 items-center gap-1.5 rounded-full border border-white/20 px-3 py-1.5 text-xs font-bold text-gray-200 transition-colors hover:bg-white/5"
|
||
>
|
||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||
{copied ? t('walletUi.supporters.silentCopied') : t('walletUi.supporters.silentCopyName')}
|
||
</button>
|
||
<p className="mt-3 text-[11px] leading-relaxed text-gray-500">
|
||
{t('walletUi.supporters.silentWallets')}
|
||
</p>
|
||
</div>
|
||
</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-6 md:mb-8">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<Icon className="h-5 w-5 flex-shrink-0" style={{ color }} />
|
||
<h2 className="text-2xl md:text-3xl font-black uppercase tracking-wide" style={{ color }}>
|
||
{title}
|
||
</h2>
|
||
<div className="flex-1 h-px" style={{ background: `linear-gradient(to right, ${color}40, transparent)` }} />
|
||
</div>
|
||
{subtitle && <p className="text-gray-400 text-sm ml-8">{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;
|
||
}
|
||
|
||
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 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>}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// ─── Placeholder card (empty slot) ───────────────────────────────────────────
|
||
|
||
function PlaceholderCard({ label }: { label: string }) {
|
||
return (
|
||
<div className="flex flex-col items-center gap-3 p-5 rounded-xl border border-dashed border-white/10 text-center opacity-40">
|
||
<div className="w-16 h-16 rounded-2xl border border-dashed border-white/20 flex items-center justify-center">
|
||
<span className="text-2xl">?</span>
|
||
</div>
|
||
<p className="text-xs text-gray-600 font-medium">{label}</p>
|
||
</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);
|
||
|
||
// 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,
|
||
});
|
||
});
|
||
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;
|
||
} else {
|
||
map.set(npub, {
|
||
key: npub,
|
||
pubkey: z.pubkey,
|
||
npub,
|
||
sats: z.sats,
|
||
angorSats: 0,
|
||
angor: false,
|
||
zap: true,
|
||
receiptId: z.receiptId,
|
||
});
|
||
}
|
||
}
|
||
return [...map.values()].sort((a, b) => b.sats - a.sats);
|
||
}, [zappers]);
|
||
|
||
// `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 (
|
||
<>
|
||
<div className="container max-w-4xl px-4 py-12 md:py-16 space-y-16">
|
||
|
||
{/* ── Hero ── */}
|
||
<div className="text-center">
|
||
<div className="text-6xl mb-4">⚡</div>
|
||
<h1 className="text-5xl md:text-6xl font-black uppercase mb-3 leading-none">
|
||
<span className="neon-red">{t('walletUi.supporters.our')}</span>{' '}
|
||
<span className="neon-lime">{t('walletUi.supporters.community')}</span>
|
||
</h1>
|
||
<p className="text-gray-400 max-w-lg mx-auto text-base mb-8">
|
||
{t('walletUi.supporters.heroDesc')}
|
||
</p>
|
||
<a
|
||
href={ANGOR_PROJECT_URL}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ff6600] text-white font-black uppercase text-sm hover:opacity-90 transition-all shadow-lg shadow-[#ff0040]/30"
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
{t('walletUi.supporters.backAngor')}
|
||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||
</a>
|
||
</div>
|
||
|
||
{/* ── 1. Support ── */}
|
||
<section>
|
||
<SectionHeader
|
||
icon={Zap}
|
||
title={t('walletUi.supporters.support')}
|
||
subtitle={t('walletUi.supporters.supportDesc')}
|
||
color="#f7931a"
|
||
/>
|
||
|
||
{/* Ways to support — purpose-first trio (each scrolls to its rail). */}
|
||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
{[
|
||
{ id: 'support-lightning', emoji: '⚡', title: t('walletUi.supporters.wayLightningTitle'), desc: t('walletUi.supporters.wayLightningDesc') },
|
||
{ id: 'support-silent', emoji: '🕵️', title: t('walletUi.supporters.waySilentTitle'), desc: t('walletUi.supporters.waySilentDesc') },
|
||
{ id: 'support-angor', emoji: '⛓', title: t('walletUi.supporters.wayAngorTitle'), desc: t('walletUi.supporters.wayAngorDesc') },
|
||
].map((w) => (
|
||
<button
|
||
key={w.id}
|
||
onClick={() => document.getElementById(w.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||
className="min-h-0 min-w-0 rounded-xl border border-white/10 bg-black/60 p-4 text-left transition-colors hover:border-[#ccff00]/40 hover:bg-white/5"
|
||
>
|
||
<span className="text-2xl">{w.emoji}</span>
|
||
<p className="mt-2 text-sm font-black uppercase text-white">{w.title}</p>
|
||
<p className="mt-1 text-xs text-gray-400">{w.desc}</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div id="support-angor" className="scroll-mt-24 rounded-2xl border-2 border-[#f7931a]/30 bg-black/60 p-8 text-center">
|
||
<div className="text-5xl mb-4">🧡⚡</div>
|
||
<h3 className="text-xl font-black uppercase mb-2 text-white">{t('walletUi.supporters.backTitle')}</h3>
|
||
<p className="text-sm text-gray-400 max-w-lg mx-auto mb-6 leading-relaxed">
|
||
{t('walletUi.supporters.backDesc')}
|
||
</p>
|
||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||
<a
|
||
href={ANGOR_PROJECT_URL}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border-2 border-[#f7931a]/40 text-[#f7931a] font-bold text-sm hover:bg-[#f7931a]/10 transition-all"
|
||
>
|
||
<ExternalLink className="h-4 w-4" />
|
||
{t('walletUi.supporters.openAngor')}
|
||
</a>
|
||
<span className="text-xs text-gray-500">
|
||
{t('walletUi.supporters.orZap')}
|
||
</span>
|
||
</div>
|
||
<p className="mt-4 text-xs text-gray-500">
|
||
{t('walletUi.supporters.donateNote')}
|
||
</p>
|
||
<p className="mt-2 text-center text-xs font-bold text-[#22d3ee]">
|
||
{t('walletUi.supporters.joinSupporters', { count: supporters.length })}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Lightning rail — the in-app way to chip in (zap publicly or pay quietly). */}
|
||
<div id="support-lightning" className="scroll-mt-24">
|
||
<SupportSkateSpots className="mt-4" />
|
||
</div>
|
||
|
||
{/* Silent payments — private on-chain (and Lightning) via a BIP-353 name. */}
|
||
<div id="support-silent" className="scroll-mt-24">
|
||
<SilentPaymentsCard className="mt-4" />
|
||
</div>
|
||
</section>
|
||
|
||
{/* ── 2. What your support pays for (words only) ── */}
|
||
<section>
|
||
<SectionHeader
|
||
icon={Heart}
|
||
title={t('walletUi.supporters.paysFor')}
|
||
subtitle={t('walletUi.supporters.paysForDesc')}
|
||
color="#22d3ee"
|
||
/>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||
{PAYS_FOR.map(({ icon: Icon, key, desc }) => (
|
||
<div
|
||
key={key}
|
||
className="flex items-start gap-3 rounded-xl border border-white/10 bg-black/60 px-4 py-3"
|
||
>
|
||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#22d3ee]" />
|
||
<div className="min-w-0">
|
||
<span className="text-sm font-medium text-white">{t(key)}</span>
|
||
<p className="text-[11px] text-gray-500">{t(desc)}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* ── 3. Impact so far (what the community built) ── */}
|
||
{impact.length > 0 && (
|
||
<section>
|
||
<SectionHeader
|
||
icon={TrendingUp}
|
||
title={t('walletUi.supporters.impact')}
|
||
subtitle={t('walletUi.supporters.impactDesc')}
|
||
color="#ccff00"
|
||
/>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||
{impact.map(({ key, value }) => (
|
||
<div key={key} className="rounded-xl border border-white/10 bg-black/60 px-4 py-3 text-center">
|
||
<div className="text-2xl font-black text-[#ccff00]">{value.toLocaleString()}</div>
|
||
<div className="text-xs text-gray-400">{t(key)}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="mt-4 text-center text-xs text-gray-500">
|
||
<Link to="/stats" className="text-[#ccff00]/70 underline transition-colors hover:text-[#ccff00]">
|
||
{t('walletUi.supporters.impactSeeStats')}
|
||
</Link>
|
||
{' · '}
|
||
{t('walletUi.supporters.impactAsOf')}
|
||
</p>
|
||
</section>
|
||
)}
|
||
|
||
{/* ── 4. Supporters (Angor + Lightning zaps) ── */}
|
||
<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-2">
|
||
{(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-3 text-center">
|
||
<button
|
||
onClick={() => setShowAll((v) => !v)}
|
||
className="min-h-0 min-w-0 text-xs 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-4 text-center text-sm 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-2 text-center text-xs text-gray-600">
|
||
{t('walletUi.supporters.quietNote')}
|
||
</p>
|
||
<TierLadder supporters={supporters} />
|
||
</>
|
||
) : (
|
||
<div className="rounded-xl border border-dashed border-[#22d3ee]/20 p-8 text-center">
|
||
<Zap className="h-10 w-10 mx-auto mb-3 text-[#22d3ee]/30" />
|
||
<p className="text-gray-500 text-sm mb-1">{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] transition-colors"
|
||
>
|
||
<ExternalLink className="h-3 w-3" />
|
||
{t('walletUi.supporters.backAngor')}
|
||
</a>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* ── 6. Collaborators ── */}
|
||
<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 sm:grid-cols-2 gap-3">
|
||
{COLLABORATORS.map((c) => (
|
||
<CollaboratorCard key={c.name} collab={c} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-xl border border-dashed border-[#ff0040]/20 p-8 text-center">
|
||
<Users className="h-10 w-10 mx-auto mb-3 text-[#ff0040]/30" />
|
||
<p className="text-gray-500 text-sm mb-1">{t('walletUi.supporters.wantCollaborate')}</p>
|
||
<p className="text-xs text-gray-600 mb-4">{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] transition-colors"
|
||
>
|
||
<ExternalLink className="h-3 w-3" />
|
||
{t('walletUi.supporters.viewNostrHub')}
|
||
</a>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* ── 7. Partners ── */}
|
||
<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 sm:grid-cols-2 gap-3">
|
||
{PARTNERS.map((p) => (
|
||
<PartnerCard key={p.name} partner={p} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-4">
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<PlaceholderCard key={i} label={t('walletUi.supporters.comingSoon')} />
|
||
))}
|
||
</div>
|
||
<div className="rounded-xl border border-dashed border-[#ff6600]/20 p-6 text-center">
|
||
<p className="text-gray-500 text-sm">{t('walletUi.supporters.wantPartner')}</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
{/* ── 8. Friends ── */}
|
||
<section>
|
||
<SectionHeader
|
||
icon={Star}
|
||
title={t('walletUi.supporters.friends')}
|
||
subtitle={t('walletUi.supporters.friendsDesc')}
|
||
color="#ccff00"
|
||
/>
|
||
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||
{FRIENDS.map((f) => (
|
||
<FriendCard key={f.name} friend={f} />
|
||
))}
|
||
{/* Fill remaining slots up to 5 with placeholders */}
|
||
{FRIENDS.length < 5 && Array.from({ length: 5 - FRIENDS.length }).map((_, i) => (
|
||
<PlaceholderCard key={`ph-${i}`} label={t('walletUi.supporters.comingSoon')} />
|
||
))}
|
||
</div>
|
||
<div className="mt-4 rounded-xl border border-dashed border-[#ccff00]/20 p-5 text-center">
|
||
<p className="text-gray-500 text-sm mb-1">{t('walletUi.supporters.wantListed')}</p>
|
||
<p className="text-xs text-gray-600">
|
||
{t('walletUi.supporters.wantListedDesc')}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ── Mission ── */}
|
||
<section>
|
||
<div className="relative rounded-2xl overflow-hidden border-2 border-[#ff0040]/30 bg-black/80 p-8 md:p-10">
|
||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00]" />
|
||
<div className="flex justify-center mb-5">
|
||
<Heart className="h-10 w-10 text-[#ff0040]" />
|
||
</div>
|
||
<h2 className="text-2xl font-black uppercase text-center mb-3">
|
||
{t('walletUi.supporters.whyTitle1')} <span className="neon-red">{t('walletUi.supporters.whyTitle2')}</span>
|
||
</h2>
|
||
<p className="text-gray-400 text-sm text-center max-w-2xl mx-auto mb-6 leading-relaxed">
|
||
{t('walletUi.supporters.whyDesc')}
|
||
</p>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
{[
|
||
{ 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="p-4 rounded-xl bg-white/5 border border-white/10 text-center">
|
||
<div className="text-3xl mb-2">{emoji}</div>
|
||
<p className="font-bold text-sm mb-1">{title}</p>
|
||
<p className="text-xs text-gray-500">{desc}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ── Footer ── */}
|
||
<div className="border-t border-gray-800 pt-8 text-center space-y-2 text-sm text-gray-500">
|
||
<p>{t('walletUi.supporters.thankYou')}</p>
|
||
<p>
|
||
<Link to="/license" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">{t('walletUi.supporters.license')}</Link>
|
||
{' '}·{' '}
|
||
<Link to="/about" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">{t('nav.about')}</Link>
|
||
{' '}·{' '}
|
||
<Link to="/faq" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">{t('walletUi.supporters.faq')}</Link>
|
||
</p>
|
||
</div>
|
||
|
||
</div>
|
||
</>
|
||
);
|
||
}
|