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:
parent
87aca51248
commit
24e1369f6c
6 changed files with 210 additions and 117 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue