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:
parent
451ec5d3ed
commit
4e227f0367
11 changed files with 1136 additions and 27 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
116
src/lib/types.ts
116
src/lib/types.ts
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
411
src/pages/BecomeEditorPage.tsx
Normal file
411
src/pages/BecomeEditorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
95
src/pages/LicensePage.tsx
Normal 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 & 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>
|
||||
{' '}·{' '}
|
||||
<Link to="/about" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">
|
||||
About SkateSpots
|
||||
</Link>
|
||||
{' '}·{' '}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
296
src/pages/SupportersPage.tsx
Normal file
296
src/pages/SupportersPage.tsx
Normal 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>
|
||||
{' '}·{' '}
|
||||
<Link to="/about" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">About</Link>
|
||||
{' '}·{' '}
|
||||
<Link to="/faq" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">FAQ</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue