feat(supporters): add tiers/badges and a unified top-zappers block

This commit is contained in:
SkateSpots 2026-10-05 18:00:19 +07:00
parent bf96a9e3a1
commit b88379a390
4 changed files with 86 additions and 2 deletions

View file

@ -6502,6 +6502,11 @@ walletUi: {
backersTotal: '{{count}} supporters · {{amount}} sats raised',
verifiedOnChain: 'On-chain verified',
supportedBy: 'Supported by {{count}}',
tierSupporter: 'Supporter',
tierPatron: 'Patron',
tierLegend: 'Legend',
topZappers: 'Top zappers',
topZappersDesc: 'Skaters who zapped the map — Lightning support, all-time.',
support: 'Support',
supportDesc: 'Fund SkateSpots on Angor — decentralized, on-chain Bitcoin crowdfunding. No middleman, no KYC.',
collaborators: 'Collaborators',

View file

@ -6502,6 +6502,11 @@ walletUi: {
backersTotal: '{{count}} patrocinadores · {{amount}} sats recaudados',
verifiedOnChain: 'Verificado on-chain',
supportedBy: 'Apoyado por {{count}}',
tierSupporter: 'Colaborador',
tierPatron: 'Patrón',
tierLegend: 'Leyenda',
topZappers: 'Principales zapeadores',
topZappersDesc: 'Patinadores que han zapeado el mapa — apoyo por Lightning, histórico.',
support: 'Apoyo',
supportDesc: 'Financia SkateSpots en Angor — crowdfunding descentralizado on-chain en Bitcoin. Sin intermediarios, sin KYC.',
collaborators: 'Colaboradores',

View file

@ -50,3 +50,16 @@ export const BACKERS: Backer[] = [
/** Total raised across the listed supporters, in satoshis. */
export const BACKERS_TOTAL_SATS = BACKERS.reduce((sum, b) => sum + (b.amount ?? 0), 0);
export type SupporterTier = 'supporter' | 'patron' | 'legend';
/**
* Tier by amount. The 100,000-sat boundary is the project's Angor penalty
* threshold: at/below it a contribution is donation-like, above it's an
* investment with a recovery path — so the tiers mirror the two rails honestly.
*/
export function tierOf(sats: number | undefined): SupporterTier {
if (!sats || sats < 100_000) return 'supporter';
if (sats < 1_000_000) return 'patron';
return 'legend';
}

View file

@ -1,10 +1,14 @@
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 } from 'lucide-react';
import { SupportSkateSpots } from '@/components/SupportSkateSpots';
import { BACKERS, BACKERS_TOTAL_SATS, type Backer } from '@/lib/backers';
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 { useAuthor } from '@/hooks/useAuthor';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
import { useTranslation } from '@/contexts/language';
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
@ -207,6 +211,12 @@ function FriendCard({ friend }: { friend: Friend }) {
// ─── Backer card (Angor backers) ─────────────────────────────────────────────
const TIER_META = {
supporter: { color: '#22d3ee', key: 'walletUi.supporters.tierSupporter' },
patron: { color: '#f7931a', key: 'walletUi.supporters.tierPatron' },
legend: { color: '#facc15', key: 'walletUi.supporters.tierLegend' },
} as const;
function BackerCard({ backer }: { backer: Backer }) {
const { t } = useTranslation();
const { data } = useAuthor(backer.npub);
@ -216,6 +226,7 @@ function BackerCard({ backer }: { backer: Backer }) {
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;
return (
@ -247,6 +258,12 @@ function BackerCard({ backer }: { backer: Backer }) {
{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)}
</span>
{backer.note && <p className="text-xs text-gray-500 mt-0.5">{backer.note}</p>}
{backer.txid && (
<a
@ -265,6 +282,31 @@ 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 }) {
@ -283,6 +325,8 @@ function PlaceholderCard({ label }: { label: string }) {
export default function SupportersPage() {
const { t } = useTranslation();
const totalUsd = useSatsToUsd(BACKERS_TOTAL_SATS);
const { data: zaps } = useZapLeaderboard();
const topZappers = (zaps?.senders ?? []).slice(0, 10);
useSeoMeta({
title: 'Supporters - SkateSpots',
description: 'The community keeping SkateSpots alive — supporters, collaborators, partners and friends.',
@ -395,7 +439,24 @@ export default function SupportersPage() {
)}
</section>
{/* ── 3. Collaborators ── */}
{/* ── 3. 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>
)}
{/* ── 4. Collaborators ── */}
<section>
<SectionHeader
icon={Users}