import { useSeoMeta } from '@unhead/react'; import { Link, useSearchParams } from 'react-router-dom'; import { EditProfileForm } from '@/components/EditProfileForm'; import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard'; import { RelayListManager } from '@/components/RelayListManager'; import { WalletModal } from '@/components/WalletModal'; import { SecuritySettings } from '@/components/SecuritySettings'; import { RelatrSettings } from '@/components/RelatrSettings'; import { CVMHealthCheck } from '@/components/CVMHealthCheck'; import { LanguageSelector } from '@/components/LanguageToggle'; import { DesktopNavManager } from '@/components/DesktopNavManager'; import { MyDataTab } from '@/components/MyDataTab'; import { useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { useState } 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 { useCitrine } from '@/hooks/useCitrine'; import { toast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import { NIP05_PROVIDERS } from '@/lib/nip05Providers'; import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff } from 'lucide-react'; // ─── Types ──────────────────────────────────────────────────────────────────── type Section = | null | 'profile' | 'follows' | 'nip05' | 'security' | 'mydata' | 'language' | 'appearance' | 'relays' | '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

) : (
{/* Quick link to the user's own public profile page */} View my public profile {!metadata?.name && (

Profile not loaded from relays.

)}
)}
); } // ─── NIP-05 sub-screen ──────────────────────────────────────────────────────── function Nip05Screen({ onBack }: { onBack: () => void }) { const providers = NIP05_PROVIDERS; 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.
); } // ─── Local relay (Citrine) card ─────────────────────────────────────────────── /** * Android-only opt-in for the local on-device Citrine relay. Renders nothing on * non-Android platforms (citrine.possible === false). When enabled, the local * relay is folded into skate-content reads (instant local hits) and writes * (on-device backup + offline capture) via useSkateRelays. * * UX states: * off — muted, invites the user to speed things up. * searching — enabled, probe in flight: breathing lime dot. * live — enabled + reachable: glowing lime, benefit chips, celebratory. * waiting — enabled + not reachable: orange, actionable install CTA. */ function CitrineCard() { const citrine = useCitrine(); // Not on native Android → the local relay can't exist. Hide entirely. if (!citrine.possible) return null; // Normal computed state. In dev preview (citrine.preview set), a forced state // overrides it purely for visual review — real relay behaviour is unaffected. const computedState: 'off' | 'searching' | 'live' | 'waiting' = !citrine.enabled ? 'off' : citrine.reachable ? 'live' : citrine.probing || !citrine.settled ? 'searching' : 'waiting'; const state = citrine.preview ?? computedState; // Treat any non-off forced preview state as "enabled" so the expanded detail // (chips / CTA / status) renders for review. const enabled = citrine.preview ? citrine.preview !== 'off' : citrine.enabled; // Spinner reflects a real probe, or a forced "searching" preview state. const probing = citrine.probing || state === 'searching'; const handleToggle = () => { // In dev preview we don't mutate the real setting — the forced state owns // the visuals. Toggling is a no-op for review (avoids a misleading toast and // flipping the underlying config while inspecting the UI). if (citrine.preview) return; const next = !citrine.enabled; citrine.setEnabled(next); toast({ title: next ? 'Local relay on' : 'Local relay off', description: next ? 'Looking for Citrine on this device…' : 'No longer using the on-device relay.', }); }; return (
{/* Header row */}
{/* Icon tile with live status pip */}
{enabled && (

Local Relay Citrine

{state === 'off' && 'Faster reads & offline backup on this device'} {state === 'searching' && 'Searching for Citrine on this device…'} {state === 'live' && 'Connected — reading & backing up locally'} {state === 'waiting' && 'Enabled — start Citrine to connect'}

{/* Toggle */}
{/* Expanding detail — only when enabled */} {enabled && (
{/* Status row + recheck */}
{/* LIVE: benefit chips — the reward for connecting */} {state === 'live' && (
{[ { icon: Zap, label: 'Instant reads' }, { icon: WifiOff, label: 'Works offline' }, { icon: Database, label: 'On-device backup' }, ].map(({ icon: Icon, label }) => ( {label} ))}
)} {/* WAITING: install / start CTA */} {state === 'waiting' && (

Can't reach Citrine.{' '} Make sure the free Citrine app is installed and its relay is started, then tap Recheck. It runs a private Nostr relay on your phone for instant reads and an offline backup.

  1. Open Citrine → tap Start
  2. Come back here → tap Recheck
Get Citrine
)} {/* Privacy reassurance — always present when enabled */}

Your Stash and messages stay end-to-end encrypted here, exactly like everywhere else. The local relay only speeds things up and keeps a copy on your device — it never replaces encryption.

)}
); } // ─── 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 }) { 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 (
{/* Owner-only — renders null for everyone except platform owners. */}

🦧 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

)}
); } // ─── 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('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. // Only sections that actually render are accepted; anything else (e.g. a // stale bookmark to ?section=language) falls back to the menu rather than a // blank screen with no way back. const RENDERABLE_SECTIONS: Section[] = [ 'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'advanced', ]; const requestedSection = searchParams.get('section') as Section; const initialSection = requestedSection && RENDERABLE_SECTIONS.includes(requestedSection) ? requestedSection : null; const [section, setSection] = useState
(initialSection); const handleNavigate = (s: Section) => setSection(s); const handleBack = () => setSection(null); 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 === 'appearance' && } {section === 'wallet' && } {section === 'advanced' && } {/* Main menu */} {!section && }
); }