feat: License page, Video Editors in Lab, Buy/Sell Gear, Stash locations, Supporters page

1. /license page
   - New LicensePage with "YOU ARE FREE TO SKATEBOARD" license text
   - Copyright 2025-∞ with neon THUG2 styling
   - Footer: "License" and "Supporters" links added next to OpenSats

2. /lab — Video Editor category
   - New SKATE_EDITOR_KIND (35003) in types.ts
   - EDITOR_SERVICE_LABELS/DESCRIPTIONS (8 edit types)
   - EDITOR_SKILL_LABELS/DESCRIPTIONS (8 software/hardware skills)
   - SkateEditor interface + parseSkateEditor() parser
   - useLabProfiles updated to query kind 35003 + 'skateeditor' tag
   - LabPage: new "Video Editors" tab (purple, Scissors icon)
   - LabPage: "Offer Editing" button in header
   - BecomeEditorPage: full form with edit types, skills, hardware,
     software, portfolio, contact, location, wallet requirement
   - Route /become-editor registered in AppRouter

3. /map Quick Actions
   - "Sell Gear" → "Buy/Sell Gear" with ArrowRightLeft icon
   - Button now links to /marketplace (browse + sell)

4. /stash — Storage locations
   - StashItem.storageLocation field: home/warehouse/friends/parents/wardrobe/other
   - AddItemDialog: 6-button grid to tag where gear is stored
   - StashItemCard: shows location badge on each card
   - Filter bar: pill filter chips to filter stash by location
   - storageLocation saved/restored via onAdd handler

5. /supporters page
   - btcmap.org-style supporters page
   - Live Geyser GraphQL leaderboard (skatespots project)
   - Avatar grid with sat amounts, anon fallbacks
   - Mission section + "Send Sats" section (Lightning address + Geyser)
   - Added to footer and desktop "More" dropdown and mobile menu

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 11:05:03 +07:00
parent 451ec5d3ed
commit 4e227f0367
11 changed files with 1136 additions and 27 deletions

View file

@ -24,6 +24,7 @@ import PyramidPage from "./pages/PyramidPage";
import LabPage from "./pages/LabPage";
import BecomeCoachPage from "./pages/BecomeCoachPage";
import BecomeFilmerPage from "./pages/BecomeFilmerPage";
import BecomeEditorPage from "./pages/BecomeEditorPage";
import CoachDetailPage from "./pages/CoachDetailPage";
import TourDetailPage from "./pages/TourDetailPage";
import AboutPage from "./pages/AboutPage";
@ -37,6 +38,8 @@ import BountyLeaderboardPage from "./pages/BountyLeaderboardPage";
import SignersPage from "./pages/SignersPage";
import StashPage from "./pages/StashPage";
import { NIP19Page } from "./pages/NIP19Page";
import LicensePage from "./pages/LicensePage";
import SupportersPage from "./pages/SupportersPage";
import NotFound from "./pages/NotFound";
export function AppRouter() {
@ -52,6 +55,7 @@ export function AppRouter() {
<Route path="/coaches" element={<LabPage />} />
<Route path="/become-coach" element={<BecomeCoachPage />} />
<Route path="/become-filmer" element={<BecomeFilmerPage />} />
<Route path="/become-editor" element={<BecomeEditorPage />} />
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/pyramid" element={<PyramidPage />} />
@ -84,6 +88,9 @@ export function AppRouter() {
<Route path="/signers" element={<SignersPage />} />
{/* Stash - Private Inventory */}
<Route path="/stash" element={<StashPage />} />
{/* Info pages */}
<Route path="/license" element={<LicensePage />} />
<Route path="/supporters" element={<SupportersPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View file

@ -43,6 +43,7 @@ import {
Database,
Package,
Zap,
Heart,
} from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -424,6 +425,12 @@ export function Layout({ children }: LayoutProps) {
{t('nav.about')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/supporters" className="flex items-center gap-2 cursor-pointer">
<Heart className="h-4 w-4 text-[#ff0040]" />
<span className="text-[#ff0040]">Supporters ⚡</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</nav>
@ -640,6 +647,13 @@ export function Layout({ children }: LayoutProps) {
isActive={isActive('/about')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/supporters"
label="Supporters ⚡"
icon={Heart}
isActive={isActive('/supporters')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/settings"
label={t('nav.settings')}
@ -723,15 +737,29 @@ export function Layout({ children }: LayoutProps) {
<div className="container px-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
<span>Made with <span className="text-red-500">❤️</span> for the skateboarding community</span>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>Not sponsored by</span>
<span className="inline-flex items-center gap-1.5">
<img
src="https://opensats.org/favicon.ico"
alt="OpenSats"
className="h-4 w-4"
/>
<span className="font-medium text-gray-400">OpenSats</span>
<div className="flex items-center gap-4 text-xs text-gray-500">
<Link
to="/license"
className="hover:text-[#ccff00] transition-colors font-medium"
>
License
</Link>
<Link
to="/supporters"
className="hover:text-[#ccff00] transition-colors font-medium"
>
Supporters
</Link>
<span className="flex items-center gap-1.5">
<span>Not sponsored by</span>
<span className="inline-flex items-center gap-1.5">
<img
src="https://opensats.org/favicon.ico"
alt="OpenSats"
className="h-4 w-4"
/>
<span className="font-medium text-gray-400">OpenSats</span>
</span>
</span>
</div>
</div>

View file

@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, parseSkateCoach, parseSkateFilmer, type SkateCoach, type SkateFilmer } from '@/lib/types';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, type SkateCoach, type SkateFilmer, type SkateEditor } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
export type LabProfile = (SkateCoach | SkateFilmer) & {
profileType: 'coach' | 'filmer';
export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor) & {
profileType: 'coach' | 'filmer' | 'editor';
};
export function useLabProfiles(limit = 100) {
@ -19,8 +19,8 @@ export function useLabProfiles(limit = 100) {
const events = await querySkateContent(
[
{
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND],
'#t': ['skatecoach', 'skatefilmer'],
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND],
'#t': ['skatecoach', 'skatefilmer', 'skateeditor'],
limit: limit * 2,
},
],
@ -41,10 +41,15 @@ export function useLabProfiles(limit = 100) {
if (filmer) {
profiles.push({ ...filmer, profileType: 'filmer' });
}
} else if (event.kind === SKATE_EDITOR_KIND) {
const editor = parseSkateEditor(event);
if (editor) {
profiles.push({ ...editor, profileType: 'editor' });
}
}
}
console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers)`);
console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers + editors)`);
return profiles.sort((a, b) => b.createdAt - a.createdAt);
},
@ -67,7 +72,7 @@ export function useLabProfile(identifier: string, pubkey?: string) {
authors?: string[];
limit: number;
} = {
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND],
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND],
'#d': [identifier],
limit: 1,
};
@ -93,6 +98,9 @@ export function useLabProfile(identifier: string, pubkey?: string) {
} else if (event.kind === SKATE_FILMER_KIND) {
const filmer = parseSkateFilmer(event);
return filmer ? { ...filmer, profileType: 'filmer' as const } : null;
} else if (event.kind === SKATE_EDITOR_KIND) {
const editor = parseSkateEditor(event);
return editor ? { ...editor, profileType: 'editor' as const } : null;
}
return null;

View file

@ -27,6 +27,8 @@ export interface StashItem {
itemType?: 'stash' | 'wtb';
// Rarity override (for special items)
rarity?: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
// Physical storage location
storageLocation?: 'home' | 'warehouse' | 'friends' | 'parents' | 'wardrobe' | 'other';
}
export interface Stash {

View file

@ -9,6 +9,7 @@ export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
export const CALENDAR_EVENT_KIND = 31923;
export const SKATE_COACH_KIND = 35001;
export const SKATE_FILMER_KIND = 35002; // Skate filmer/photographer profiles
export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
@ -455,6 +456,52 @@ export const FILMING_SKILL_DESCRIPTIONS: Record<string, string> = {
color: 'Color correction and grading',
};
// Video editor service types
export const EDITOR_SERVICE_LABELS: Record<string, string> = {
parts: 'Video Parts',
montages: 'Montages',
reels: 'Reels / Short Clips',
documentaries: 'Documentaries',
contest: 'Contest Edits',
brand: 'Brand / Promo Videos',
motion: 'Motion Graphics',
colorgrade: 'Color Grading Only',
};
export const EDITOR_SERVICE_DESCRIPTIONS: Record<string, string> = {
parts: 'Full skate video parts with soundtrack',
montages: 'Compilation edits from raw footage',
reels: '15–60 sec social media clips & reels',
documentaries: 'Story-driven skate culture films',
contest: 'Recap edits for contests and events',
brand: 'Promotional content for brands and shops',
motion: 'Titles, transitions, effects, and graphics',
colorgrade: 'Colour correction and LUT-based grading',
};
// Hardware / software skills for editors
export const EDITOR_SKILL_LABELS: Record<string, string> = {
premiere: 'Adobe Premiere',
resolve: 'DaVinci Resolve',
finalcut: 'Final Cut Pro',
aftereffects: 'After Effects',
luts: 'Custom LUTs',
vhs: 'VHS / Retro Aesthetic',
'4k': '4K & RAW Workflows',
sound: 'Sound Design / Mix',
};
export const EDITOR_SKILL_DESCRIPTIONS: Record<string, string> = {
premiere: 'Adobe Premiere Pro editing',
resolve: 'DaVinci Resolve editing & grading',
finalcut: 'Final Cut Pro editing',
aftereffects: 'Motion graphics and compositing',
luts: 'Custom colour look development',
vhs: 'VHS glitch, super-8, retro film looks',
'4k': 'High-res RAW/LOG camera workflows',
sound: 'Music sync, SFX, audio mastering',
};
export interface SkateCoach {
id: string;
pubkey: string;
@ -497,6 +544,29 @@ export interface SkateFilmer {
event: NostrEvent;
}
export interface SkateEditor {
id: string;
pubkey: string;
identifier: string;
title: string;
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // Combined editor services + software/hardware skills
experience?: string; // Years of experience
rate?: string;
phone?: string;
email?: string;
website?: string;
portfolio?: string; // Portfolio link (Vimeo, YouTube, etc.)
social?: string;
hardware?: string; // e.g. "MacBook Pro M3, Sony FX3"
software?: string; // e.g. "DaVinci Resolve, Premiere"
createdAt: number;
event: NostrEvent;
}
export interface SkateTour {
id: string;
pubkey: string;
@ -649,6 +719,52 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
};
}
export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
if (event.kind !== SKATE_EDITOR_KIND) return null;
const d = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !title) return null;
const location = event.tags.find(([name]) => name === 'location')?.[1];
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
const image = event.tags.find(([name]) => name === 'image')?.[1];
const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skateeditor').map(([, value]) => value);
const experience = event.tags.find(([name]) => name === 'experience')?.[1];
const rate = event.tags.find(([name]) => name === 'rate')?.[1];
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
const email = event.tags.find(([name]) => name === 'email')?.[1];
const website = event.tags.find(([name]) => name === 'website')?.[1];
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const hardware = event.tags.find(([name]) => name === 'hardware')?.[1];
const software = event.tags.find(([name]) => name === 'software')?.[1];
return {
id: event.id,
pubkey: event.pubkey,
identifier: d,
title,
bio: event.content,
location,
geohash,
image,
skills,
experience,
rate,
phone,
email,
website,
portfolio,
social,
hardware,
software,
createdAt: event.created_at,
event,
};
}
export function parseSkateTour(event: NostrEvent): SkateTour | null {
if (event.kind !== SKATE_TOUR_KIND) return null;

View file

@ -0,0 +1,411 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import {
SKATE_EDITOR_KIND,
EDITOR_SERVICE_LABELS,
EDITOR_SERVICE_DESCRIPTIONS,
EDITOR_SKILL_LABELS,
EDITOR_SKILL_DESCRIPTIONS,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
export default function BecomeEditorPage() {
useSeoMeta({
title: 'Become a Video Editor - SkateSpots',
description: 'Create your skateboarding video editor profile on SkateSpots.',
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const hasWallet = !!webln || !!activeNWC;
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [image, setImage] = useState<string | null>(null);
const [experience, setExperience] = useState('');
const [rate, setRate] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [hardware, setHardware] = useState('');
const [software, setSoftware] = useState('');
const [isGettingLocation, setIsGettingLocation] = useState(false);
const getDisplayName = () => {
if (author.data?.metadata?.name) return author.data.metadata.name;
if (user?.pubkey) {
const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
return 'Anonymous Editor';
};
const toggleService = (s: string) =>
setSelectedServices((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
const toggleSkill = (s: string) =>
setSelectedSkills((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(err) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: err.message, variant: 'destructive' });
}
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const tags = await uploadFile(files[0]);
const url = tags[0]?.[1];
if (url) { setImage(url); toast({ title: 'Image uploaded' }); }
} catch (err) {
toast({ title: 'Upload failed', description: err instanceof Error ? err.message : 'Error', variant: 'destructive' });
}
e.target.value = '';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedServices.length === 0) {
toast({ title: 'Services required', description: 'Select at least one edit type.', variant: 'destructive' });
return;
}
try {
const identifier = 'editor-profile';
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', displayName],
['t', 'skateeditor'],
['alt', `Skate video editor: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
}
for (const s of selectedServices) tags.push(['t', s]);
for (const s of selectedSkills) tags.push(['t', s]);
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]);
if (rate.trim()) tags.push(['rate', rate.trim()]);
if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
if (software.trim()) tags.push(['software', software.trim()]);
await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags });
toast({ title: 'Editor profile created!', description: 'Your profile has been published to SkateSpots.' });
navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (err) {
toast({ title: 'Failed to publish', description: err instanceof Error ? err.message : 'Error', variant: 'destructive' });
}
};
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<Scissors className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">Log in to create your video editor profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = !!author.data?.metadata?.nip05;
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab
</Link>
<h1 className="text-3xl font-black uppercase" style={{ color: '#c084fc', fontFamily: 'Impact, "Arial Black", sans-serif', textShadow: '0 0 20px rgba(192,132,252,0.6)' }}>
Become a Video Editor
</h1>
<p className="text-gray-400 mt-1">Offer your video editing services to the skate community</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<span className="text-yellow-500">⚠️</span>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Recommended</h3>
<p className="text-sm text-gray-300 mb-2">Verify your identity to build trust with clients.</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)}
<Card className="mt-4 bg-purple-950/30 border-2 border-purple-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-purple-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-purple-400 mb-2">⚡ Lightning Wallet for Payments</h3>
<p className="text-sm text-gray-300 mb-3">Receive Bitcoin ⚡ payments directly from clients for your editing work.</p>
{hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<p className="text-sm font-bold text-green-400">Wallet Connected ✓</p>
</div>
</div>
) : (
<div className="flex flex-col sm:flex-row gap-2">
<WalletModal>
<Button className="gap-2 bg-purple-600 hover:bg-purple-700 flex-1">
<Wallet className="h-4 w-4" />
Connect Wallet
</Button>
</WalletModal>
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1">
<Button variant="outline" className="gap-2 w-full border-purple-500/50 text-purple-400 hover:bg-purple-500/10">
<ExternalLink className="h-4 w-4" /> Learn How
</Button>
</a>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Scissors className="h-5 w-5 text-purple-400" />
Editor Profile
</CardTitle>
<CardDescription className="text-gray-400">Tell the community about your editing style and skills</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Identity */}
<div className="space-y-2">
<Label>Your Identity</Label>
<div className="p-4 rounded-lg border-2 border-purple-500/30 bg-purple-500/5">
<p className="font-black text-lg">{getDisplayName()}</p>
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
</div>
</div>
{/* Profile image */}
<div className="space-y-2">
<Label>Profile / Showreel Thumbnail</Label>
{image ? (
<div className="relative w-32 h-32">
<img src={image} alt="profile" className="w-32 h-32 object-cover rounded-lg border-2 border-purple-500/40" />
<Button type="button" variant="destructive" size="icon" className="absolute -top-2 -right-2 h-6 w-6" onClick={() => setImage(null)}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-700 rounded-lg cursor-pointer hover:border-purple-500/50 transition-colors bg-gray-800/50">
<Upload className="h-8 w-8 text-gray-500 mb-2" />
<span className="text-sm text-gray-500">{isUploading ? 'Uploading…' : 'Upload image'}</span>
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} disabled={isUploading} />
</label>
)}
</div>
{/* Bio */}
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your editing style, background, notable projects…" value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
{/* Edit Types */}
<div className="space-y-3">
<Label>Edit Types Offered * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => (
<button
key={service}
type="button"
onClick={() => toggleService(service)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedServices.includes(service)
? 'bg-purple-600 text-white border-purple-600'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="text-xs opacity-70 mt-1">{EDITOR_SERVICE_DESCRIPTIONS[service]}</div>
</button>
))}
</div>
</div>
{/* Software / Hardware Skills */}
<div className="space-y-3">
<Label>Software & Hardware Skills</Label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{SKILL_ENTRIES.map(([skill, label]) => (
<button
key={skill}
type="button"
onClick={() => toggleSkill(skill)}
className={cn(
'p-2.5 rounded-lg text-left transition-all border text-sm',
selectedSkills.includes(skill)
? 'bg-purple-500 text-white border-purple-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold text-xs uppercase">{label}</div>
<div className="text-[10px] opacity-60 mt-0.5 line-clamp-1">{EDITOR_SKILL_DESCRIPTIONS[skill]}</div>
</button>
))}
</div>
</div>
{/* Hardware / Software free-text */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="hardware" className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-gray-400" /> Hardware
</Label>
<Input id="hardware" placeholder="e.g. MacBook Pro M3, Sony FX3" value={hardware} onChange={(e) => setHardware(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="software" className="flex items-center gap-2">
<Scissors className="h-4 w-4 text-gray-400" /> Software
</Label>
<Input id="software" placeholder="e.g. DaVinci Resolve, Premiere" value={software} onChange={(e) => setSoftware(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{/* Experience & Rate */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="experience">Years of Experience</Label>
<Input id="experience" placeholder="e.g. 5 years" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="rate">Rate / Pricing</Label>
<Input id="rate" placeholder="e.g. 50k sats/min, DM for quote" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{/* Location */}
<div className="space-y-2">
<Label>Location (optional)</Label>
<Input placeholder="City, Country" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
<div className="grid grid-cols-2 gap-2">
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
</div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="gap-2 border-gray-700">
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
Use My Location
</Button>
</div>
{/* Contact & Portfolio */}
<div className="space-y-4">
<Label>Contact & Portfolio</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Phone / Telegram" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Portfolio (Vimeo, YouTube…)" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Website" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
</CardContent>
</Card>
<div className="mt-6 flex gap-3">
<Link to="/lab" className="flex-1">
<Button type="button" variant="outline" className="w-full border-gray-700">Cancel</Button>
</Link>
<Button
type="submit"
disabled={isPublishing || selectedServices.length === 0}
className="flex-1 bg-purple-600 hover:bg-purple-700 font-black uppercase gap-2"
>
{isPublishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Scissors className="h-4 w-4" />}
{isPublishing ? 'Publishing…' : 'Publish Editor Profile'}
</Button>
</div>
</form>
</div>
</Layout>
);
}

View file

@ -15,12 +15,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useTranslation } from '@/contexts/LanguageContext';
import { genUserName } from '@/lib/genUserName';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS } from '@/lib/types';
import { GraduationCap, Search, Plus, MapPin, Filter, Video } from 'lucide-react';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS } from '@/lib/types';
import { GraduationCap, Search, Plus, MapPin, Filter, Video, Scissors } from 'lucide-react';
import { cn } from '@/lib/utils';
const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
const FILMING_SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
const EDITOR_SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
const EDITOR_SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
export default function LabPage() {
const { t } = useTranslation();
@ -34,7 +36,7 @@ export default function LabPage() {
const { data: profiles, isLoading } = useLabProfiles(100);
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [serviceType, setServiceType] = useState<'coaching' | 'filming'>('coaching');
const [serviceType, setServiceType] = useState<'coaching' | 'filming' | 'editing'>('coaching');
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>('all');
const [sortBy, setSortBy] = useState<SortOption>('recent');
@ -57,6 +59,7 @@ export default function LabPage() {
// Filter by service type
if (serviceType === 'coaching' && profile.profileType !== 'coach') return false;
if (serviceType === 'filming' && profile.profileType !== 'filmer') return false;
if (serviceType === 'editing' && profile.profileType !== 'editor') return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
@ -108,7 +111,7 @@ export default function LabPage() {
<p className="text-gray-400">{t('labPage.subtitle')}</p>
</div>
{user ? (
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Link to="/become-coach">
<Button className="bg-red-500 hover:bg-red-600 gap-2 uppercase font-black">
<Plus className="h-4 w-4" />
@ -121,6 +124,12 @@ export default function LabPage() {
{t('labPage.offerFilming')}
</Button>
</Link>
<Link to="/become-editor">
<Button className="bg-purple-600 hover:bg-purple-700 gap-2 uppercase font-black">
<Plus className="h-4 w-4" />
Offer Editing
</Button>
</Link>
</div>
) : (
<Badge variant="outline" className="border-yellow-500/50 text-yellow-400 px-3 py-1.5">
@ -159,6 +168,20 @@ export default function LabPage() {
<Video className="h-4 w-4" />
{t('labPage.filmersTab')}
</Button>
<Button
onClick={() => {
setServiceType('editing');
setSelectedSkills([]);
}}
variant={serviceType === 'editing' ? 'default' : 'outline'}
className={cn(
'uppercase font-bold gap-2',
serviceType === 'editing' ? 'bg-purple-600 hover:bg-purple-700' : 'border-gray-700'
)}
>
<Scissors className="h-4 w-4" />
Video Editors
</Button>
</div>
{/* Filters */}
@ -166,15 +189,17 @@ export default function LabPage() {
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center gap-2 mb-3">
<Filter className={`h-5 w-5 ${serviceType === 'filming' ? 'text-orange-500' : 'text-red-500'}`} />
<Filter className={`h-5 w-5 ${serviceType === 'filming' ? 'text-orange-500' : serviceType === 'editing' ? 'text-purple-500' : 'text-red-500'}`} />
<h3 className="font-black uppercase text-sm">
{serviceType === 'coaching' && t('labPage.whatToLearn')}
{serviceType === 'filming' && t('labPage.whatContent')}
{serviceType === 'editing' && 'Edit Style & Tools'}
</h3>
</div>
<p className="text-sm text-gray-400 mb-4">
{serviceType === 'coaching' && t('labPage.selectSkillsDesc')}
{serviceType === 'filming' && t('labPage.selectFilmingDesc')}
{serviceType === 'editing' && 'Filter by edit type or software/hardware the editor uses.'}
</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
@ -215,6 +240,41 @@ export default function LabPage() {
</div>
</button>
))}
{/* Show editor services when editing */}
{serviceType === 'editing' && EDITOR_SERVICE_ENTRIES.map(([service, label]) => (
<button
key={service}
onClick={() => toggleSkill(service)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedSkills.includes(service)
? 'bg-purple-600 text-white border-purple-600'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
<div className="font-bold uppercase text-xs mb-1">{label}</div>
<div className="text-xs opacity-70 line-clamp-1">
{EDITOR_SERVICE_DESCRIPTIONS[service]}
</div>
</button>
))}
{/* Show editor tool skills when editing (second row) */}
{serviceType === 'editing' && EDITOR_SKILL_ENTRIES.map(([skill, label]) => (
<button
key={skill}
onClick={() => toggleSkill(skill)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedSkills.includes(skill)
? 'bg-purple-500 text-white border-purple-500'
: 'bg-gray-900 hover:bg-gray-800 border-purple-900/50 text-gray-400'
)}
>
<div className="font-bold uppercase text-xs mb-1">{label}</div>
</button>
))}
</div>
<div className="flex flex-col sm:flex-row gap-3">

95
src/pages/LicensePage.tsx Normal file
View file

@ -0,0 +1,95 @@
import { useSeoMeta } from '@unhead/react';
import { Layout } from '@/components/Layout';
import { Link } from 'react-router-dom';
import { Infinity, Scale, Skateboard } from 'lucide-react';
export default function LicensePage() {
useSeoMeta({
title: 'License - SkateSpots',
description: 'SkateSpots license. You are free to skateboard.',
});
return (
<Layout>
<div className="container max-w-2xl px-4 py-16 md:py-24">
{/* Big skate emoji hero */}
<div className="text-center mb-16">
<div className="text-8xl mb-6 select-none" style={{ filter: 'drop-shadow(0 0 24px #ff0040)' }}>
🛹
</div>
<h1 className="text-5xl md:text-7xl font-black uppercase mb-4 leading-none tracking-tight">
<span className="neon-red">Skate</span>{' '}
<span className="neon-lime">Spots</span>
</h1>
<p className="text-gray-400 text-lg">
Skate, earn, have fun and spend Sats wherever you are.
</p>
</div>
{/* Copyright */}
<div className="text-center mb-12">
<p className="text-gray-300 text-base">
Copyright © 2025–
<span className="neon-lime text-xl align-middle mx-1">∞</span>
</p>
<p className="text-gray-500 text-sm mt-1">
SkateSpots contributors &amp; the community
</p>
</div>
{/* License card */}
<div className="relative rounded-2xl border-2 border-[#ccff00]/40 bg-black/80 overflow-hidden shadow-[0_0_40px_rgba(204,255,0,0.1)] p-8 md:p-12 text-center">
{/* Glow strip */}
<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-6">
<div className="w-16 h-16 rounded-2xl bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center">
<Scale className="h-8 w-8 text-[#ccff00]" />
</div>
</div>
<p className="text-gray-400 text-sm uppercase tracking-widest mb-4 font-bold">License</p>
<h2 className="text-3xl md:text-4xl font-black uppercase mb-6 leading-tight">
<span className="neon-lime">YOU ARE FREE</span>
<br />
<span className="text-white">TO SKATEBOARD</span>
</h2>
<p className="text-gray-400 leading-relaxed max-w-md mx-auto mb-8">
This project is open. Share it, fork it, build on it, skate it.
The streets belong to everyone. So does this code.
Just keep skating and don't be a kook.
</p>
<div className="border-t border-gray-800 pt-6 space-y-2 text-sm text-gray-500">
<p>Built with ❤️ on <span className="text-[#ff0040]">Nostr</span> · Powered by <span className="text-[#f7931a]">Bitcoin Lightning ⚡</span></p>
<p>
<a
href="https://github.com/skatespots"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline"
>
View source
</a>
{' '}&middot;{' '}
<Link to="/about" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">
About SkateSpots
</Link>
{' '}&middot;{' '}
<Link to="/supporters" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">
Supporters
</Link>
</p>
</div>
</div>
{/* MIT note */}
<p className="text-center text-gray-600 text-xs mt-8">
The source code is also available under the MIT license for those who need it.
</p>
</div>
</Layout>
);
}

View file

@ -811,12 +811,12 @@ export default function MapPage() {
Add Spot
</Button>
</Link>
<Link to="/create-listing" onClick={() => setMobileFiltersOpen(false)}>
<Button size="sm" variant="outline" className="w-full gap-1 border-orange-500/50 text-orange-400 font-bold h-9 text-xs">
<ShoppingBag className="h-3 w-3" />
Sell Gear
</Button>
</Link>
<Link to="/marketplace" onClick={() => setMobileFiltersOpen(false)}>
<Button size="sm" variant="outline" className="w-full gap-1 border-orange-500/50 text-orange-400 font-bold h-9 text-xs">
<ArrowRightLeft className="h-3 w-3" />
Buy/Sell Gear
</Button>
</Link>
<Link to="/stash" onClick={() => setMobileFiltersOpen(false)}>
<Button size="sm" variant="outline" className="w-full gap-1 border-[#ff6600]/50 text-[#ff6600] font-bold h-9 text-xs">
<Backpack className="h-3 w-3" />

View file

@ -224,6 +224,22 @@ function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fa
</div>
</div>
{/* Storage location badge */}
{!isWTB && item.storageLocation && item.storageLocation !== 'other' && (
<div className="flex items-center gap-1">
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-[#ff6600]/30 text-[#ff6600] font-bold uppercase tracking-wide">
{{
home: '🏠 Home',
warehouse: '🏭 Warehouse',
friends: "👫 Friend's",
parents: "👨‍👩‍👦 Parents'",
wardrobe: '🚪 Wardrobe',
other: '📦 Other',
}[item.storageLocation]}
</span>
</div>
)}
{/* Condition stars */}
{!isWTB && (
<div className="flex items-center gap-1">
@ -452,6 +468,7 @@ function AddItemDialog({ onAdd, mode = 'stash' }: { onAdd: (item: Omit<StashItem
willingToTrade: formData.willingToTrade,
images: formData.images,
itemType: isWTB ? 'wtb' : 'stash',
storageLocation: isWTB ? undefined : formData.storageLocation,
});
setFormData({
@ -722,6 +739,40 @@ function AddItemDialog({ onAdd, mode = 'stash' }: { onAdd: (item: Omit<StashItem
</div>
</div>
{/* Storage Location */}
{!isWTB && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-[#ff6600]" />
Where is this item?
</Label>
<div className="grid grid-cols-3 gap-2">
{([
{ value: 'home', label: '🏠 Home' },
{ value: 'warehouse', label: '🏭 Warehouse' },
{ value: 'friends', label: "👫 Friend's" },
{ value: 'parents', label: "👨‍👩‍👦 Parents'" },
{ value: 'wardrobe', label: '🚪 Wardrobe' },
{ value: 'other', label: '📦 Other' },
] as const).map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setFormData({ ...formData, storageLocation: value })}
className={cn(
'p-2 rounded-lg text-xs font-bold text-center transition-all border',
formData.storageLocation === value
? 'bg-[#ff6600] border-[#ff6600] text-black'
: 'bg-black/40 border-white/10 text-gray-400 hover:border-[#ff6600]/50 hover:text-white'
)}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Visibility */}
<div className="space-y-2">
<Label>Visibility</Label>
@ -1264,6 +1315,7 @@ export default function StashPage() {
const { items, stats, isLoading, addItem, updateItem, removeItem, isEncryptionAvailable } = useStash();
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState<string>('all');
const [filterLocation, setFilterLocation] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'stash' | 'wishlist'>('stash');
// Edit dialog state
@ -1329,6 +1381,9 @@ export default function StashPage() {
if (filterCategory !== 'all' && item.category !== filterCategory) {
return false;
}
if (filterLocation !== 'all' && item.storageLocation !== filterLocation) {
return false;
}
return true;
});
@ -1502,6 +1557,37 @@ export default function StashPage() {
</Select>
</div>
{/* Location filter chips — only show for stash tab */}
{activeTab === 'stash' && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-xs text-gray-500 font-bold uppercase tracking-wide flex items-center gap-1">
<MapPin className="h-3 w-3" /> Location:
</span>
{([
{ value: 'all', label: 'All' },
{ value: 'home', label: '🏠 Home' },
{ value: 'warehouse', label: '🏭 Warehouse' },
{ value: 'friends', label: "👫 Friend's" },
{ value: 'parents', label: "👨‍👩‍👦 Parents'" },
{ value: 'wardrobe', label: '🚪 Wardrobe' },
{ value: 'other', label: '📦 Other' },
] as const).map(({ value, label }) => (
<button
key={value}
onClick={() => setFilterLocation(value)}
className={cn(
'px-2.5 py-1 rounded-full text-xs font-bold transition-all border',
filterLocation === value
? 'bg-[#ff6600] border-[#ff6600] text-black'
: 'bg-black/40 border-white/10 text-gray-400 hover:border-[#ff6600]/50 hover:text-white'
)}
>
{label}
</button>
))}
</div>
)}
{/* Items grid */}
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">

View file

@ -0,0 +1,296 @@
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 } 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';
interface GeyserContributor {
user: {
id: string;
username: string;
imageUrl?: string;
heroLink?: string;
};
amount: number; // sats
funder?: boolean;
}
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, // 5 min cache
retry: false,
});
}
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 min-w-0">
<div className="relative">
{contributor.user.imageUrl ? (
<img
src={contributor.user.imageUrl}
alt={contributor.user.username || 'Anon'}
className="w-14 h-14 rounded-full object-cover border-2 border-white/10 group-hover:border-[#ccff00]/50 transition-all"
/>
) : (
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-[#ff0040]/20 to-[#ccff00]/20 border-2 border-white/10 flex items-center justify-center text-2xl">
🛹
</div>
)}
<div className="absolute -bottom-1 -right-1 bg-[#f7931a] rounded-full w-5 h-5 flex items-center justify-center">
<Zap className="h-2.5 w-2.5 text-white" />
</div>
</div>
<p className="text-xs font-bold text-center text-white truncate max-w-full">
{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>
);
}
export default function SupportersPage() {
useSeoMeta({
title: 'Supporters - SkateSpots',
description: 'The community keeping SkateSpots alive. Powered by Geyser and Bitcoin Lightning.',
});
const { data: contributors = [], isLoading } = useGeyserLeaderboard();
return (
<Layout>
<div className="container max-w-4xl px-4 py-12 md:py-16">
{/* Hero */}
<div className="text-center mb-14">
<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">SUPPORTERS</span>
</h1>
<p className="text-gray-400 max-w-lg mx-auto text-base mb-6">
SkateSpots is community-powered. These skaters and Bitcoiners help keep the project free, decentralized, and built for the streets.
</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>
{/* Individuals section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-6">
<Users className="h-5 w-5 text-[#ccff00]" />
<h2 className="text-2xl font-black uppercase">
<span className="neon-lime">Plebs</span>
</h2>
<div className="flex-1 h-px bg-gradient-to-r from-[#ccff00]/30 to-transparent" />
</div>
<p className="text-gray-400 text-sm mb-6">
Every sat counts — ordered by cumulative contributions.
</p>
{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-14 h-14 rounded-full bg-white/10" />
<div className="h-3 w-16 bg-white/10 rounded" />
<div className="h-3 w-12 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 will update once contributions come in.
</p>
</div>
)}
{/* Geyser leaderboard link */}
<div className="mt-6 text-center">
<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-[#ccff00]/30 text-[#ccff00] font-bold text-sm hover:bg-[#ccff00]/10 transition-all"
>
<ExternalLink className="h-4 w-4" />
Full Leaderboard on Geyser
</a>
</div>
{/* Powered by Geyser */}
<div className="mt-8 flex items-center justify-center gap-3">
<span className="text-xs text-gray-500">Powered by</span>
<a
href={GEYSER_PROJECT_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<img
src="https://geyser.fund/logo-brand.svg"
alt="Geyser"
className="h-5 brightness-0 invert opacity-60 hover:opacity-90 transition-opacity"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<span className="text-sm font-bold text-gray-400 hover:text-white transition-colors">Geyser</span>
</a>
</div>
</section>
{/* Mission section */}
<section className="mb-16">
<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="absolute -top-16 -right-16 w-48 h-48 bg-[#ff0040] rounded-full opacity-5 blur-3xl pointer-events-none" />
<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.
We don't run ads, sell your data, or answer to investors. Keeping the lights on and building new features relies entirely on the skate community.
Every sat you contribute goes directly toward development, relay hosting, and keeping this tool free for every skater on earth.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
{[
{ 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>
{/* Payment options */}
<section className="mb-12">
<h2 className="text-xl font-black uppercase mb-5 flex items-center gap-2">
<Zap className="h-5 w-5 text-[#f7931a]" />
<span>Send Sats</span>
</h2>
<div className="grid sm:grid-cols-2 gap-4">
<div className="p-5 rounded-xl border-2 border-[#f7931a]/30 bg-black/60 space-y-2">
<p className="text-xs font-bold uppercase text-[#f7931a] tracking-widest flex items-center gap-2">
<Zap className="h-3.5 w-3.5" /> Lightning
</p>
<p className="font-mono text-sm text-white break-all select-all cursor-text">
skatespots@goskatespots.nostr1.com
</p>
<p className="text-xs text-gray-500">Any Lightning wallet supporting LNURL</p>
</div>
<div className="p-5 rounded-xl border-2 border-[#ff0040]/20 bg-black/60 flex flex-col justify-between gap-3">
<p className="text-xs font-bold uppercase text-gray-400 tracking-widest">Via Geyser</p>
<p className="text-sm text-gray-400">
Contribute via the Geyser campaign — your name goes on the leaderboard above.
</p>
<a
href={GEYSER_PROJECT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-bold text-[#ccff00] hover:text-[#ccff00]/80 transition-colors"
>
Open campaign <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
</section>
{/* Footer links */}
<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>
);
}