feat(supporters): pay-for list + impact stats; scope zaps to the project

This commit is contained in:
SkateSpots 2026-10-05 18:46:07 +07:00
parent b88379a390
commit 87aca51248
4 changed files with 129 additions and 14 deletions

View file

@ -30,17 +30,24 @@ export interface ZapLeaderboard {
* - **senders** — total sats sent per zapper (taken from the embedded zap * - **senders** — total sats sent per zapper (taken from the embedded zap
* request, since a receipt's own pubkey is the LNURL server, not the sender). * request, since a receipt's own pubkey is the LNURL server, not the sender).
*/ */
export function useZapLeaderboard(since?: number) { export function useZapLeaderboard(since?: number, recipients?: string[]) {
const { nostr } = useNostr(); const { nostr } = useNostr();
return useQuery<ZapLeaderboard>({ return useQuery<ZapLeaderboard>({
queryKey: ['zap-leaderboard', since ?? 'all'], queryKey: ['zap-leaderboard', since ?? 'all', recipients?.join(',') ?? ''],
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
queryFn: async (c): Promise<ZapLeaderboard> => { queryFn: async (c): Promise<ZapLeaderboard> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]); const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
const receipts: NostrEvent[] = await nostr.query( const receipts: NostrEvent[] = await nostr.query(
[{ kinds: [9735], ...(since ? { since } : {}), limit: 1000 }], [
{
kinds: [9735],
...(since ? { since } : {}),
...(recipients?.length ? { '#p': recipients } : {}),
limit: 1000,
},
],
{ signal }, { signal },
); );

View file

@ -6505,8 +6505,24 @@ walletUi: {
tierSupporter: 'Supporter', tierSupporter: 'Supporter',
tierPatron: 'Patron', tierPatron: 'Patron',
tierLegend: 'Legend', tierLegend: 'Legend',
topZappers: 'Top zappers', topZappers: 'Zapped SkateSpots',
topZappersDesc: 'Skaters who zapped the map — Lightning support, all-time.', topZappersDesc: 'Skaters who sent a public zap straight to the project. Quiet payments aren’t listed.',
paysFor: 'What your support pays for',
paysForDesc: 'Where every sat goes — no salaries, no offices, no ads.',
payRelay: 'Relay hosting',
payDomain: 'Domain & TLS',
payAi: 'AI / ContextVM node',
payDev: 'Development time',
payOps: 'Operations',
payMarketing: 'Marketing',
impact: 'Impact so far',
impactDesc: 'What the community has built — funded by skaters.',
impactSpots: 'Spots',
impactCrews: 'Crews',
impactEvents: 'Events',
impactLab: 'Lab pros',
impactShops: 'Shops',
impactMappers: 'Mappers',
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',

View file

@ -6505,8 +6505,24 @@ walletUi: {
tierSupporter: 'Colaborador', tierSupporter: 'Colaborador',
tierPatron: 'Patrón', tierPatron: 'Patrón',
tierLegend: 'Leyenda', tierLegend: 'Leyenda',
topZappers: 'Principales zapeadores', topZappers: 'Zapearon SkateSpots',
topZappersDesc: 'Patinadores que han zapeado el mapa — apoyo por Lightning, histórico.', topZappersDesc: 'Patinadores que enviaron un zap público directamente al proyecto. Los pagos silenciosos no se listan.',
paysFor: 'En qué se invierte tu apoyo',
paysForDesc: 'A dónde va cada sat — sin sueldos, sin oficinas, sin anuncios.',
payRelay: 'Alojamiento de relés',
payDomain: 'Dominio y TLS',
payAi: 'Nodo de IA / ContextVM',
payDev: 'Tiempo de desarrollo',
payOps: 'Operaciones',
payMarketing: 'Marketing',
impact: 'Impacto hasta ahora',
impactDesc: 'Lo que ha construido la comunidad — financiado por patinadores.',
impactSpots: 'Spots',
impactCrews: 'Crews',
impactEvents: 'Eventos',
impactLab: 'Pros del Lab',
impactShops: 'Tiendas',
impactMappers: 'Mapeadores',
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',

View file

@ -1,12 +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 { 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, 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, type Backer } from '@/lib/backers';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useGrowthStats } from '@/hooks/useGrowthStats';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { useSatsToUsd } from '@/hooks/useExchangeRate'; import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { useZapLeaderboard } from '@/hooks/useZapLeaderboard'; import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -86,6 +88,17 @@ const FRIENDS: Friend[] = [
// Add more friends here // Add more friends here
]; ];
// ─── "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' },
] as const;
// ─── Section header ─────────────────────────────────────────────────────────── // ─── Section header ───────────────────────────────────────────────────────────
function SectionHeader({ function SectionHeader({
@ -325,8 +338,30 @@ 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 { data: zaps } = useZapLeaderboard(undefined, PLATFORM_OWNER_PUBKEYS);
const topZappers = (zaps?.senders ?? []).slice(0, 10); const topZappers = (zaps?.senders ?? []).slice(0, 10);
const { data: stats } = useGrowthStats();
// `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
? num(stats.totalCoaches) + num(stats.totalFilmers) + num(stats.totalEditors) + num(stats.totalPhotographers)
+ num(stats.totalBuilders) + num(stats.totalArtists) + num(stats.totalGraffiti) + num(stats.totalTattoo)
+ num(stats.totalTrainers) + num(stats.totalMusicians) + num(stats.totalJudges) + num(stats.totalOrganizers)
+ num(stats.totalBarbers)
: 0;
const impact = stats
? [
{ key: 'walletUi.supporters.impactSpots', value: num(stats.totalSpots) },
{ key: 'walletUi.supporters.impactCrews', value: num(stats.totalCrews) },
{ key: 'walletUi.supporters.impactEvents', value: num(stats.totalEvents) },
{ key: 'walletUi.supporters.impactLab', value: labTotal },
{ key: 'walletUi.supporters.impactShops', value: num(stats.totalShops) },
{ key: 'walletUi.supporters.impactMappers', value: num(stats.totalMappers) },
].filter((s) => s.value > 0)
: [];
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.',
@ -396,7 +431,48 @@ export default function SupportersPage() {
<SupportSkateSpots className="mt-4" /> <SupportSkateSpots className="mt-4" />
</section> </section>
{/* ── 2. Backers (Angor) ── */} {/* ── 2. What your support pays for (words only) ── */}
<section>
<SectionHeader
icon={Heart}
title={t('walletUi.supporters.paysFor')}
subtitle={t('walletUi.supporters.paysForDesc')}
color="#22d3ee"
/>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{PAYS_FOR.map(({ icon: Icon, key }) => (
<div
key={key}
className="flex items-center 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>
</div>
))}
</div>
</section>
{/* ── 3. Impact so far (what the community built) ── */}
{impact.length > 0 && (
<section>
<SectionHeader
icon={TrendingUp}
title={t('walletUi.supporters.impact')}
subtitle={t('walletUi.supporters.impactDesc')}
color="#ccff00"
/>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{impact.map(({ key, value }) => (
<div key={key} className="rounded-xl border border-white/10 bg-black/60 px-4 py-3 text-center">
<div className="text-2xl font-black text-[#ccff00]">{value.toLocaleString()}</div>
<div className="text-xs text-gray-400">{t(key)}</div>
</div>
))}
</div>
</section>
)}
{/* ── 4. Supporters (Angor) ── */}
<section> <section>
<SectionHeader <SectionHeader
icon={Zap} icon={Zap}
@ -439,7 +515,7 @@ export default function SupportersPage() {
)} )}
</section> </section>
{/* ── 3. Top zappers (Lightning) ── */} {/* ── 5. Top zappers (Lightning) ── */}
{topZappers.length > 0 && ( {topZappers.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
@ -456,7 +532,7 @@ export default function SupportersPage() {
</section> </section>
)} )}
{/* ── 4. Collaborators ── */} {/* ── 6. Collaborators ── */}
<section> <section>
<SectionHeader <SectionHeader
icon={Users} icon={Users}
@ -489,7 +565,7 @@ export default function SupportersPage() {
)} )}
</section> </section>
{/* ── 3. Partners ── */} {/* ── 7. Partners ── */}
<section> <section>
<SectionHeader <SectionHeader
icon={Handshake} icon={Handshake}
@ -518,7 +594,7 @@ export default function SupportersPage() {
)} )}
</section> </section>
{/* ── 4. Friends ── */} {/* ── 8. Friends ── */}
<section> <section>
<SectionHeader <SectionHeader
icon={Star} icon={Star}