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 { 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 (
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue