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

View file

@ -14,6 +14,8 @@ export interface ZapLeaderboardSender {
pubkey: string; pubkey: string;
sats: number; sats: number;
count: number; count: number;
/** Most recent receipt id for this sender — used for a verifiable link. */
receiptId?: string;
} }
export interface ZapLeaderboard { export interface ZapLeaderboard {
@ -52,7 +54,7 @@ export function useZapLeaderboard(since?: number, recipients?: string[]) {
); );
const targets = new Map<string, { sats: number; count: number }>(); 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) { for (const zap of receipts) {
const sats = zapReceiptSats(zap); const sats = zapReceiptSats(zap);
@ -72,9 +74,13 @@ export function useZapLeaderboard(since?: number, recipients?: string[]) {
try { try {
const sender = JSON.parse(description)?.pubkey as string | undefined; const sender = JSON.parse(description)?.pubkey as string | undefined;
if (sender) { 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.sats += sats;
cur.count += 1; cur.count += 1;
if (zap.created_at >= cur.at) {
cur.at = zap.created_at;
cur.receiptId = zap.id;
}
senders.set(sender, cur); senders.set(sender, cur);
} }
} catch { } catch {
@ -88,7 +94,7 @@ export function useZapLeaderboard(since?: number, recipients?: string[]) {
.map(([key, v]) => ({ key, ...v })) .map(([key, v]) => ({ key, ...v }))
.sort((a, b) => b.sats - a.sats), .sort((a, b) => b.sats - a.sats),
senders: [...senders.entries()] 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), .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.', heroDesc: 'SkateSpots is built by and for the community. Supporters, collaborators, partners and friends keeping this free and decentralized.',
backAngor: 'Back us on Angor', backAngor: 'Back us on Angor',
backers: 'Supporters', 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', anonymousBacker: 'Anonymous supporter',
beFirstBacker: 'No public supporters yet — be the first.', beFirstBacker: 'No public supporters yet — be the first.',
backersTotal: '{{count}} supporters · {{amount}} sats raised', backersTotal: '{{count}} supporters · {{amount}} sats raised',
@ -6515,6 +6522,12 @@ walletUi: {
payDev: 'Development time', payDev: 'Development time',
payOps: 'Operations', payOps: 'Operations',
payMarketing: 'Marketing', 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', impact: 'Impact so far',
impactDesc: 'What the community has built — funded by skaters.', impactDesc: 'What the community has built — funded by skaters.',
impactSpots: 'Spots', 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.', 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', backAngor: 'Apóyanos en Angor',
backers: 'Patrocinadores', 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', anonymousBacker: 'Patrocinador anónimo',
beFirstBacker: 'Aún no hay patrocinadores públicos — sé el primero.', beFirstBacker: 'Aún no hay patrocinadores públicos — sé el primero.',
backersTotal: '{{count}} patrocinadores · {{amount}} sats recaudados', backersTotal: '{{count}} patrocinadores · {{amount}} sats recaudados',
@ -6515,6 +6522,12 @@ walletUi: {
payDev: 'Tiempo de desarrollo', payDev: 'Tiempo de desarrollo',
payOps: 'Operaciones', payOps: 'Operaciones',
payMarketing: 'Marketing', 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', impact: 'Impacto hasta ahora',
impactDesc: 'Lo que ha construido la comunidad — financiado por patinadores.', impactDesc: 'Lo que ha construido la comunidad — financiado por patinadores.',
impactSpots: 'Spots', impactSpots: 'Spots',

View file

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

View file

@ -1,11 +1,11 @@
import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; 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 { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star, ShieldCheck, Server, Globe, Bot, Code2, Briefcase, Megaphone, TrendingUp } from 'lucide-react';
import { SupportSkateSpots } from '@/components/SupportSkateSpots'; import { SupportSkateSpots } from '@/components/SupportSkateSpots';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { BACKERS, BACKERS_TOTAL_SATS, tierOf, type Backer } from '@/lib/backers'; import { BACKERS, BACKERS_TOTAL_SATS, tierOf } from '@/lib/backers';
import { genUserName } from '@/lib/genUserName';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useGrowthStats } from '@/hooks/useGrowthStats'; import { useGrowthStats } from '@/hooks/useGrowthStats';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
@ -91,12 +91,12 @@ const FRIENDS: Friend[] = [
// ─── "What your support pays for" (words only — no figures) ────────────────── // ─── "What your support pays for" (words only — no figures) ──────────────────
const PAYS_FOR = [ const PAYS_FOR = [
{ icon: Server, key: 'walletUi.supporters.payRelay' }, { icon: Server, key: 'walletUi.supporters.payRelay', desc: 'walletUi.supporters.payRelayDesc' },
{ icon: Globe, key: 'walletUi.supporters.payDomain' }, { icon: Globe, key: 'walletUi.supporters.payDomain', desc: 'walletUi.supporters.payDomainDesc' },
{ icon: Bot, key: 'walletUi.supporters.payAi' }, { icon: Bot, key: 'walletUi.supporters.payAi', desc: 'walletUi.supporters.payAiDesc' },
{ icon: Code2, key: 'walletUi.supporters.payDev' }, { icon: Code2, key: 'walletUi.supporters.payDev', desc: 'walletUi.supporters.payDevDesc' },
{ icon: Briefcase, key: 'walletUi.supporters.payOps' }, { icon: Briefcase, key: 'walletUi.supporters.payOps', desc: 'walletUi.supporters.payOpsDesc' },
{ icon: Megaphone, key: 'walletUi.supporters.payMarketing' }, { icon: Megaphone, key: 'walletUi.supporters.payMarketing', desc: 'walletUi.supporters.payMarketingDesc' },
] as const; ] as const;
// ─── Section header ─────────────────────────────────────────────────────────── // ─── 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 = { const TIER_META = {
supporter: { color: '#22d3ee', key: 'walletUi.supporters.tierSupporter' }, supporter: { color: '#22d3ee', key: 'walletUi.supporters.tierSupporter' },
@ -230,61 +246,76 @@ const TIER_META = {
legend: { color: '#facc15', key: 'walletUi.supporters.tierLegend' }, legend: { color: '#facc15', key: 'walletUi.supporters.tierLegend' },
} as const; } as const;
function BackerCard({ backer }: { backer: Backer }) { function SupporterRow({ rank, entry }: { rank: number; entry: SupporterEntry }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useAuthor(backer.npub); const { data } = useAuthor(entry.anonymous ? undefined : entry.npub);
const meta = data?.metadata; const meta = data?.metadata;
const name = backer.name || meta?.display_name || meta?.name; const name = entry.anonymous
const avatar = backer.logo || meta?.picture; ? t('walletUi.supporters.anonymousBacker')
const display = name : entry.name || meta?.display_name || meta?.name
|| (backer.npub ? `${backer.npub.slice(0, 12)}…${backer.npub.slice(-6)}` : t('walletUi.supporters.anonymousBacker')); || (entry.npub ? `${entry.npub.slice(0, 12)}…${entry.npub.slice(-6)}` : t('walletUi.supporters.anonymousBacker'));
const initial = display.replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || 'S'; const avatar = entry.anonymous ? undefined : (entry.logo || meta?.picture);
const tier = TIER_META[tierOf(backer.amount)]; const tier = TIER_META[tierOf(entry.angorSats || undefined)];
const href = backer.npub ? `/${backer.npub}` : backer.url; 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 ( 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="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">
<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"> <span className="w-6 shrink-0 text-center text-sm font-bold text-gray-500">#{rank}</span>
{avatar ? ( <Avatar className="h-9 w-9 shrink-0">
<img {avatar && <AvatarImage src={avatar} alt={name} />}
src={avatar} <AvatarFallback className="font-black text-[#22d3ee]">
alt={name || 'Supporter'} {entry.anonymous ? '🛹' : name.replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || 'S'}
className="w-16 h-16 object-cover" </AvatarFallback>
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} </Avatar>
/> <div className="min-w-0 flex-1">
) : ( <div className="flex min-w-0 items-center gap-2">
<span className="text-2xl font-black text-[#22d3ee]">{initial}</span> {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>
<div className="min-w-0"> <div className="mt-0.5 flex items-center gap-2">
{href && backer.npub ? ( {entry.angor && (
<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 <span
className="mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide" 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` }} style={{ background: `${tier.color}22`, color: tier.color }}
> >
{t(tier.key)} {t(tier.key)}
</span> </span>
{backer.note && <p className="text-xs text-gray-500 mt-0.5">{backer.note}</p>} )}
{backer.txid && ( {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 <a
href={`https://mempool.space/tx/${backer.txid}`} href={proofUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={backer.txid} title={entry.txid || entry.receiptId}
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" 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" /> <ShieldCheck className="h-3 w-3" />
{t('walletUi.supporters.verifiedOnChain')} {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) ─────────────────────────────────────────── // ─── Placeholder card (empty slot) ───────────────────────────────────────────
function PlaceholderCard({ label }: { label: string }) { function PlaceholderCard({ label }: { label: string }) {
@ -337,11 +343,56 @@ function PlaceholderCard({ label }: { label: string }) {
export default function SupportersPage() { export default function SupportersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const totalUsd = useSatsToUsd(BACKERS_TOTAL_SATS);
const { data: zaps } = useZapLeaderboard(undefined, PLATFORM_OWNER_PUBKEYS); 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(); 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. // `growth-stats` is persisted, so an older cache may lack newer fields — guard every read.
const num = (v: number | undefined) => v ?? 0; const num = (v: number | undefined) => v ?? 0;
const labTotal = stats const labTotal = stats
@ -365,6 +416,9 @@ export default function SupportersPage() {
useSeoMeta({ useSeoMeta({
title: 'Supporters - SkateSpots', title: 'Supporters - SkateSpots',
description: 'The community keeping SkateSpots alive — supporters, collaborators, partners and friends.', 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 ( return (
@ -425,6 +479,9 @@ export default function SupportersPage() {
<p className="mt-4 text-xs text-gray-500"> <p className="mt-4 text-xs text-gray-500">
{t('walletUi.supporters.donateNote')} {t('walletUi.supporters.donateNote')}
</p> </p>
<p className="mt-2 text-center text-xs font-bold text-[#22d3ee]">
{t('walletUi.supporters.joinSupporters', { count: supporters.length })}
</p>
</div> </div>
{/* Lightning rail — the in-app way to chip in (zap publicly or pay quietly). */} {/* Lightning rail — the in-app way to chip in (zap publicly or pay quietly). */}
@ -440,13 +497,16 @@ export default function SupportersPage() {
color="#22d3ee" color="#22d3ee"
/> />
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <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 <div
key={key} 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]" /> <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> <span className="text-sm font-medium text-white">{t(key)}</span>
<p className="text-[11px] text-gray-500">{t(desc)}</p>
</div>
</div> </div>
))} ))}
</div> </div>
@ -469,10 +529,17 @@ export default function SupportersPage() {
</div> </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> </section>
)} )}
{/* ── 4. Supporters (Angor) ── */} {/* ── 4. Supporters (Angor + Lightning zaps) ── */}
<section> <section>
<SectionHeader <SectionHeader
icon={Zap} icon={Zap}
@ -481,22 +548,30 @@ export default function SupportersPage() {
color="#22d3ee" 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"> <div className="flex flex-col gap-2">
{BACKERS.map((b) => ( {supporters.map((entry, i) => (
<BackerCard key={b.npub || b.name || b.url} backer={b} /> <SupporterRow key={entry.key} rank={i + 1} entry={entry} />
))} ))}
</div> </div>
<p className="mt-4 text-center text-sm text-gray-400"> <p className="mt-4 text-center text-sm text-gray-400">
{t('walletUi.supporters.backersTotal', { {t('walletUi.supporters.supportersTotals', {
count: BACKERS.length, count: supporters.length,
amount: BACKERS_TOTAL_SATS.toLocaleString(), angor: BACKERS_TOTAL_SATS.toLocaleString(),
zap: zapTotal.toLocaleString(),
})} })}
{typeof totalUsd === 'number' && ( {typeof totalUsd === 'number' && (
<span className="text-gray-500"> · ≈ ${totalUsd.toFixed(2)}</span> <span className="text-gray-500"> · ≈ ${totalUsd.toFixed(2)}</span>
)} )}
</p> </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"> <div className="rounded-xl border border-dashed border-[#22d3ee]/20 p-8 text-center">
@ -515,23 +590,6 @@ export default function SupportersPage() {
)} )}
</section> </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 ── */} {/* ── 6. Collaborators ── */}
<section> <section>
<SectionHeader <SectionHeader