import { useSeoMeta } from '@unhead/react'; import { Link, useSearchParams } from 'react-router-dom'; import { Layout } from '@/components/Layout'; import { EditProfileForm } from '@/components/EditProfileForm'; import { RelayListManager } from '@/components/RelayListManager'; import { FollowListManager } from '@/components/FollowListManager'; import { WalletModal } from '@/components/WalletModal'; import { SecuritySettings } from '@/components/SecuritySettings'; import { RelatrSettings } from '@/components/RelatrSettings'; import { LanguageSelector } from '@/components/LanguageToggle'; import { DesktopNavManager } from '@/components/DesktopNavManager'; import { MyDataTab } from '@/components/MyDataTab'; import { useTranslation } from '@/contexts/LanguageContext'; import { useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { NSchema as n } from '@nostrify/nostrify'; import { useState, useEffect, useRef, useCallback } from 'react'; import { LoginArea } from '@/components/auth/LoginArea'; import { Button } from '@/components/ui/button'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; import { toast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import QRCode from 'qrcode'; import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Network, Copy, Check, Smartphone, Download, Globe, } from 'lucide-react'; // ─── Types ──────────────────────────────────────────────────────────────────── type Section = | null | 'profile' | 'follows' | 'nip05' | 'security' | 'mydata' | 'language' | 'appearance' | 'relays' | 'crewnetwork' | 'wallet' | 'stash' | 'advanced'; // ─── Back header ────────────────────────────────────────────────────────────── function BackHeader({ title, onBack }: { title: string; onBack: () => void }) { return (

{title}

); } // ─── Menu row ──────────────────────────────────────────────────────────────── function MenuRow({ icon: Icon, label, sublabel, iconColor, iconBg, onClick, href, badge, }: { icon: React.ComponentType<{ className?: string }>; label: string; sublabel?: string; iconColor: string; iconBg: string; onClick?: () => void; href?: string; badge?: string; }) { const inner = (

{label}

{sublabel &&

{sublabel}

}
{badge && ( {badge} )}
); if (href) { return {inner}; } return ; } // ─── Menu group ─────────────────────────────────────────────────────────────── function MenuGroup({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } // ─── Profile sub-screen ─────────────────────────────────────────────────────── const PROFILE_FALLBACK_RELAYS = ['wss://purplepag.es', 'wss://relay.damus.io', 'wss://nos.lol']; function ProfileScreen({ onBack }: { onBack: () => void }) { const { user, metadata } = useCurrentUser(); const { nostr } = useNostr(); const queryClient = useQueryClient(); const [isRefreshing, setIsRefreshing] = useState(false); const handleRefresh = async () => { if (!user) return; setIsRefreshing(true); try { queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); let events = await nostr.query([{ kinds: [0], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(8000) }); if (events.length === 0) { const fallback = nostr.group(PROFILE_FALLBACK_RELAYS); events = await fallback.query([{ kinds: [0], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(8000) }); } if (events.length > 0) { const profileMetadata = n.json().pipe(n.metadata()).parse(events[0].content); queryClient.setQueryData(['author', user.pubkey], { metadata: profileMetadata, event: events[0] }); toast({ title: 'Profile refreshed!', description: `Found: ${profileMetadata.name || 'unnamed'}` }); } else { toast({ title: 'Profile not found', description: 'Not found on any relay.', variant: 'destructive' }); } } catch { toast({ title: 'Refresh failed', description: 'Could not connect to relays.', variant: 'destructive' }); } finally { setIsRefreshing(false); } }; return (
{!user ? (

Log in to edit your profile

) : (
{!metadata?.name && (

Profile not loaded from relays.

)}
)}
); } // ─── NIP-05 sub-screen ──────────────────────────────────────────────────────── function Nip05Screen({ onBack }: { onBack: () => void }) { const providers = [ { name: 'nostr.directory', desc: 'Free NIP-05 addresses', url: 'https://nostr.directory', free: true }, { name: 'nip05.social', desc: 'Free verification service', url: 'https://nip05.social', free: true }, { name: 'nostrplebs.com', desc: '~$3/month · Custom domains', url: 'https://nostrplebs.com', free: false }, { name: 'nostr.band', desc: 'One-time fee · Analytics included', url: 'https://nostr.band', free: false }, ]; return (

Get a verified Nostr address (like name@domain.com) to build trust. Required for creating events, coaching profiles, and shops.

{providers.map((p) => (

{p.name}

{p.desc}

{p.free ? 'Free' : 'Paid'}
))}
After getting your NIP-05, add it in the Profile field and save.
); } // ─── Relays sub-screen ──────────────────────────────────────────────────────── function RelaysScreen({ onBack }: { onBack: () => void }) { const { user } = useCurrentUser(); const { config, updateConfig } = useAppContext(); const useSkateRelay = config.useSkateRelay ?? true; const handleToggle = () => { updateConfig((current) => ({ ...current, useSkateRelay: !useSkateRelay })); toast({ title: useSkateRelay ? 'Community relay disabled' : 'Community relay enabled', description: useSkateRelay ? 'Using only your configured relays.' : 'SkateSpots relay included.', }); }; return (
{user && (

Community Relay

{useSkateRelay ? 'Syncing with global skate community' : 'Using only personal relays'}

)}
); } // ─── Appearance sub-screen ──────────────────────────────────────────────────── function AppearanceScreen({ onBack }: { onBack: () => void }) { const { t } = useTranslation(); return (

Language

🌙

Dark Mode Only

SkateSpots runs a dark neon aesthetic inspired by skate culture. Black background, neon pink, lime, and orange.

{/* Desktop top-nav customization (Option B). Only relevant on wider screens, so hide the control on mobile where the bottom-bar customizer is used instead. */}

Navigation

); } // ─── Advanced sub-screen ────────────────────────────────────────────────────── function AdvancedScreen({ onBack }: { onBack: () => void }) { const handleResetSasquatch = () => { localStorage.removeItem('sasquatch-minimized'); alert('The Sasquatch has been expanded! 🦧 Refresh the page to see him.'); window.location.reload(); }; return (

🦧 Sasquatch Guide

Reset the onboarding guide to expanded view

); } // ─── Wallet sub-screen ──────────────────────────────────────────────────────── function WalletScreen({ onBack }: { onBack: () => void }) { const { user } = useCurrentUser(); return (
{!user ? (

Log in to manage your wallet

) : (

Open Wallet

Balance, send, receive

)}
); } // ─── Crew Network sub-screen ────────────────────────────────────────────────── const NOSTR_VPN_APP_STORE = 'https://apps.apple.com/app/nostr-vpn/id6785410348'; const NOSTR_VPN_GITHUB = 'https://github.com/mmalmi/nostr-vpn/releases/latest'; const NOSTR_VPN_TESTFLIGHT = 'https://testflight.apple.com/join/58sg4agv'; const NOSTR_VPN_ANDROID = 'https://github.com/mmalmi/nostr-vpn/releases/latest'; /** Detect the user's OS/platform to surface the most relevant download first. */ function detectPlatform(): 'ios' | 'android' | 'desktop' { const ua = navigator.userAgent; if (/android/i.test(ua)) return 'android'; if (/iphone|ipad|ipod/i.test(ua)) return 'ios'; return 'desktop'; } const ALL_PLATFORMS = [ { id: 'ios', label: 'iOS App Store', sublabel: 'iPhone & iPad', icon: Smartphone, url: NOSTR_VPN_APP_STORE, color: 'text-blue-400', bg: 'bg-blue-500/15', badge: 'Free' }, { id: 'testflight',label: 'iOS TestFlight', sublabel: 'Beta builds', icon: Smartphone, url: NOSTR_VPN_TESTFLIGHT, color: 'text-indigo-400', bg: 'bg-indigo-500/15', badge: 'Beta' }, { id: 'android', label: 'Android APK', sublabel: 'arm64 devices', icon: Smartphone, url: NOSTR_VPN_ANDROID, color: 'text-green-400', bg: 'bg-green-500/15', badge: 'APK' }, { id: 'desktop', label: 'macOS / Linux / Windows',sublabel: 'Native desktop apps', icon: Download, url: NOSTR_VPN_GITHUB, color: 'text-amber-400', bg: 'bg-amber-500/15', badge: 'Open source' }, ]; function CrewNetworkScreen({ onBack }: { onBack: () => void }) { const { user, metadata } = useCurrentUser(); const canvasRef = useRef(null); const [copied, setCopied] = useState(false); const [qrReady, setQrReady] = useState(false); const [qrError, setQrError] = useState(false); const [openingApp, setOpeningApp] = useState(false); // Detect platform once — sort most-relevant download to the top. const platform = detectPlatform(); const platforms = [...ALL_PLATFORMS].sort((a) => { if (platform === 'ios' && (a.id === 'ios' || a.id === 'testflight')) return -1; if (platform === 'android' && a.id === 'android') return -1; if (platform === 'desktop' && a.id === 'desktop') return -1; return 0; }); // Build the join-request deep link using the user's npub as device identity. // Format: nvpn://join-request?pubkey=&name=&relay= const joinUri = user ? `nvpn://join-request?pubkey=${user.pubkey}&name=${encodeURIComponent(metadata?.name ?? 'Skater')}&relay=${encodeURIComponent('wss://goskatespots.nostr1.com')}` : ''; const renderQR = useCallback(async () => { if (!canvasRef.current || !joinUri) return; try { setQrError(false); await QRCode.toCanvas(canvasRef.current, joinUri, { width: 220, margin: 2, color: { dark: '#000000', light: '#ffffff' }, errorCorrectionLevel: 'M', }); setQrReady(true); } catch { setQrError(true); } }, [joinUri]); useEffect(() => { renderQR(); }, [renderQR]); const handleCopy = async () => { if (!joinUri) return; try { await navigator.clipboard.writeText(joinUri); setCopied(true); setTimeout(() => setCopied(false), 2500); toast({ title: 'Copied!', description: 'Join request link copied to clipboard.' }); } catch { toast({ title: 'Copy failed', description: 'Could not access clipboard.', variant: 'destructive' }); } }; // Save the QR canvas as a PNG the user can share. const handleDownloadQR = () => { if (!canvasRef.current || !qrReady) return; const link = document.createElement('a'); link.download = 'nostr-vpn-join-request.png'; link.href = canvasRef.current.toDataURL('image/png'); link.click(); }; // Open the deep link on the device. On iOS Safari, window.location.href inside // a user-gesture handler is the only reliable way to fire custom schemes. // After 2 s with no visibility change, assume the app isn't installed and // redirect to the correct store page. const handleOpenApp = () => { if (!joinUri) return; setOpeningApp(true); const fallbackUrl = platform === 'android' ? NOSTR_VPN_ANDROID : NOSTR_VPN_APP_STORE; const timeout = setTimeout(() => { // Still here → app not installed → go to store/release page window.location.href = fallbackUrl; }, 2000); // Cancel the fallback if the page loses focus (app opened successfully). const cancel = () => { clearTimeout(timeout); setOpeningApp(false); }; window.addEventListener('blur', cancel, { once: true }); document.addEventListener('visibilitychange', () => { if (document.hidden) cancel(); }, { once: true }); window.location.href = joinUri; }; // Steps adapt: if the user is logged out, step 2 says "log in" instead of "show QR". const steps = [ { n: '1', text: 'Install nostr-vpn on your device (free, open source)' }, { n: '2', text: user ? 'Show your QR code above to a crew admin' : 'Log in to SkateSpots to generate your personal QR' }, { n: '3', text: 'Admin approves — you receive a cryptographically signed roster' }, { n: '4', text: 'WireGuard-grade encrypted mesh tunnel connects you to your crew' }, ]; return (
{/* Hero explainer */}

Private Encrypted Mesh

nostr-vpn turns your Nostr keys into a private WireGuard mesh for your crew. No accounts, no central server — your key is your identity. Your crew, your network.

{/* QR + join request */} {user ? (

Your Join Request

{/* QR canvas — fixed container prevents layout shift */}
{/* Loading spinner — shown until QR renders */} {!qrReady && !qrError && (

Generating…

)} {/* Error state */} {qrError && (

QR failed to render

)}

Show this QR to an admin in the nostr-vpn app. They scan → approve → you're on the encrypted mesh.

{/* URI display — improved contrast, selectable */}

Join URI

{joinUri.length > 130 ? joinUri.slice(0, 130) + '…' : joinUri}

{/* Actions row */}
{/* Deep link hint */}

"Open app" requires nostr-vpn installed. If not installed, you'll be redirected to the download page.

) : (
)} {/* How it works — steps adapt based on login state */}

How It Works

    {steps.map(({ n, text }) => (
  1. {text}

  2. ))}
{/* Download links — platform-sorted */}

Get nostr-vpn

{platforms.map(({ id, label, sublabel, icon: Icon, url, color, bg, badge }) => (

{label}

{sublabel}

{badge}
))}
{/* Use cases */}

Why Skaters Use It

    {[ { emoji: '🔒', label: 'Private crew comms' }, { emoji: '📍', label: 'Secret spot sharing' }, { emoji: '🌍', label: 'Censorship bypass' }, { emoji: '📶', label: 'Offline session mesh' }, ].map(({ emoji, label }) => (
  • {label}

  • ))}
{/* Learn more */}

nostr-vpn on GitHub

Source code, docs, protocol spec

); } // ─── Main settings menu ─────────────────────────────────────────────────────── function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) { const { user, metadata } = useCurrentUser(); return (
{/* Account */} onNavigate('profile')} /> onNavigate('security')} /> onNavigate('nip05')} /> {/* App */} onNavigate('wallet')} /> onNavigate('relays')} /> onNavigate('crewnetwork')} /> onNavigate('appearance')} /> {/* Data & Advanced */} onNavigate('mydata')} /> onNavigate('advanced')} /> {/* Login area */} {!user && (
)}
); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function SettingsPage() { useSeoMeta({ title: 'Settings - SkateSpots', description: 'Manage your SkateSpots account settings.', }); const [searchParams] = useSearchParams(); // Allow deep-linking into a specific section via ?section=profile etc. const initialSection = (searchParams.get('section') as Section) ?? null; const [section, setSection] = useState
(initialSection); const handleNavigate = (s: Section) => setSection(s); const handleBack = () => setSection(null); const sectionTitles: Record, string> = { profile: 'Edit Profile', follows: 'Follows', nip05: 'NIP-05 Verification', security: 'Security', mydata: 'My Data', language: 'Language', appearance: 'Appearance', relays: 'Relays', crewnetwork: 'Crew Network', wallet: 'Wallet', stash: 'My Stash', advanced: 'Advanced', }; return (
{/* Header — only shown on the main menu */} {!section && (

SETTINGS

Account and app preferences

)} {/* Sub-screens */} {section === 'profile' && } {section === 'security' && (
)} {section === 'nip05' && } {section === 'mydata' && (
)} {section === 'relays' && } {section === 'crewnetwork' && } {section === 'appearance' && } {section === 'wallet' && } {section === 'advanced' && } {/* Main menu */} {!section && }
); }