import { useSeoMeta } from '@unhead/react';
import { Link, useSearchParams } from 'react-router-dom';
import { EditProfileForm } from '@/components/EditProfileForm';
import { Switch } from '@/components/ui/switch';
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
import { useMapFooterHidden } from '@/hooks/useMapFooterHidden';
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
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 { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
import { GrantProCard } from '@/components/GrantProCard';
import { ProSupportScreen } from '@/components/ProSupportScreen';
import { AdminReviewCard } from '@/components/AdminReviewCard';
import { RegionBackfillCard } from '@/components/RegionBackfillCard';
import { NotificationSettingsCard } from '@/components/NotificationSettingsCard';
import { CoordinatorSettingsCard } from '@/components/CoordinatorSettingsCard';
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, type NostrMetadata } from '@nostrify/nostrify';
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 { SKATE_NIP05_DOMAIN } from '@/lib/skateNip05';
import { useTranslation } from '@/contexts/language';
import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff, Bell, Server, Crown } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
type Section =
| null
| 'profile'
| 'follows'
| 'nip05'
| 'security'
| 'mydata'
| 'language'
| 'appearance'
| 'relays'
| 'wallet'
| 'stash'
| 'notifications'
| 'coordinator'
| 'advanced'
| 'pro';
// ─── Back header ──────────────────────────────────────────────────────────────
function BackHeader({ title, onBack }: { title: string; onBack: () => void }) {
const { t } = useTranslation();
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 {inner} ;
}
// ─── Menu group ───────────────────────────────────────────────────────────────
function MenuGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
);
}
// ─── Profile sub-screen ───────────────────────────────────────────────────────
const PROFILE_FALLBACK_RELAYS = ['wss://purplepag.es', 'wss://relay.damus.io', 'wss://nos.lol'];
/**
* A short "what's missing" nudge. Deliberately only lists genuinely empty
* essentials — a new skater sees it, a finished profile never does — so it can't
* read as nagging.
*/
function ProfileChecklist({ metadata }: { metadata?: NostrMetadata }) {
const { t } = useTranslation();
const items: string[] = [];
if (!metadata?.name && !metadata?.display_name) items.push(t('settingsUi.settingsPage.checkDisplayName'));
if (!metadata?.picture) items.push(t('settingsUi.settingsPage.checkPhoto'));
if (!metadata?.about?.trim()) items.push(t('settingsUi.settingsPage.checkBio'));
if (!metadata?.lud16 && !metadata?.lud06) items.push(t('settingsUi.settingsPage.checkLightning'));
if (!metadata?.nip05) items.push(t('settingsUi.settingsPage.checkHandle'));
if (items.length === 0) return null;
return (
{t('settingsUi.settingsPage.finishProfile')}
{items.map((item) => (
{t('settingsUi.settingsPage.addItem', { item })}
))}
);
}
function ProfileScreen({ onBack }: { onBack: () => void }) {
const { user, metadata, isLoadingProfile } = useCurrentUser();
const { nostr } = useNostr();
const { t } = useTranslation();
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: t('settingsUi.settingsPage.profileRefreshed'), description: t('settingsUi.settingsPage.found', { name: profileMetadata.name || t('settingsUi.settingsPage.unnamed') }) });
} else {
toast({ title: t('settingsUi.settingsPage.profileNotFound'), description: t('settingsUi.settingsPage.notFoundOnRelay'), variant: 'destructive' });
}
} catch {
toast({ title: t('settingsUi.settingsPage.refreshFailed'), description: t('settingsUi.settingsPage.refreshFailedDesc'), variant: 'destructive' });
} finally {
setIsRefreshing(false);
}
};
return (
{!user ? (
{t('settingsUi.settingsPage.loginToEdit')}
) : (
{/* How everyone else sees you (last saved profile). */}
{/* Only warn when we truly have NOTHING — an account with a name but no
bio is not "not loaded", and the checklist below covers that case. */}
{!isLoadingProfile && !metadata && (
{t('settingsUi.settingsPage.noProfileRelays')}
)}
)}
);
}
// ─── NIP-05 sub-screen ────────────────────────────────────────────────────────
function Nip05Screen({ onBack }: { onBack: () => void }) {
const providers = NIP05_PROVIDERS;
const { t } = useTranslation();
return (
{t('settingsUi.settingsPage.nip05Desc')}
{t('settingsUi.settingsPage.freeFromSkateSpots')}
{t('settingsUi.settingsPage.claimHandleBefore')}{' '}
you@{SKATE_NIP05_DOMAIN} {t('settingsUi.settingsPage.claimHandleAfter')}
{t('settingsUi.settingsPage.claimInProfile')}
{t('settingsUi.settingsPage.nip05Footer')}
);
}
// ─── 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();
const { t } = useTranslation();
// 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 ? t('settingsUi.settingsPage.localRelayOn') : t('settingsUi.settingsPage.localRelayOff'),
description: next
? t('settingsUi.settingsPage.lookingForCitrine')
: t('settingsUi.settingsPage.noLongerUsing'),
});
};
return (
{/* Header row */}
{/* Icon tile with live status pip */}
{t('settingsUi.settingsPage.localRelay')}
Citrine
{state === 'off' && t('settingsUi.settingsPage.citrineOff')}
{state === 'searching' && t('settingsUi.settingsPage.citrineSearching')}
{state === 'live' && t('settingsUi.settingsPage.citrineLive')}
{state === 'waiting' && t('settingsUi.settingsPage.citrineWaiting')}
{/* Toggle */}
{/* Expanding detail — only when enabled */}
{enabled && (
{/* Status row + recheck */}
{state === 'live' && t('settingsUi.settingsPage.citrineDetected')}
{state === 'searching' && t('settingsUi.settingsPage.citrineLooking')}
{state === 'waiting' && t('settingsUi.settingsPage.citrineNotDetected')}
citrine.recheck()}
disabled={probing}
aria-label={t('settingsUi.settingsPage.recheckCitrine')}
className={cn(
'ml-auto inline-flex items-center gap-1 rounded-full px-2 py-1 -mr-1 text-muted-foreground',
'hover:text-white hover:bg-white/5 transition-colors disabled:opacity-50',
)}
>
{probing ? t('settingsUi.settingsPage.checking') : t('settingsUi.settingsPage.recheck')}
{/* LIVE: benefit chips — the reward for connecting */}
{state === 'live' && (
{[
{ icon: Zap, label: t('settingsUi.settingsPage.chipInstantReads') },
{ icon: WifiOff, label: t('settingsUi.settingsPage.chipWorksOffline') },
{ icon: Database, label: t('settingsUi.settingsPage.chipOnDeviceBackup') },
].map(({ icon: Icon, label }) => (
{label}
))}
)}
{/* WAITING: install / start CTA */}
{state === 'waiting' && (
{t('settingsUi.settingsPage.cantReachStrong')} {' '}
{t('settingsUi.settingsPage.cantReachBefore')}
{t('settingsUi.settingsPage.started')} {t('settingsUi.settingsPage.cantReachAfter')}
{t('settingsUi.settingsPage.citrineStep1Before')} {t('settingsUi.settingsPage.start')}
{t('settingsUi.settingsPage.citrineStep2Before')} {t('settingsUi.settingsPage.recheck')}
{t('settingsUi.settingsPage.getCitrine')}
)}
{/* Privacy reassurance — always present when enabled */}
{t('settingsUi.settingsPage.citrinePrivacy')}
)}
);
}
// ─── Relays sub-screen ────────────────────────────────────────────────────────
function RelaysScreen({ onBack }: { onBack: () => void }) {
const { user } = useCurrentUser();
const { config, updateConfig } = useAppContext();
const { t } = useTranslation();
const useSkateRelay = config.useSkateRelay ?? true;
const handleToggle = () => {
updateConfig((current) => ({ ...current, useSkateRelay: !useSkateRelay }));
toast({
title: useSkateRelay ? t('settingsUi.settingsPage.communityRelayDisabled') : t('settingsUi.settingsPage.communityRelayEnabled'),
description: useSkateRelay ? t('settingsUi.settingsPage.usingOnlyPersonal') : t('settingsUi.settingsPage.skateRelayIncluded'),
});
};
return (
{/* Privacy note — next to the relay list, where the thought actually occurs.
Deliberately neutral: no VPN brand, just "free is a warning sign". */}
{t('settingsUi.settingsPage.relayPrivacyBefore')} {t('settingsUi.settingsPage.ipAddress')} {t('settingsUi.settingsPage.relayPrivacyAfter')}{' '}
{t('common.learnMore')}
{user && (
{t('settingsUi.settingsPage.communityRelay')}
{useSkateRelay ? t('settingsUi.settingsPage.syncingGlobal') : t('settingsUi.settingsPage.usingOnlyPersonalShort')}
)}
);
}
// ─── Appearance sub-screen ────────────────────────────────────────────────────
function AppearanceScreen({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
const [mapFooterHidden, setMapFooterHidden] = useMapFooterHidden();
return (
{t('settingsUi.settingsPage.language')}
{/* Sasquatch assistant — the floating map bubble can be distracting;
let people turn it off and back on. Device-local. */}
{t('settingsUi.settingsPage.assistant')}
{t('settingsUi.settingsPage.showSasquatch')}
{t('settingsUi.settingsPage.showSasquatchDesc')}
setSasquatchHidden(!v)} aria-label={t('settingsUi.settingsPage.showSasquatch')} />
{t('settingsUi.settingsPage.showMapFooter')}
{t('settingsUi.settingsPage.showMapFooterDesc')}
setMapFooterHidden(!v)} aria-label={t('settingsUi.settingsPage.showMapFooter')} />
🌙
{t('settingsUi.settingsPage.darkModeOnly')}
{t('settingsUi.settingsPage.darkModeDesc')}
{/* 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. */}
{t('settingsUi.settingsPage.navigation')}
);
}
// ─── Advanced sub-screen ──────────────────────────────────────────────────────
function AdvancedScreen({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
const handleResetSasquatch = () => {
localStorage.removeItem('sasquatch-minimized');
alert(t('settingsUi.settingsPage.sasquatchExpanded'));
window.location.reload();
};
return (
{/* Owner-only — render null for everyone except platform owners. */}
{t('settingsUi.settingsPage.sasquatchGuide')}
{t('settingsUi.settingsPage.sasquatchResetDesc')}
{t('settingsUi.settingsPage.expandSasquatch')}
);
}
// ─── Wallet sub-screen ────────────────────────────────────────────────────────
function WalletScreen({ onBack }: { onBack: () => void }) {
const { user } = useCurrentUser();
const { t } = useTranslation();
return (
{!user ? (
{t('settingsUi.settingsPage.loginToWallet')}
) : (
{t('settingsUi.settingsPage.openWallet')}
{t('settingsUi.settingsPage.balanceSendReceive')}
{t('settingsUi.settingsPage.connectNwc')}
Alby, Minibits, Zeus, Coinos
)}
);
}
// ─── Main settings menu ───────────────────────────────────────────────────────
function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
const { user, metadata } = useCurrentUser();
const { t } = useTranslation();
return (
{/* Account */}
onNavigate('profile')}
/>
onNavigate('security')}
/>
onNavigate('nip05')}
/>
{/* App */}
onNavigate('wallet')}
/>
onNavigate('relays')}
/>
onNavigate('appearance')}
/>
onNavigate('pro')}
/>
onNavigate('notifications')}
/>
onNavigate('coordinator')}
/>
{/* Data & Advanced */}
onNavigate('mydata')}
/>
onNavigate('advanced')}
/>
{/* Login area */}
{!user && (
)}
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() {
const { t } = useTranslation();
useSeoMeta({
title: t('settingsUi.settingsPage.seoTitle'),
description: t('settingsUi.settingsPage.seoDescription'),
});
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', 'notifications', 'coordinator', 'advanced', 'pro',
];
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 && (
{t('settingsUi.settingsPage.heading')}
{t('settingsUi.settingsPage.subtitle')}
)}
{/* Sub-screens */}
{section === 'profile' &&
}
{section === 'security' && (
)}
{section === 'nip05' &&
}
{section === 'mydata' && (
)}
{section === 'relays' &&
}
{section === 'appearance' &&
}
{section === 'pro' &&
}
{section === 'wallet' &&
}
{section === 'notifications' && (
)}
{section === 'coordinator' && (
)}
{section === 'advanced' &&
}
{/* Main menu */}
{!section &&
}
>
);
}