feat(supporters): add tiers/badges and a unified top-zappers block
This commit is contained in:
parent
bf96a9e3a1
commit
b88379a390
4 changed files with 86 additions and 2 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue