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',
|
backersTotal: '{{count}} supporters · {{amount}} sats raised',
|
||||||
verifiedOnChain: 'On-chain verified',
|
verifiedOnChain: 'On-chain verified',
|
||||||
supportedBy: 'Supported by {{count}}',
|
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',
|
support: 'Support',
|
||||||
supportDesc: 'Fund SkateSpots on Angor — decentralized, on-chain Bitcoin crowdfunding. No middleman, no KYC.',
|
supportDesc: 'Fund SkateSpots on Angor — decentralized, on-chain Bitcoin crowdfunding. No middleman, no KYC.',
|
||||||
collaborators: 'Collaborators',
|
collaborators: 'Collaborators',
|
||||||
|
|
|
||||||
|
|
@ -6502,6 +6502,11 @@ walletUi: {
|
||||||
backersTotal: '{{count}} patrocinadores · {{amount}} sats recaudados',
|
backersTotal: '{{count}} patrocinadores · {{amount}} sats recaudados',
|
||||||
verifiedOnChain: 'Verificado on-chain',
|
verifiedOnChain: 'Verificado on-chain',
|
||||||
supportedBy: 'Apoyado por {{count}}',
|
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',
|
support: 'Apoyo',
|
||||||
supportDesc: 'Financia SkateSpots en Angor — crowdfunding descentralizado on-chain en Bitcoin. Sin intermediarios, sin KYC.',
|
supportDesc: 'Financia SkateSpots en Angor — crowdfunding descentralizado on-chain en Bitcoin. Sin intermediarios, sin KYC.',
|
||||||
collaborators: 'Colaboradores',
|
collaborators: 'Colaboradores',
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,16 @@ export const BACKERS: Backer[] = [
|
||||||
|
|
||||||
/** Total raised across the listed supporters, in satoshis. */
|
/** Total raised across the listed supporters, in satoshis. */
|
||||||
export const BACKERS_TOTAL_SATS = BACKERS.reduce((sum, b) => sum + (b.amount ?? 0), 0);
|
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 { useSeoMeta } from '@unhead/react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
import { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star, ShieldCheck } from 'lucide-react';
|
import { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star, ShieldCheck } from 'lucide-react';
|
||||||
import { SupportSkateSpots } from '@/components/SupportSkateSpots';
|
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 { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
||||||
|
import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
|
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
|
||||||
|
|
@ -207,6 +211,12 @@ function FriendCard({ friend }: { friend: Friend }) {
|
||||||
|
|
||||||
// ─── Backer card (Angor backers) ─────────────────────────────────────────────
|
// ─── 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 }) {
|
function BackerCard({ backer }: { backer: Backer }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data } = useAuthor(backer.npub);
|
const { data } = useAuthor(backer.npub);
|
||||||
|
|
@ -216,6 +226,7 @@ function BackerCard({ backer }: { backer: Backer }) {
|
||||||
const display = name
|
const display = name
|
||||||
|| (backer.npub ? `${backer.npub.slice(0, 12)}…${backer.npub.slice(-6)}` : t('walletUi.supporters.anonymousBacker'));
|
|| (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 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 href = backer.npub ? `/${backer.npub}` : backer.url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -247,6 +258,12 @@ function BackerCard({ backer }: { backer: Backer }) {
|
||||||
{typeof backer.amount === 'number' && (
|
{typeof backer.amount === 'number' && (
|
||||||
<p className="text-xs font-mono mt-0.5 text-[#22d3ee]/80">{backer.amount.toLocaleString()} sats</p>
|
<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.note && <p className="text-xs text-gray-500 mt-0.5">{backer.note}</p>}
|
||||||
{backer.txid && (
|
{backer.txid && (
|
||||||
<a
|
<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) ───────────────────────────────────────────
|
// ─── Placeholder card (empty slot) ───────────────────────────────────────────
|
||||||
|
|
||||||
function PlaceholderCard({ label }: { label: string }) {
|
function PlaceholderCard({ label }: { label: string }) {
|
||||||
|
|
@ -283,6 +325,8 @@ 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 totalUsd = useSatsToUsd(BACKERS_TOTAL_SATS);
|
||||||
|
const { data: zaps } = useZapLeaderboard();
|
||||||
|
const topZappers = (zaps?.senders ?? []).slice(0, 10);
|
||||||
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.',
|
||||||
|
|
@ -395,7 +439,24 @@ export default function SupportersPage() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={Users}
|
icon={Users}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue