SkateSpots/src/pages/SupportersPage.tsx
shakespeare.diy 4ffa39ee95 Various UI cleanups: remove Nostr footer link, hide Map&Earn, update NostrHub links
- Index.tsx: remove Nostr.com button from homepage footer (next to FAQ/About)
- SupportersPage: change 'View on GitHub' → 'View on NostrHub' with correct naddr URL
- Layout: remove 'Map & Earn' (/bounties) from desktop More dropdown menu
- Layout: remove 'Map & Earn' (/bounties) from mobile slide-out nav
- Layout: clean up unused Gift icon import and stale isActive check

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-27 21:27:10 +07:00

505 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useSeoMeta } from '@unhead/react';
import { useQuery } from '@tanstack/react-query';
import { Layout } from '@/components/Layout';
import { Link } from 'react-router-dom';
import { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star } from 'lucide-react';
const GEYSER_PROJECT_URL = 'https://geyser.fund/project/skatespots?hero=satsnfacts';
const GEYSER_LEADERBOARD_URL = 'https://geyser.fund/project/skatespots/leaderboard';
const GEYSER_API_URL = 'https://api.geyser.fund/graphql';
// ─── Static data — edit these arrays to add/remove entries ──────────────────
interface Collaborator {
name: string;
role: string;
avatar?: string; // URL or leave blank for placeholder
npub?: string; // Nostr npub — links to their profile
url?: string; // fallback link
}
interface Partner {
name: string;
logo?: string; // URL to logo (SVG/PNG transparent preferred)
url: string;
description: string;
}
interface Friend {
name: string;
logo?: string; // URL to logo (SVG/PNG transparent preferred, min 200×200px)
url: string;
tagline?: string;
}
const COLLABORATORS: Collaborator[] = [
// Add collaborators here — mix of Nostr users and external people
// Example:
// { name: 'Alice', role: 'Developer', npub: 'npub1...', avatar: 'https://...' },
// { name: 'Bob', role: 'Relay Operator', url: 'https://...' },
];
const PARTNERS: Partner[] = [
// Add partners here
// Format: { name: 'Example', logo: 'https://...', url: 'https://...', description: 'Short description' }
// Logo: PNG/WebP transparent background preferred
];
const FRIENDS: Friend[] = [
{
name: 'Geyser',
logo: 'https://geyser.fund/logo-brand.svg',
url: 'https://geyser.fund/project/skatespots',
description: 'Centralized Bitcoin Lightning crowdfunding',
},
// Add more friends here
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
interface GeyserContributor {
user: {
id: string;
username: string;
imageUrl?: string;
heroLink?: string;
};
amount: number;
}
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M`;
if (sats >= 1_000) return `${Math.round(sats / 1_000).toLocaleString()}k`;
return sats.toLocaleString();
}
function useGeyserLeaderboard() {
return useQuery<GeyserContributor[]>({
queryKey: ['geyser-leaderboard'],
queryFn: async () => {
try {
const res = await fetch(GEYSER_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `{
projectGet(where: { name: "skatespots" }) {
funders(input: { orderBy: { amountFunded: desc }, pagination: { take: 100 } }) {
amountFunded
user { id username imageUrl heroLink }
}
}
}`,
}),
});
const json = await res.json();
const funders = json?.data?.projectGet?.funders ?? [];
return funders.map((f: { amountFunded: number; user: GeyserContributor['user'] }) => ({
user: f.user,
amount: f.amountFunded,
}));
} catch {
return [];
}
},
staleTime: 5 * 60 * 1000,
retry: false,
});
}
// ─── Section header ───────────────────────────────────────────────────────────
function SectionHeader({
icon: Icon,
title,
subtitle,
color = '#ccff00',
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
subtitle?: string;
color?: string;
}) {
return (
<div className="mb-6 md:mb-8">
<div className="flex items-center gap-3 mb-2">
<Icon className="h-5 w-5 flex-shrink-0" style={{ color }} />
<h2 className="text-2xl md:text-3xl font-black uppercase tracking-wide" style={{ color }}>
{title}
</h2>
<div className="flex-1 h-px" style={{ background: `linear-gradient(to right, ${color}40, transparent)` }} />
</div>
{subtitle && <p className="text-gray-400 text-sm ml-8">{subtitle}</p>}
</div>
);
}
// ─── Supporter card (Geyser contributors) ────────────────────────────────────
function SupporterCard({ contributor }: { contributor: GeyserContributor }) {
const isAnon = !contributor.user.username || contributor.user.username.toLowerCase().startsWith('anon');
const profileUrl = contributor.user.heroLink || `https://geyser.fund/user/${contributor.user.id}`;
const card = (
<div className="flex flex-col items-center gap-2 p-4 rounded-xl bg-black/60 border border-white/10 hover:border-[#ccff00]/40 transition-all group">
<div className="relative">
{contributor.user.imageUrl ? (
<img
src={contributor.user.imageUrl}
alt={contributor.user.username || 'Anon'}
className="w-12 h-12 rounded-full object-cover border-2 border-white/10 group-hover:border-[#ccff00]/50 transition-all"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#ff0040]/20 to-[#ccff00]/20 border-2 border-white/10 flex items-center justify-center text-xl">
🛹
</div>
)}
<div className="absolute -bottom-1 -right-1 bg-[#f7931a] rounded-full w-4 h-4 flex items-center justify-center">
<Zap className="h-2 w-2 text-white" />
</div>
</div>
<p className="text-xs font-bold text-center text-white truncate max-w-full w-full text-center">
{isAnon ? 'Anon 🛹' : contributor.user.username}
</p>
<p className="text-[10px] font-bold text-[#f7931a]">
{formatSats(contributor.amount)} sats
</p>
</div>
);
if (isAnon) return card;
return <a href={profileUrl} target="_blank" rel="noopener noreferrer">{card}</a>;
}
// ─── Collaborator card ────────────────────────────────────────────────────────
function CollaboratorCard({ collab }: { collab: Collaborator }) {
const href = collab.npub
? `/${collab.npub}`
: collab.url;
const inner = (
<div className="flex items-center gap-3 p-4 rounded-xl bg-black/60 border border-white/10 hover:border-[#ff0040]/40 transition-all group">
<div className="relative flex-shrink-0">
{collab.avatar ? (
<img
src={collab.avatar}
alt={collab.name}
className="w-12 h-12 rounded-full object-cover border-2 border-white/10 group-hover:border-[#ff0040]/50 transition-all"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#ff0040]/20 to-[#ff6600]/20 border-2 border-white/10 flex items-center justify-center text-xl">
🛹
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm text-white truncate">{collab.name}</p>
<p className="text-xs text-[#ff0040]/80 font-medium">{collab.role}</p>
</div>
{href && <ExternalLink className="h-3.5 w-3.5 text-gray-600 group-hover:text-[#ff0040] flex-shrink-0 transition-colors" />}
</div>
);
if (!href) return inner;
if (collab.npub) return <Link to={`/${collab.npub}`}>{inner}</Link>;
return <a href={href} target="_blank" rel="noopener noreferrer">{inner}</a>;
}
// ─── Partner card ─────────────────────────────────────────────────────────────
function PartnerCard({ partner }: { partner: Partner }) {
return (
<a
href={partner.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 rounded-xl bg-black/60 border border-white/10 hover:border-[#ff6600]/50 hover:bg-[#ff6600]/5 transition-all group"
>
<div className="w-10 h-10 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center flex-shrink-0 overflow-hidden">
{partner.logo ? (
<img
src={partner.logo}
alt={partner.name}
className="w-7 h-7 object-contain brightness-0 invert opacity-70 group-hover:opacity-100 transition-opacity"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<Building2 className="h-5 w-5 text-gray-600 group-hover:text-[#ff6600] transition-colors" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm text-white group-hover:text-[#ff6600] transition-colors">{partner.name}</p>
<p className="text-xs text-gray-500 truncate">{partner.description}</p>
</div>
<ExternalLink className="h-3.5 w-3.5 text-gray-600 group-hover:text-[#ff6600] flex-shrink-0 transition-colors" />
</a>
);
}
// ─── Friend card ──────────────────────────────────────────────────────────────
function FriendCard({ friend }: { friend: Friend }) {
return (
<a
href={friend.url}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 p-5 rounded-xl bg-black/60 border border-white/10 hover:border-[#ccff00]/40 hover:bg-[#ccff00]/5 transition-all group text-center"
>
<div className="w-16 h-16 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden group-hover:border-[#ccff00]/40 transition-all">
{friend.logo ? (
<img
src={friend.logo}
alt={friend.name}
className="w-12 h-12 object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<span className="text-2xl">🛹</span>
)}
</div>
<div>
<p className="font-bold text-sm text-white group-hover:text-[#ccff00] transition-colors">{friend.name}</p>
{friend.tagline && <p className="text-xs text-gray-500 mt-0.5">{friend.tagline}</p>}
</div>
</a>
);
}
// ─── Placeholder card (empty slot) ───────────────────────────────────────────
function PlaceholderCard({ label }: { label: string }) {
return (
<div className="flex flex-col items-center gap-3 p-5 rounded-xl border border-dashed border-white/10 text-center opacity-40">
<div className="w-16 h-16 rounded-2xl border border-dashed border-white/20 flex items-center justify-center">
<span className="text-2xl">?</span>
</div>
<p className="text-xs text-gray-600 font-medium">{label}</p>
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function SupportersPage() {
useSeoMeta({
title: 'Supporters - SkateSpots',
description: 'The community keeping SkateSpots alive — supporters, collaborators, partners and friends.',
});
const { data: contributors = [], isLoading } = useGeyserLeaderboard();
return (
<Layout>
<div className="container max-w-4xl px-4 py-12 md:py-16 space-y-16">
{/* ── Hero ── */}
<div className="text-center">
<div className="text-6xl mb-4">⚡</div>
<h1 className="text-5xl md:text-6xl font-black uppercase mb-3 leading-none">
<span className="neon-red">OUR</span>{' '}
<span className="neon-lime">COMMUNITY</span>
</h1>
<p className="text-gray-400 max-w-lg mx-auto text-base mb-8">
SkateSpots is built by and for the community. Supporters, collaborators, partners and friends keeping this free and decentralized.
</p>
<a
href={GEYSER_PROJECT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ff6600] text-white font-black uppercase text-sm hover:opacity-90 transition-all shadow-lg shadow-[#ff0040]/30"
>
<Zap className="h-4 w-4" />
Become a Supporter
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
</a>
</div>
{/* ── 1. Supporters ── */}
<section>
<SectionHeader
icon={Zap}
title="Supporters"
subtitle="Bitcoin contributors via Geyser — ordered by cumulative sats."
color="#f7931a"
/>
{isLoading ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="flex flex-col items-center gap-2 p-4 rounded-xl bg-black/40 border border-white/5 animate-pulse">
<div className="w-12 h-12 rounded-full bg-white/10" />
<div className="h-3 w-14 bg-white/10 rounded" />
<div className="h-3 w-10 bg-white/5 rounded" />
</div>
))}
</div>
) : contributors.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{contributors.map((c, i) => (
<SupporterCard key={`${c.user.id}-${i}`} contributor={c} />
))}
</div>
) : (
<div className="text-center py-12 rounded-2xl border border-dashed border-white/10">
<div className="text-4xl mb-3">🛹</div>
<p className="text-gray-500 mb-2">Be the first supporter!</p>
<p className="text-sm text-gray-600">The leaderboard updates once contributions come in.</p>
</div>
)}
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
<a
href={GEYSER_LEADERBOARD_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border-2 border-[#f7931a]/30 text-[#f7931a] font-bold text-sm hover:bg-[#f7931a]/10 transition-all"
>
<ExternalLink className="h-4 w-4" />
Full Leaderboard on Geyser
</a>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>Powered by</span>
<a href={GEYSER_PROJECT_URL} target="_blank" rel="noopener noreferrer"
className="font-bold text-gray-400 hover:text-white transition-colors">
Geyser 🧦
</a>
</div>
</div>
</section>
{/* ── 2. Collaborators ── */}
<section>
<SectionHeader
icon={Users}
title="Collaborators"
subtitle="People actively building SkateSpots — developers, designers, relay operators."
color="#ff0040"
/>
{COLLABORATORS.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{COLLABORATORS.map((c) => (
<CollaboratorCard key={c.name} collab={c} />
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-[#ff0040]/20 p-8 text-center">
<Users className="h-10 w-10 mx-auto mb-3 text-[#ff0040]/30" />
<p className="text-gray-500 text-sm mb-1">Want to collaborate?</p>
<p className="text-xs text-gray-600 mb-4">Developers, designers, skaters with skills — reach out.</p>
<a
href="https://nostrhub.io/naddr1qvzqqqrhnypzqg88e9f588nx7wyz4wt0ura23w3yd4hmztqrkr95utwyvgachjepqyd8wumn8ghj7emfwsh8x6rpddjhxur9v9ex2tnyd9uj7qq22d4kzar92dcx7arnhyhdwn"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-bold text-[#ff0040]/70 hover:text-[#ff0040] transition-colors"
>
<ExternalLink className="h-3 w-3" />
View on NostrHub
</a>
</div>
)}
</section>
{/* ── 3. Partners ── */}
<section>
<SectionHeader
icon={Handshake}
title="Partners"
subtitle="Organizations and projects we work closely with."
color="#ff6600"
/>
{PARTNERS.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{PARTNERS.map((p) => (
<PartnerCard key={p.name} partner={p} />
))}
</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-4">
{Array.from({ length: 4 }).map((_, i) => (
<PlaceholderCard key={i} label="Coming soon" />
))}
</div>
<div className="rounded-xl border border-dashed border-[#ff6600]/20 p-6 text-center">
<p className="text-gray-500 text-sm">Want to partner with SkateSpots? Reach out on Nostr.</p>
</div>
</>
)}
</section>
{/* ── 4. Friends ── */}
<section>
<SectionHeader
icon={Star}
title="Friends"
subtitle="People we vibe with."
color="#ccff00"
/>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{FRIENDS.map((f) => (
<FriendCard key={f.name} friend={f} />
))}
{/* Fill remaining slots up to 5 with placeholders */}
{FRIENDS.length < 5 && Array.from({ length: 5 - FRIENDS.length }).map((_, i) => (
<PlaceholderCard key={`ph-${i}`} label="Coming soon" />
))}
</div>
<div className="mt-4 rounded-xl border border-dashed border-[#ccff00]/20 p-5 text-center">
<p className="text-gray-500 text-sm mb-1">Want to be listed here?</p>
<p className="text-xs text-gray-600">
Send a logo (PNG/WebP, transparent background, min 200×200px) and your URL via DM on Nostr, only if you have the vibes.
</p>
</div>
</section>
{/* ── Mission ── */}
<section>
<div className="relative rounded-2xl overflow-hidden border-2 border-[#ff0040]/30 bg-black/80 p-8 md:p-10">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00]" />
<div className="flex justify-center mb-5">
<Heart className="h-10 w-10 text-[#ff0040]" />
</div>
<h2 className="text-2xl font-black uppercase text-center mb-3">
Why We Need <span className="neon-red">Support</span>
</h2>
<p className="text-gray-400 text-sm text-center max-w-2xl mx-auto mb-6 leading-relaxed">
SkateSpots is 100% free, open-source, and built on Nostr — a decentralized protocol with no central server to shut it down.
No ads, no data selling, no investors. Every sat goes directly toward development, relay hosting, and keeping this free for every skater on earth.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ emoji: '🛹', title: 'Nostr-native', desc: 'No central server. Your data lives on relays you choose.' },
{ emoji: '⚡', title: 'Bitcoin powered', desc: 'Earn sats for mapping spots. Spend them at local shops.' },
{ emoji: '🌍', title: 'Free forever', desc: 'Open source, no paywalls, no tracking, no VC funding.' },
].map(({ emoji, title, desc }) => (
<div key={title} className="p-4 rounded-xl bg-white/5 border border-white/10 text-center">
<div className="text-3xl mb-2">{emoji}</div>
<p className="font-bold text-sm mb-1">{title}</p>
<p className="text-xs text-gray-500">{desc}</p>
</div>
))}
</div>
</div>
</section>
{/* ── Footer ── */}
<div className="border-t border-gray-800 pt-8 text-center space-y-2 text-sm text-gray-500">
<p>Thank you. Every sat keeps the spots on the map. 🛹⚡</p>
<p>
<Link to="/license" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">License</Link>
{' '}&middot;{' '}
<Link to="/about" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">About</Link>
{' '}&middot;{' '}
<Link to="/faq" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">FAQ</Link>
</p>
</div>
</div>
</Layout>
);
}