feat(supporters): per-row dates + 'updated', dedicated /donate page, extract shared support cards

This commit is contained in:
SkateSpots 2026-10-05 19:54:49 +07:00
parent 8c20395ccb
commit a9dcb5719c
10 changed files with 227 additions and 110 deletions

View file

@ -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() {
<Route path="/license" element={<LicensePage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/supporters" element={<SupportersPage />} />
<Route path="/donate" element={<DonatePage />} />
{/* Marketplace */}
<Route path="/marketplace-listings" element={<MarketplacePage />} />
{/* Newsletter */}

View file

@ -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 (
<div className={`rounded-2xl border-2 border-[#f7931a]/30 bg-black/60 p-8 text-center ${className ?? ''}`}>
<div className="mb-4 text-5xl">🧡⚡</div>
<h3 className="mb-2 text-xl font-black uppercase text-white">{t('walletUi.supporters.backTitle')}</h3>
<p className="mx-auto mb-6 max-w-lg text-sm leading-relaxed text-gray-400">
{t('walletUi.supporters.backDesc')}
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<a
href={ANGOR_PROJECT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full border-2 border-[#f7931a]/40 px-5 py-2.5 text-sm font-bold text-[#f7931a] transition-all hover:bg-[#f7931a]/10"
>
<ExternalLink className="h-4 w-4" />
{t('walletUi.supporters.openAngor')}
</a>
<span className="text-xs text-gray-500">{t('walletUi.supporters.orZap')}</span>
</div>
<p className="mt-4 text-xs text-gray-500">{t('walletUi.supporters.donateNote')}</p>
{typeof count === 'number' && count > 0 && (
<p className="mt-2 text-center text-xs font-bold text-[#22d3ee]">
{t('walletUi.supporters.joinSupporters', { count })}
</p>
)}
</div>
);
}

View file

@ -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<string | null>(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 (
<div className={`rounded-2xl border-2 border-white/15 bg-black/60 p-6 ${className ?? ''}`}>
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/5 text-lg">🕵️</span>
<div>
<h3 className="text-lg font-black uppercase leading-tight text-white">
{t('walletUi.supporters.silentTitle')}
</h3>
<p className="text-xs text-gray-400">{t('walletUi.supporters.silentDesc')}</p>
</div>
</div>
<div className="mt-4 flex flex-col items-center gap-4 sm:flex-row sm:items-start">
{qr && (
<img src={qr} alt={SILENT_NAME} className="h-[200px] w-[200px] shrink-0 rounded-xl bg-white p-2" />
)}
<div className="min-w-0 text-center sm:text-left">
<p className="break-all font-mono text-sm text-white">{SILENT_NAME}</p>
<button
onClick={copy}
className="mt-2 inline-flex min-h-0 min-w-0 items-center gap-1.5 rounded-full border border-white/20 px-3 py-1.5 text-xs font-bold text-gray-200 transition-colors hover:bg-white/5"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? t('walletUi.supporters.silentCopied') : t('walletUi.supporters.silentCopyName')}
</button>
<p className="mt-3 text-[11px] leading-relaxed text-gray-500">
{t('walletUi.supporters.silentWallets')}
</p>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className={`grid grid-cols-1 gap-3 sm:grid-cols-3 ${className ?? ''}`}>
{ways.map((w) => (
<button
key={w.id}
onClick={() => document.getElementById(w.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
className="min-h-0 min-w-0 rounded-xl border border-white/10 bg-black/60 p-4 text-left transition-colors hover:border-[#ccff00]/40 hover:bg-white/5"
>
<span className="text-2xl">{w.emoji}</span>
<p className="mt-2 text-sm font-black uppercase text-white">{w.title}</p>
<p className="mt-1 text-xs text-gray-400">{w.desc}</p>
</button>
))}
</div>
);
}

View file

@ -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),
};
},

View file

@ -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',

View file

@ -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',

View file

@ -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',
},
];

45
src/pages/DonatePage.tsx Normal file
View file

@ -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 (
<div className="container max-w-3xl space-y-6 px-4 py-12 md:py-16">
<header className="text-center">
<div className="mb-4 text-6xl">⚡</div>
<h1 className="mb-3 text-4xl font-black uppercase leading-none md:text-5xl">
<span className="neon-lime">{t('walletUi.supporters.donateTitle')}</span>
</h1>
<p className="mx-auto max-w-lg text-gray-400">{t('walletUi.supporters.donateDesc')}</p>
</header>
<WaysToSupport />
<div id="support-lightning" className="scroll-mt-24">
<SupportSkateSpots />
</div>
<div id="support-silent" className="scroll-mt-24">
<SilentPaymentsCard />
</div>
<div id="support-angor" className="scroll-mt-24">
<AngorProjectCard />
</div>
</div>
);
}

View file

@ -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<string | null>(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 (
<div className={`rounded-2xl border-2 border-white/15 bg-black/60 p-6 ${className ?? ''}`}>
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/5 text-lg">🕵️</span>
<div>
<h3 className="text-lg font-black uppercase leading-tight text-white">
{t('walletUi.supporters.silentTitle')}
</h3>
<p className="text-xs text-gray-400">{t('walletUi.supporters.silentDesc')}</p>
</div>
</div>
<div className="mt-4 flex flex-col items-center gap-4 sm:flex-row sm:items-start">
{qr && (
<img src={qr} alt={SILENT_NAME} className="h-[200px] w-[200px] shrink-0 rounded-xl bg-white p-2" />
)}
<div className="min-w-0 text-center sm:text-left">
<p className="break-all font-mono text-sm text-white">{SILENT_NAME}</p>
<button
onClick={copy}
className="mt-2 inline-flex min-h-0 min-w-0 items-center gap-1.5 rounded-full border border-white/20 px-3 py-1.5 text-xs font-bold text-gray-200 transition-colors hover:bg-white/5"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? t('walletUi.supporters.silentCopied') : t('walletUi.supporters.silentCopyName')}
</button>
<p className="mt-3 text-[11px] leading-relaxed text-gray-500">
{t('walletUi.supporters.silentWallets')}
</p>
</div>
</div>
</div>
);
}
// ─── 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)}
</span>
{entry.note && !entry.anonymous && <span className="text-[11px] text-gray-500">{entry.note}</span>}
{when && <span className="text-[11px] text-gray-600">· {when}</span>}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
@ -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')}
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
</a>
<Link
to="/donate"
className="mt-5 inline-flex items-center gap-1.5 text-xs font-bold text-[#ccff00]/80 transition-colors hover:text-[#ccff00]"
>
{t('walletUi.supporters.donatePageLink')} →
</Link>
</div>
{/* ── 1. Support ── */}
@ -612,24 +567,7 @@ export default function SupportersPage() {
color="#f7931a"
/>
{/* Ways to support — purpose-first trio (each scrolls to its rail). */}
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
{[
{ 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) => (
<button
key={w.id}
onClick={() => document.getElementById(w.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
className="min-h-0 min-w-0 rounded-xl border border-white/10 bg-black/60 p-4 text-left transition-colors hover:border-[#ccff00]/40 hover:bg-white/5"
>
<span className="text-2xl">{w.emoji}</span>
<p className="mt-2 text-sm font-black uppercase text-white">{w.title}</p>
<p className="mt-1 text-xs text-gray-400">{w.desc}</p>
</button>
))}
</div>
<WaysToSupport className="mb-4" />
{/* Lightning rail — the in-app way to chip in (zap publicly or pay quietly). */}
<div id="support-lightning" className="scroll-mt-24">
@ -640,32 +578,8 @@ export default function SupportersPage() {
<div id="support-silent" className="scroll-mt-24">
<SilentPaymentsCard className="mt-4" />
</div>
<div id="support-angor" className="scroll-mt-24 rounded-2xl border-2 border-[#f7931a]/30 bg-black/60 p-8 text-center">
<div className="text-5xl mb-4">🧡⚡</div>
<h3 className="text-xl font-black uppercase mb-2 text-white">{t('walletUi.supporters.backTitle')}</h3>
<p className="text-sm text-gray-400 max-w-lg mx-auto mb-6 leading-relaxed">
{t('walletUi.supporters.backDesc')}
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<a
href={ANGOR_PROJECT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border-2 border-[#f7931a]/40 text-[#f7931a] font-bold text-sm hover:bg-[#f7931a]/10 transition-all"
>
<ExternalLink className="h-4 w-4" />
{t('walletUi.supporters.openAngor')}
</a>
<span className="text-xs text-gray-500">
{t('walletUi.supporters.orZap')}
</span>
</div>
<p className="mt-4 text-xs text-gray-500">
{t('walletUi.supporters.donateNote')}
</p>
<p className="mt-2 text-center text-xs font-bold text-[#22d3ee]">
{t('walletUi.supporters.joinSupporters', { count: supporters.length })}
</p>
<div id="support-angor" className="scroll-mt-24">
<AngorProjectCard count={supporters.length} className="mt-4" />
</div>
</section>
@ -762,6 +676,13 @@ export default function SupportersPage() {
<p className="mt-2 text-center text-xs text-gray-600">
{t('walletUi.supporters.quietNote')}
</p>
{latestAt > 0 && (
<p className="mt-1 text-center text-[11px] text-gray-600">
{t('walletUi.supporters.updatedAt', {
when: formatDistanceToNow(new Date(latestAt * 1000), { addSuffix: true }),
})}
</p>
)}
<TierLadder supporters={supporters} />
</>
) : (