feat(supporters): pay-for list + impact stats; scope zaps to the project
This commit is contained in:
parent
b88379a390
commit
87aca51248
4 changed files with 129 additions and 14 deletions
|
|
@ -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 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue