From 24e1369f6c95f7689e9b2949bf6594755d0b0d92 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 18:55:16 +0700 Subject: [PATCH] feat(supporters): unified ranked wall, verifiable zaps, tier legend, impact link, pay-for detail, anonymous flag, map chip, OG meta --- src/components/MapFooterStrip.tsx | 3 +- src/hooks/useZapLeaderboard.ts | 12 +- src/i18n/en.ts | 15 +- src/i18n/es.ts | 15 +- src/lib/backers.ts | 2 + src/pages/SupportersPage.tsx | 280 ++++++++++++++++++------------ 6 files changed, 210 insertions(+), 117 deletions(-) diff --git a/src/components/MapFooterStrip.tsx b/src/components/MapFooterStrip.tsx index cabb73f..7f5305a 100644 --- a/src/components/MapFooterStrip.tsx +++ b/src/components/MapFooterStrip.tsx @@ -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 ( diff --git a/src/hooks/useZapLeaderboard.ts b/src/hooks/useZapLeaderboard.ts index f9278ae..6e28cfa 100644 --- a/src/hooks/useZapLeaderboard.ts +++ b/src/hooks/useZapLeaderboard.ts @@ -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(); - const senders = new Map(); + const senders = new Map(); 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), }; }, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b4b33b5..89e6752 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 22f1302..0cca777 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/backers.ts b/src/lib/backers.ts index 32d2270..b07c47a 100644 --- a/src/lib/backers.ts +++ b/src/lib/backers.ts @@ -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; } diff --git a/src/pages/SupportersPage.tsx b/src/pages/SupportersPage.tsx index 8d40f5d..1394a1b 100644 --- a/src/pages/SupportersPage.tsx +++ b/src/pages/SupportersPage.tsx @@ -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 ? ( + {name} + ) : ( + {name} + ) + ) : ( + {name} + ); return ( -
-
- {avatar ? ( - {name { (e.target as HTMLImageElement).style.display = 'none'; }} - /> - ) : ( - {initial} - )} +
+ #{rank} + + {avatar && } + + {entry.anonymous ? '🛹' : name.replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || 'S'} + + +
+
+ {nameEl} + {entry.angor && ( + + {t('walletUi.supporters.sourceAngor')} + + )} + {entry.zap && } +
+
+ {entry.angor && ( + + {t(tier.key)} + + )} + {entry.note && !entry.anonymous && {entry.note}} +
-
- {href && backer.npub ? ( - - {display} - - ) : href ? ( - - {display} - - ) : ( -

{display}

- )} - {typeof backer.amount === 'number' && ( -

{backer.amount.toLocaleString()} sats

- )} - - {t(tier.key)} +
+ + {entry.sats.toLocaleString()}sats - {backer.note &&

{backer.note}

} - {backer.txid && ( + {proofUrl && ( {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 ( - - #{rank} - - - {name.slice(0, 1).toUpperCase()} - - {name} - - - {sats.toLocaleString()} - - - ); -} - // ─── 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(() => { + const map = new Map(); + BACKERS.forEach((b, i) => { + const npub = b.anonymous ? undefined : b.npub; + const key = (npub || b.url || b.name || `anon-${i}`).toLowerCase(); + map.set(key, { + key, + 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() {

{t('walletUi.supporters.donateNote')}

+

+ {t('walletUi.supporters.joinSupporters', { count: supporters.length })} +

{/* Lightning rail — the in-app way to chip in (zap publicly or pay quietly). */} @@ -440,13 +497,16 @@ export default function SupportersPage() { color="#22d3ee" />
- {PAYS_FOR.map(({ icon: Icon, key }) => ( + {PAYS_FOR.map(({ icon: Icon, key, desc }) => (
- - {t(key)} + +
+ {t(key)} +

{t(desc)}

+
))}
@@ -469,10 +529,17 @@ export default function SupportersPage() {
))}
+

+ + {t('walletUi.supporters.impactSeeStats')} + + {' · '} + {t('walletUi.supporters.impactAsOf')} +

)} - {/* ── 4. Supporters (Angor) ── */} + {/* ── 4. Supporters (Angor + Lightning zaps) ── */}
- {BACKERS.length > 0 ? ( + {supporters.length > 0 ? ( <> -
- {BACKERS.map((b) => ( - +
+ {supporters.map((entry, i) => ( + ))}
+

- {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' && ( · ≈ ${totalUsd.toFixed(2)} )}

+

+ {t('walletUi.supporters.quietNote')} +

+

+ {t('walletUi.supporters.tiersNote')} +

) : (
@@ -515,23 +590,6 @@ export default function SupportersPage() { )}
- {/* ── 5. Top zappers (Lightning) ── */} - {topZappers.length > 0 && ( -
- -
- {topZappers.map((z, i) => ( - - ))} -
-
- )} - {/* ── 6. Collaborators ── */}