feat(supporters): unified ranked wall, verifiable zaps, tier legend, impact link, pay-for detail, anonymous flag, map chip, OG meta

This commit is contained in:
SkateSpots 2026-10-05 18:55:16 +07:00
parent 87aca51248
commit 24e1369f6c
6 changed files with 210 additions and 117 deletions

View file

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronUp, X } from 'lucide-react';
import { BACKERS } from '@/lib/backers';
import { useTranslation } from '@/contexts/language';
import { useMapFooterHidden } from '@/hooks/useMapFooterHidden';
import { cn } from '@/lib/utils';
@ -25,7 +26,7 @@ export function MapFooterStrip() {
{ to: '/newsletter', label: t('nav.newsletter') },
{ to: '/license', label: t('chromeUi.license') },
{ to: '/privacy', label: t('chromeUi.privacy') },
{ to: '/supporters', label: t('chromeUi.collaborators') },
{ to: '/supporters', label: t('walletUi.supporters.supportedBy', { count: BACKERS.length }) },
];
return (

View file

@ -14,6 +14,8 @@ export interface ZapLeaderboardSender {
pubkey: string;
sats: number;
count: number;
/** Most recent receipt id for this sender — used for a verifiable link. */
receiptId?: string;
}
export interface ZapLeaderboard {
@ -52,7 +54,7 @@ export function useZapLeaderboard(since?: number, recipients?: string[]) {
);
const targets = new Map<string, { sats: number; count: number }>();
const senders = new Map<string, { sats: number; count: number }>();
const senders = new Map<string, { sats: number; count: number; receiptId?: string; at: number }>();
for (const zap of receipts) {
const sats = zapReceiptSats(zap);
@ -72,9 +74,13 @@ export function useZapLeaderboard(since?: number, recipients?: string[]) {
try {
const sender = JSON.parse(description)?.pubkey as string | undefined;
if (sender) {
const cur = senders.get(sender) ?? { sats: 0, count: 0 };
const cur = senders.get(sender) ?? { sats: 0, count: 0, at: 0 };
cur.sats += sats;
cur.count += 1;
if (zap.created_at >= cur.at) {
cur.at = zap.created_at;
cur.receiptId = zap.id;
}
senders.set(sender, cur);
}
} catch {
@ -88,7 +94,7 @@ export function useZapLeaderboard(since?: number, recipients?: string[]) {
.map(([key, v]) => ({ key, ...v }))
.sort((a, b) => b.sats - a.sats),
senders: [...senders.entries()]
.map(([pubkey, v]) => ({ pubkey, ...v }))
.map(([pubkey, v]) => ({ pubkey, sats: v.sats, count: v.count, receiptId: v.receiptId }))
.sort((a, b) => b.sats - a.sats),
};
},

View file

@ -6496,7 +6496,14 @@ walletUi: {
heroDesc: 'SkateSpots is built by and for the community. Supporters, collaborators, partners and friends keeping this free and decentralized.',
backAngor: 'Back us on Angor',
backers: 'Supporters',
backersDesc: 'People who backed SkateSpots on Angor — decentralized, on-chain Bitcoin. Thank you.',
backersDesc: 'Skaters who backed SkateSpots — on-chain funding via Angor, and Lightning zaps to the project.',
sourceAngor: 'Angor',
supportersTotals: '{{count}} supporters · {{angor}} sats via Angor · {{zap}} sats zapped',
quietNote: 'Plus everyone who gave quietly — thank you. ❤️',
tiersNote: 'Tiers: Supporter under 100k sats · Patron 100k–1M · Legend 1M+',
joinSupporters: 'Join {{count}} supporters',
impactSeeStats: 'See the full picture →',
impactAsOf: 'live from the community relays',
anonymousBacker: 'Anonymous supporter',
beFirstBacker: 'No public supporters yet — be the first.',
backersTotal: '{{count}} supporters · {{amount}} sats raised',
@ -6515,6 +6522,12 @@ walletUi: {
payDev: 'Development time',
payOps: 'Operations',
payMarketing: 'Marketing',
payRelayDesc: 'Keeps the map online and resilient.',
payDomainDesc: 'The name, the certificate, the secure connection.',
payAiDesc: 'Sasquatch, the community guide.',
payDevDesc: 'Building and fixing SkateSpots.',
payOpsDesc: 'The unglamorous work that keeps it running.',
payMarketingDesc: 'Reaching new local scenes and skateshops.',
impact: 'Impact so far',
impactDesc: 'What the community has built — funded by skaters.',
impactSpots: 'Spots',

View file

@ -6496,7 +6496,14 @@ walletUi: {
heroDesc: 'SkateSpots lo construye y lo mantiene la comunidad. Patrocinadores, colaboradores, socios y amigos que lo mantienen libre y descentralizado.',
backAngor: 'Apóyanos en Angor',
backers: 'Patrocinadores',
backersDesc: 'Personas que han apoyado SkateSpots en Angor — Bitcoin descentralizado on-chain. Gracias.',
backersDesc: 'Patinadores que han apoyado SkateSpots — financiación on-chain vía Angor y zaps Lightning al proyecto.',
sourceAngor: 'Angor',
supportersTotals: '{{count}} patrocinadores · {{angor}} sats vía Angor · {{zap}} sats zapeados',
quietNote: 'Y a todos los que aportaron en silencio — gracias. ❤️',
tiersNote: 'Niveles: Colaborador menos de 100k sats · Patrón 100k–1M · Leyenda 1M+',
joinSupporters: 'Únete a {{count}} patrocinadores',
impactSeeStats: 'Ver el panorama completo →',
impactAsOf: 'en vivo desde los relés de la comunidad',
anonymousBacker: 'Patrocinador anónimo',
beFirstBacker: 'Aún no hay patrocinadores públicos — sé el primero.',
backersTotal: '{{count}} patrocinadores · {{amount}} sats recaudados',
@ -6515,6 +6522,12 @@ walletUi: {
payDev: 'Tiempo de desarrollo',
payOps: 'Operaciones',
payMarketing: 'Marketing',
payRelayDesc: 'Mantiene el mapa en línea y resiliente.',
payDomainDesc: 'El nombre, el certificado, la conexión segura.',
payAiDesc: 'Sasquatch, el guía de la comunidad.',
payDevDesc: 'Construir y arreglar SkateSpots.',
payOpsDesc: 'El trabajo poco glamuroso que lo mantiene en marcha.',
payMarketingDesc: 'Llegar a nuevas escenas locales y skateshops.',
impact: 'Impacto hasta ahora',
impactDesc: 'Lo que ha construido la comunidad — financiado por patinadores.',
impactSpots: 'Spots',

View file

@ -29,6 +29,8 @@ export interface Backer {
txid?: string;
/** Amount contributed, in satoshis. */
amount?: number;
/** List without revealing name/logo/npub (privacy opt-out). */
anonymous?: boolean;
/** Small line under the name, e.g. "Founding backer". */
note?: string;
}

View file

@ -1,11 +1,11 @@
import { useMemo } 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { BACKERS, BACKERS_TOTAL_SATS, tierOf, type Backer } from '@/lib/backers';
import { genUserName } from '@/lib/genUserName';
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';
@ -91,12 +91,12 @@ const FRIENDS: Friend[] = [
// ─── "What your support pays for" (words only — no figures) ──────────────────
const PAYS_FOR = [
{ icon: Server, key: 'walletUi.supporters.payRelay' },
{ icon: Globe, key: 'walletUi.supporters.payDomain' },
{ icon: Bot, key: 'walletUi.supporters.payAi' },
{ icon: Code2, key: 'walletUi.supporters.payDev' },
{ icon: Briefcase, key: 'walletUi.supporters.payOps' },
{ icon: Megaphone, key: 'walletUi.supporters.payMarketing' },
{ 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;
// ─── Section header ───────────────────────────────────────────────────────────
@ -222,7 +222,23 @@ function FriendCard({ friend }: { friend: Friend }) {
);
}
// ─── Backer card (Angor backers) ─────────────────────────────────────────────
// ─── Supporter types + row (unified: Angor + Lightning) ──────────────────────
interface SupporterEntry {
key: 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' },
@ -230,61 +246,76 @@ const TIER_META = {
legend: { color: '#facc15', key: 'walletUi.supporters.tierLegend' },
} as const;
function BackerCard({ backer }: { backer: Backer }) {
function SupporterRow({ rank, entry }: { rank: number; entry: SupporterEntry }) {
const { t } = useTranslation();
const { data } = useAuthor(backer.npub);
const { data } = useAuthor(entry.anonymous ? undefined : entry.npub);
const meta = data?.metadata;
const name = backer.name || meta?.display_name || meta?.name;
const avatar = backer.logo || meta?.picture;
const display = name
|| (backer.npub ? `${backer.npub.slice(0, 12)}…${backer.npub.slice(-6)}` : t('walletUi.supporters.anonymousBacker'));
const initial = display.replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || 'S';
const tier = TIER_META[tierOf(backer.amount)];
const href = backer.npub ? `/${backer.npub}` : backer.url;
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.angorSats || 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 flex-col items-center gap-3 p-5 rounded-xl bg-black/60 border border-white/10 hover:border-[#22d3ee]/40 hover:bg-[#22d3ee]/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-[#22d3ee]/40 transition-all">
{avatar ? (
<img
src={avatar}
alt={name || 'Supporter'}
className="w-16 h-16 object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<span className="text-2xl font-black text-[#22d3ee]">{initial}</span>
)}
<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">
{entry.angor && (
<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 }}
>
{t(tier.key)}
</span>
)}
{entry.note && !entry.anonymous && <span className="text-[11px] text-gray-500">{entry.note}</span>}
</div>
</div>
<div className="min-w-0">
{href && backer.npub ? (
<Link to={href} className="font-bold text-sm text-white hover:text-[#22d3ee] transition-colors">
{display}
</Link>
) : href ? (
<a href={href} target="_blank" rel="noopener noreferrer" className="font-bold text-sm text-white hover:text-[#22d3ee] transition-colors">
{display}
</a>
) : (
<p className="font-bold text-sm text-white">{display}</p>
)}
{typeof backer.amount === 'number' && (
<p className="text-xs font-mono mt-0.5 text-[#22d3ee]/80">{backer.amount.toLocaleString()} sats</p>
)}
<span
className="mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide"
style={{ background: `${tier.color}22`, color: tier.color, border: `1px solid ${tier.color}55` }}
>
{t(tier.key)}
<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>
{backer.note && <p className="text-xs text-gray-500 mt-0.5">{backer.note}</p>}
{backer.txid && (
{proofUrl && (
<a
href={`https://mempool.space/tx/${backer.txid}`}
href={proofUrl}
target="_blank"
rel="noopener noreferrer"
title={backer.txid}
className="mt-1 inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-emerald-400/80 hover:text-emerald-300 transition-colors"
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')}
@ -295,31 +326,6 @@ function BackerCard({ backer }: { backer: Backer }) {
);
}
// ─── Zapper row (Lightning supporters) ───────────────────────────────────────
function ZapperRow({ rank, pubkey, sats }: { rank: number; pubkey: string; sats: number }) {
const { data } = useAuthor(pubkey);
const meta = data?.metadata;
const name = meta?.display_name || meta?.name || genUserName(pubkey);
return (
<Link
to={`/${nip19.npubEncode(pubkey)}`}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/60 px-3 py-2.5 transition-colors hover:border-[#f7931a]/50"
>
<span className="w-6 text-center text-sm font-bold text-gray-500">#{rank}</span>
<Avatar className="h-9 w-9">
<AvatarImage src={meta?.picture} alt={name} />
<AvatarFallback>{name.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-white">{name}</span>
<span className="flex items-center gap-1 font-bold text-[#f7931a]">
<Zap className="h-3.5 w-3.5" />
{sats.toLocaleString()}
</span>
</Link>
);
}
// ─── Placeholder card (empty slot) ───────────────────────────────────────────
function PlaceholderCard({ label }: { label: string }) {
@ -337,11 +343,56 @@ function PlaceholderCard({ label }: { label: string }) {
export default function SupportersPage() {
const { t } = useTranslation();
const totalUsd = useSatsToUsd(BACKERS_TOTAL_SATS);
const { data: zaps } = useZapLeaderboard(undefined, PLATFORM_OWNER_PUBKEYS);
const topZappers = (zaps?.senders ?? []).slice(0, 10);
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();
// 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,
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,
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
@ -365,6 +416,9 @@ export default function SupportersPage() {
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 (
@ -425,6 +479,9 @@ export default function SupportersPage() {
<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). */}
@ -440,13 +497,16 @@ export default function SupportersPage() {
color="#22d3ee"
/>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{PAYS_FOR.map(({ icon: Icon, key }) => (
{PAYS_FOR.map(({ icon: Icon, key, desc }) => (
<div
key={key}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/60 px-4 py-3"
className="flex items-start gap-3 rounded-xl border border-white/10 bg-black/60 px-4 py-3"
>
<Icon className="h-4 w-4 flex-shrink-0 text-[#22d3ee]" />
<span className="text-sm font-medium text-white">{t(key)}</span>
<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>
@ -469,10 +529,17 @@ export default function SupportersPage() {
</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) ── */}
{/* ── 4. Supporters (Angor + Lightning zaps) ── */}
<section>
<SectionHeader
icon={Zap}
@ -481,22 +548,30 @@ export default function SupportersPage() {
color="#22d3ee"
/>
{BACKERS.length > 0 ? (
{supporters.length > 0 ? (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{BACKERS.map((b) => (
<BackerCard key={b.npub || b.name || b.url} backer={b} />
<div className="flex flex-col gap-2">
{supporters.map((entry, i) => (
<SupporterRow key={entry.key} rank={i + 1} entry={entry} />
))}
</div>
<p className="mt-4 text-center text-sm text-gray-400">
{t('walletUi.supporters.backersTotal', {
count: BACKERS.length,
amount: BACKERS_TOTAL_SATS.toLocaleString(),
{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>
<p className="mt-3 text-center text-[11px] text-gray-600">
{t('walletUi.supporters.tiersNote')}
</p>
</>
) : (
<div className="rounded-xl border border-dashed border-[#22d3ee]/20 p-8 text-center">
@ -515,23 +590,6 @@ export default function SupportersPage() {
)}
</section>
{/* ── 5. Top zappers (Lightning) ── */}
{topZappers.length > 0 && (
<section>
<SectionHeader
icon={Zap}
title={t('walletUi.supporters.topZappers')}
subtitle={t('walletUi.supporters.topZappersDesc')}
color="#f7931a"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{topZappers.map((z, i) => (
<ZapperRow key={z.pubkey} rank={i + 1} pubkey={z.pubkey} sats={z.sats} />
))}
</div>
</section>
)}
{/* ── 6. Collaborators ── */}
<section>
<SectionHeader