From a9dcb5719cd373c6b19acb207b5878f981d42957 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 19:54:49 +0700 Subject: [PATCH] feat(supporters): per-row dates + 'updated', dedicated /donate page, extract shared support cards --- src/AppRouter.tsx | 2 + src/components/AngorProjectCard.tsx | 39 ++++++++ src/components/SilentPaymentsCard.tsx | 66 ++++++++++++ src/components/WaysToSupport.tsx | 30 ++++++ src/hooks/useZapLeaderboard.ts | 4 +- src/i18n/en.ts | 4 + src/i18n/es.ts | 4 + src/lib/backers.ts | 4 + src/pages/DonatePage.tsx | 45 +++++++++ src/pages/SupportersPage.tsx | 139 ++++++-------------------- 10 files changed, 227 insertions(+), 110 deletions(-) create mode 100644 src/components/AngorProjectCard.tsx create mode 100644 src/components/SilentPaymentsCard.tsx create mode 100644 src/components/WaysToSupport.tsx create mode 100644 src/pages/DonatePage.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 541c760..64dc5a4 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -68,6 +68,7 @@ const NIP19Page = lazyWithRetry(() => import("./pages/NIP19Page").then(m => ({ d const LicensePage = lazyWithRetry(() => import("./pages/LicensePage")); const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage")); const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage")); +const DonatePage = lazyWithRetry(() => import("./pages/DonatePage")); const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage")); const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage")); const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage")); @@ -183,6 +184,7 @@ export function AppRouter() { } /> } /> } /> + } /> {/* Marketplace */} } /> {/* Newsletter */} diff --git a/src/components/AngorProjectCard.tsx b/src/components/AngorProjectCard.tsx new file mode 100644 index 0000000..1449f31 --- /dev/null +++ b/src/components/AngorProjectCard.tsx @@ -0,0 +1,39 @@ +import { ExternalLink } from 'lucide-react'; +import { useTranslation } from '@/contexts/language'; + +export const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z'; + +/** + * The "Fund the build on Angor" box β€” a reusable rail card (used on /supporters + * and /donate). `count` adds the "Join N supporters" line when known. + */ +export function AngorProjectCard({ count, className }: { count?: number; className?: string }) { + const { t } = useTranslation(); + return ( +
+
🧑⚑
+

{t('walletUi.supporters.backTitle')}

+

+ {t('walletUi.supporters.backDesc')} +

+
+ + + {t('walletUi.supporters.openAngor')} + + {t('walletUi.supporters.orZap')} +
+

{t('walletUi.supporters.donateNote')}

+ {typeof count === 'number' && count > 0 && ( +

+ {t('walletUi.supporters.joinSupporters', { count })} +

+ )} +
+ ); +} diff --git a/src/components/SilentPaymentsCard.tsx b/src/components/SilentPaymentsCard.tsx new file mode 100644 index 0000000..5dc7eb5 --- /dev/null +++ b/src/components/SilentPaymentsCard.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import QRCode from 'qrcode'; +import { Check, Copy } from 'lucide-react'; +import { useTranslation } from '@/contexts/language'; + +// BIP-353 payment name β†’ BIP-321 URI (silent payment `sp` + BOLT 12 `lno`). +// Resolved 2026-10-05 from skatespots.user._bitcoin-payment.silentpayments.net. +// Update if the name is re-pointed (or a Cashu `creq` is added). +const SILENT_NAME = 'skatespots@silentpayments.net'; +const SILENT_URI = 'bitcoin:?sp=sp1qq2xlxmvh5tpkjw460cfjf5ndz87fc45k0fm3glyalqxq4m436u8nyqa745dyh55pz4vchhjzam5tmlvvlp6whhkqmdk3e7xvfp4y80jhvq40lyl3&lno=lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878s8y79jwe96rpahf567zyt0lhe26sxpsace8n9u5l359n254e639mqqgpas2cxvhc6ftcr6wrh6nv36mz53qhsfvwrxvgf8mnvq4tsg0z2plqqxvv8geddvc60pys4ja0ajvhhhka2zzfdsnmkvygu872fa0awyqvv8rrpg6k0kg8zpxh8hy3hm9pr35hfa73s92mk38xfqfgfz98v0w34c4z8un6ljxlfjcu69m3de0nqarvh8k8kqqey5kgfmvyfhrg3wltewtj8xn35aevy96dws40hygx7xpfu2k0ty8rm4mv262df8f9q3z2strn4cmh8svss'; + +export function SilentPaymentsCard({ className }: { className?: string }) { + const { t } = useTranslation(); + const [qr, setQr] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + let active = true; + QRCode.toDataURL(SILENT_URI, { width: 200, margin: 1 }) + .then((url) => { if (active) setQr(url); }) + .catch(() => { if (active) setQr(null); }); + return () => { active = false; }; + }, []); + + const copy = async () => { + try { + await navigator.clipboard.writeText(SILENT_NAME); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* clipboard blocked */ } + }; + + return ( +
+
+ πŸ•΅οΈ +
+

+ {t('walletUi.supporters.silentTitle')} +

+

{t('walletUi.supporters.silentDesc')}

+
+
+ +
+ {qr && ( + {SILENT_NAME} + )} +
+

{SILENT_NAME}

+ +

+ {t('walletUi.supporters.silentWallets')} +

+
+
+
+ ); +} + diff --git a/src/components/WaysToSupport.tsx b/src/components/WaysToSupport.tsx new file mode 100644 index 0000000..d07e55e --- /dev/null +++ b/src/components/WaysToSupport.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from '@/contexts/language'; + +/** + * "Ways to support" β€” three purpose-first cards; each scrolls to its rail by id + * (#support-lightning / #support-silent / #support-angor), so it works anywhere + * those anchors exist (/supporters and /donate). + */ +export function WaysToSupport({ className }: { className?: string }) { + const { t } = useTranslation(); + const ways = [ + { id: 'support-lightning', emoji: '⚑', title: t('walletUi.supporters.wayLightningTitle'), desc: t('walletUi.supporters.wayLightningDesc') }, + { id: 'support-silent', emoji: 'πŸ•΅οΈ', title: t('walletUi.supporters.waySilentTitle'), desc: t('walletUi.supporters.waySilentDesc') }, + { id: 'support-angor', emoji: 'β›“', title: t('walletUi.supporters.wayAngorTitle'), desc: t('walletUi.supporters.wayAngorDesc') }, + ]; + return ( +
+ {ways.map((w) => ( + + ))} +
+ ); +} diff --git a/src/hooks/useZapLeaderboard.ts b/src/hooks/useZapLeaderboard.ts index 6e28cfa..0c1d894 100644 --- a/src/hooks/useZapLeaderboard.ts +++ b/src/hooks/useZapLeaderboard.ts @@ -16,6 +16,8 @@ export interface ZapLeaderboardSender { count: number; /** Most recent receipt id for this sender β€” used for a verifiable link. */ receiptId?: string; + /** Most recent receipt time (unix seconds). */ + createdAt?: number; } export interface ZapLeaderboard { @@ -94,7 +96,7 @@ export function useZapLeaderboard(since?: number, recipients?: string[]) { .map(([key, v]) => ({ key, ...v })) .sort((a, b) => b.sats - a.sats), senders: [...senders.entries()] - .map(([pubkey, v]) => ({ pubkey, sats: v.sats, count: v.count, receiptId: v.receiptId })) + .map(([pubkey, v]) => ({ pubkey, sats: v.sats, count: v.count, receiptId: v.receiptId, createdAt: v.at || undefined })) .sort((a, b) => b.sats - a.sats), }; }, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 00464a3..ed4a544 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6511,6 +6511,10 @@ walletUi: { waySilentDesc: 'A silent-payments name β€” a new address for every payment.', wayAngorTitle: 'Fund the build', wayAngorDesc: 'Angor β€” staged release, refundable above 100k sats.', + donateTitle: 'Support SkateSpots', + donateDesc: 'Three rails, three reasons β€” pick what fits you. Every sat keeps the map free, ad-free and open-source.', + donatePageLink: 'Open the simple donate page', + updatedAt: 'Updated {{when}}', tiersNote: 'Tiers: Supporter under 100k sats Β· Patron 100k–1M Β· Legend 1M+', tierEmpty: 'No one yet β€” be the first.', joinSupporters: 'Join {{count}} supporters', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index eb5c3e0..61bedc3 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6511,6 +6511,10 @@ walletUi: { waySilentDesc: 'Un nombre de silent payments β€” una direcciΓ³n nueva en cada pago.', wayAngorTitle: 'Financia el proyecto', wayAngorDesc: 'Angor β€” liberaciΓ³n por etapas, reembolsable por encima de 100k sats.', + donateTitle: 'Apoya a SkateSpots', + donateDesc: 'Tres vΓ­as, tres razones β€” elige la que te encaje. Cada sat mantiene el mapa libre, sin anuncios y open-source.', + donatePageLink: 'Abrir la pΓ‘gina de donaciΓ³n', + updatedAt: 'Actualizado {{when}}', tiersNote: 'Niveles: Colaborador menos de 100k sats Β· PatrΓ³n 100k–1M Β· Leyenda 1M+', tierEmpty: 'Nadie todavΓ­a β€” sΓ© el primero.', joinSupporters: 'Únete a {{count}} patrocinadores', diff --git a/src/lib/backers.ts b/src/lib/backers.ts index b07c47a..fa0cd98 100644 --- a/src/lib/backers.ts +++ b/src/lib/backers.ts @@ -29,6 +29,8 @@ export interface Backer { txid?: string; /** Amount contributed, in satoshis. */ amount?: number; + /** Contribution time (unix seconds). */ + at?: number; /** List without revealing name/logo/npub (privacy opt-out). */ anonymous?: boolean; /** Small line under the name, e.g. "Founding backer". */ @@ -40,12 +42,14 @@ export const BACKERS: Backer[] = [ npub: 'npub15q3ekfxdvzuc9nhhyh2yv7lwtnfagurllc9rkkrypt40d303jsjsdfujeu', txid: '4f95d18ff44a86e8302d923f53fe3777c52236ec087b0d374b8e9566995e1bcc', amount: 99000, + at: 1780930859, note: 'Founding supporter', }, { npub: 'npub1t5yjluyvxatlmmcxccv7tdppmp4ae0j850k63323t7j0fe4lsqlsp7mfyl', txid: '5dbf872fd8f02ecf2b534857b0613e880a021478c6e8e6483c774e95d2ddc565', amount: 99000, + at: 1780644182, note: 'Founding supporter', }, ]; diff --git a/src/pages/DonatePage.tsx b/src/pages/DonatePage.tsx new file mode 100644 index 0000000..0f329d7 --- /dev/null +++ b/src/pages/DonatePage.tsx @@ -0,0 +1,45 @@ +import { useSeoMeta } from '@unhead/react'; +import { SupportSkateSpots } from '@/components/SupportSkateSpots'; +import { SilentPaymentsCard } from '@/components/SilentPaymentsCard'; +import { AngorProjectCard } from '@/components/AngorProjectCard'; +import { WaysToSupport } from '@/components/WaysToSupport'; +import { useTranslation } from '@/contexts/language'; + +/** + * Focused, shareable donation page: the three rails and nothing else. + * Reuses the same cards as /supporters (via the shared components). + */ +export default function DonatePage() { + const { t } = useTranslation(); + useSeoMeta({ + title: 'Donate β€” SkateSpots', + description: 'Support SkateSpots β€” Lightning, private on-chain (silent payments), or fund the build on Angor.', + ogTitle: 'Support SkateSpots', + ogDescription: 'Keep the map free, ad-free and open-source.', + ogImage: 'https://skate-spots.org/images/icon-1024.png', + }); + + return ( +
+
+
⚑
+

+ {t('walletUi.supporters.donateTitle')} +

+

{t('walletUi.supporters.donateDesc')}

+
+ + + +
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/pages/SupportersPage.tsx b/src/pages/SupportersPage.tsx index 2f691d1..91cf6ae 100644 --- a/src/pages/SupportersPage.tsx +++ b/src/pages/SupportersPage.tsx @@ -1,12 +1,15 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; -import QRCode from 'qrcode'; -import { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star, ShieldCheck, Server, Globe, Bot, Code2, Briefcase, Megaphone, TrendingUp, Check, Copy } 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 { SilentPaymentsCard } from '@/components/SilentPaymentsCard'; +import { AngorProjectCard, ANGOR_PROJECT_URL } from '@/components/AngorProjectCard'; +import { WaysToSupport } from '@/components/WaysToSupport'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { BACKERS, BACKERS_TOTAL_SATS, tierOf } from '@/lib/backers'; +import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { useAuthor } from '@/hooks/useAuthor'; import { useGrowthStats } from '@/hooks/useGrowthStats'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; @@ -14,14 +17,6 @@ import { useSatsToUsd } from '@/hooks/useExchangeRate'; import { useZapLeaderboard } from '@/hooks/useZapLeaderboard'; import { useTranslation } from '@/contexts/language'; -const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z'; - -// Silent-payments "payment name" (BIP-353) β†’ BIP-321 URI with a silent payment -// address (sp) + a BOLT 12 Lightning offer (lno). Resolved from -// skatespots.user._bitcoin-payment.silentpayments.net (2026-10-05). If the name -// is re-pointed, update SILENT_URI from the DNS TXT record. -const SILENT_NAME = 'skatespots@silentpayments.net'; -const SILENT_URI = 'bitcoin:?sp=sp1qq2xlxmvh5tpkjw460cfjf5ndz87fc45k0fm3glyalqxq4m436u8nyqa745dyh55pz4vchhjzam5tmlvvlp6whhkqmdk3e7xvfp4y80jhvq40lyl3&lno=lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878s8y79jwe96rpahf567zyt0lhe26sxpsace8n9u5l359n254e639mqqgpas2cxvhc6ftcr6wrh6nv36mz53qhsfvwrxvgf8mnvq4tsg0z2plqqxvv8geddvc60pys4ja0ajvhhhka2zzfdsnmkvygu872fa0awyqvv8rrpg6k0kg8zpxh8hy3hm9pr35hfa73s92mk38xfqfgfz98v0w34c4z8un6ljxlfjcu69m3de0nqarvh8k8kqqey5kgfmvyfhrg3wltewtj8xn35aevy96dws40hygx7xpfu2k0ty8rm4mv262df8f9q3z2strn4cmh8svss'; // Only count support received from this point (the project's public launch era). // Zaps to the platform before this are ignored. NOTE: as of 2026-10-05 every @@ -127,60 +122,6 @@ const PAYS_FOR = [ // ─── Silent-payments donation card (BIP-353 name β†’ BIP-321 URI) ────────────── -function SilentPaymentsCard({ className }: { className?: string }) { - const { t } = useTranslation(); - const [qr, setQr] = useState(null); - const [copied, setCopied] = useState(false); - - useEffect(() => { - let active = true; - QRCode.toDataURL(SILENT_URI, { width: 200, margin: 1 }) - .then((url) => { if (active) setQr(url); }) - .catch(() => { if (active) setQr(null); }); - return () => { active = false; }; - }, []); - - const copy = async () => { - try { - await navigator.clipboard.writeText(SILENT_NAME); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { /* clipboard blocked */ } - }; - - return ( -
-
- πŸ•΅οΈ -
-

- {t('walletUi.supporters.silentTitle')} -

-

{t('walletUi.supporters.silentDesc')}

-
-
- -
- {qr && ( - {SILENT_NAME} - )} -
-

{SILENT_NAME}

- -

- {t('walletUi.supporters.silentWallets')} -

-
-
-
- ); -} // ─── Section header ─────────────────────────────────────────────────────────── @@ -322,6 +263,7 @@ interface SupporterEntry { receiptId?: string; note?: string; anonymous?: boolean; + at?: number; } const TIER_META = { @@ -406,6 +348,7 @@ function SupporterRow({ rank, entry }: { rank: number; entry: SupporterEntry }) || (entry.npub ? `${entry.npub.slice(0, 12)}…${entry.npub.slice(-6)}` : t('walletUi.supporters.anonymousBacker')); const avatar = entry.anonymous ? undefined : (entry.logo || meta?.picture); const tier = TIER_META[tierOf(entry.sats)]; + const when = entry.at ? formatDistanceToNow(new Date(entry.at * 1000), { addSuffix: true }) : undefined; const profileHref = !entry.anonymous && entry.npub ? `/${entry.npub}` : undefined; const linkHref = profileHref || entry.url; const proofUrl = entry.txid @@ -456,6 +399,7 @@ function SupporterRow({ rank, entry }: { rank: number; entry: SupporterEntry }) {tier.emoji} {t(tier.key)} {entry.note && !entry.anonymous && {entry.note}} + {when && Β· {when}}
@@ -524,6 +468,7 @@ export default function SupportersPage() { txid: b.txid, note: b.note, anonymous: b.anonymous, + at: b.at, }); }); for (const z of zappers) { @@ -533,6 +478,7 @@ export default function SupportersPage() { existing.sats += z.sats; existing.zap = true; existing.receiptId = z.receiptId; + if (z.createdAt && (!existing.at || z.createdAt > existing.at)) existing.at = z.createdAt; } else { map.set(npub, { key: npub, @@ -543,12 +489,15 @@ export default function SupportersPage() { angor: false, zap: true, receiptId: z.receiptId, + at: z.createdAt, }); } } return [...map.values()].sort((a, b) => b.sats - a.sats); }, [zappers]); + const latestAt = supporters.reduce((m, s) => Math.max(m, s.at ?? 0), 0); + // `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 @@ -601,6 +550,12 @@ export default function SupportersPage() { {t('walletUi.supporters.backAngor')} + + {t('walletUi.supporters.donatePageLink')} β†’ +
{/* ── 1. Support ── */} @@ -612,24 +567,7 @@ export default function SupportersPage() { color="#f7931a" /> - {/* Ways to support β€” purpose-first trio (each scrolls to its rail). */} -
- {[ - { id: 'support-lightning', emoji: '⚑', title: t('walletUi.supporters.wayLightningTitle'), desc: t('walletUi.supporters.wayLightningDesc') }, - { id: 'support-silent', emoji: 'πŸ•΅οΈ', title: t('walletUi.supporters.waySilentTitle'), desc: t('walletUi.supporters.waySilentDesc') }, - { id: 'support-angor', emoji: 'β›“', title: t('walletUi.supporters.wayAngorTitle'), desc: t('walletUi.supporters.wayAngorDesc') }, - ].map((w) => ( - - ))} -
+ {/* Lightning rail β€” the in-app way to chip in (zap publicly or pay quietly). */}
@@ -640,32 +578,8 @@ export default function SupportersPage() {
-
-
🧑⚑
-

{t('walletUi.supporters.backTitle')}

-

- {t('walletUi.supporters.backDesc')} -

-
- - - {t('walletUi.supporters.openAngor')} - - - {t('walletUi.supporters.orZap')} - -
-

- {t('walletUi.supporters.donateNote')} -

-

- {t('walletUi.supporters.joinSupporters', { count: supporters.length })} -

+
+
@@ -762,6 +676,13 @@ export default function SupportersPage() {

{t('walletUi.supporters.quietNote')}

+ {latestAt > 0 && ( +

+ {t('walletUi.supporters.updatedAt', { + when: formatDistanceToNow(new Date(latestAt * 1000), { addSuffix: true }), + })} +

+ )} ) : (