982 lines
42 KiB
TypeScript
982 lines
42 KiB
TypeScript
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 { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
|
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
|
|
import { RelayListManager } from '@/components/RelayListManager';
|
|
import { RelayDivergenceCard } from '@/components/RelayDivergenceCard';
|
|
|
|
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 { TrustSettingsCard } from '@/components/TrustSettingsCard';
|
|
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 { TranslationSettings } from '@/components/chat/TranslationSettings';
|
|
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'
|
|
| 'trust';
|
|
|
|
// ─── Back header ──────────────────────────────────────────────────────────────
|
|
|
|
function BackHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<button
|
|
onClick={onBack}
|
|
aria-label={t('common.back')}
|
|
className="flex items-center justify-center w-8 h-8 rounded-xl bg-white/5 hover:bg-white/10 active:scale-95 transition-all"
|
|
>
|
|
<ChevronLeft className="h-4 w-4 text-white" />
|
|
</button>
|
|
<h2 className="text-lg font-black uppercase tracking-wide text-white">{title}</h2>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 = (
|
|
<div className="flex items-center gap-3 w-full py-3 px-4 hover:bg-white/5 active:scale-[0.99] transition-all">
|
|
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', iconBg)}>
|
|
<Icon className={cn('h-4 w-4', iconColor)} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-bold text-white leading-tight">{label}</p>
|
|
{sublabel && <p className="text-xs text-muted-foreground leading-tight mt-0.5">{sublabel}</p>}
|
|
</div>
|
|
{badge && (
|
|
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/30 flex-shrink-0">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground/50 flex-shrink-0" />
|
|
</div>
|
|
);
|
|
|
|
if (href) {
|
|
return <Link to={href} className="block">{inner}</Link>;
|
|
}
|
|
|
|
return <button onClick={onClick} className="w-full text-left">{inner}</button>;
|
|
}
|
|
|
|
// ─── Menu group ───────────────────────────────────────────────────────────────
|
|
|
|
function MenuGroup({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-4 mb-1">{title}</p>
|
|
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5 bg-white/2">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div className="rounded-2xl border border-[#ccff00]/25 bg-[#ccff00]/5 p-4">
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.finishProfile')}</p>
|
|
<ul className="mt-2 space-y-1">
|
|
{items.map((item) => (
|
|
<li key={item} className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
<span className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#ccff00]" />
|
|
{t('settingsUi.settingsPage.addItem', { item })}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.editProfile')} onBack={onBack} />
|
|
{!user ? (
|
|
<div className="text-center py-16">
|
|
<User className="h-10 w-10 mx-auto mb-3 text-muted-foreground/40" />
|
|
<p className="text-muted-foreground text-sm mb-5">{t('settingsUi.settingsPage.loginToEdit')}</p>
|
|
<LoginArea className="justify-center" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* How everyone else sees you (last saved profile). */}
|
|
<ProfilePreviewCard />
|
|
|
|
{/* 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 && (
|
|
<div className="flex items-center gap-3 p-3 rounded-2xl bg-yellow-950/30 border border-yellow-500/40">
|
|
<AlertCircle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
|
<p className="text-xs text-yellow-200 flex-1">
|
|
{t('settingsUi.settingsPage.noProfileRelays')}
|
|
</p>
|
|
<Button onClick={handleRefresh} disabled={isRefreshing} size="sm" variant="outline" className="border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 flex-shrink-0 h-8">
|
|
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<ProfileChecklist metadata={metadata} />
|
|
<SkaterDirectoryCard />
|
|
<EditProfileForm />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── NIP-05 sub-screen ────────────────────────────────────────────────────────
|
|
|
|
function Nip05Screen({ onBack }: { onBack: () => void }) {
|
|
const providers = NIP05_PROVIDERS;
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.nip05Title')} onBack={onBack} />
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
{t('settingsUi.settingsPage.nip05Desc')}
|
|
</p>
|
|
<div className="rounded-2xl border border-[#ccff00]/25 bg-[#ccff00]/5 p-4 space-y-2">
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.freeFromSkateSpots')}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settingsUi.settingsPage.claimHandleBefore')}{' '}
|
|
<span className="font-mono text-foreground">you@{SKATE_NIP05_DOMAIN}</span> {t('settingsUi.settingsPage.claimHandleAfter')}
|
|
</p>
|
|
<Link to="/settings?section=profile">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
|
>
|
|
{t('settingsUi.settingsPage.claimInProfile')}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5">
|
|
{providers.map((p) => (
|
|
<a
|
|
key={p.name}
|
|
href={p.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-between px-4 py-3.5 hover:bg-white/5 active:scale-[0.99] transition-all"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-bold text-white">{p.name}</p>
|
|
<p className="text-xs text-muted-foreground">{p.desc}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<span className={cn('text-[10px] font-bold px-2 py-0.5 rounded-full border', p.free ? 'bg-green-500/15 text-green-400 border-green-500/30' : 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30')}>
|
|
{p.free ? t('common.free') : t('settingsUi.nip05Required.paid')}
|
|
</span>
|
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
<div className="p-3 rounded-2xl bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-muted-foreground">
|
|
{t('settingsUi.settingsPage.nip05Footer')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div
|
|
className={cn(
|
|
'rounded-2xl border overflow-hidden transition-all duration-500',
|
|
state === 'live'
|
|
? 'border-[#ccff00]/40 bg-[#ccff00]/[0.05] shadow-[0_0_28px_-8px_rgba(204,255,0,0.35)]'
|
|
: state === 'waiting'
|
|
? 'border-orange-500/25 bg-orange-500/[0.04]'
|
|
: 'border-white/8 bg-white/3',
|
|
)}
|
|
>
|
|
{/* Header row */}
|
|
<div className="flex items-center justify-between gap-3 p-4">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
{/* Icon tile with live status pip */}
|
|
<div className="relative flex-shrink-0">
|
|
<div
|
|
className={cn(
|
|
'w-10 h-10 rounded-xl flex items-center justify-center transition-colors duration-500',
|
|
state === 'live'
|
|
? 'bg-[#ccff00]/15'
|
|
: state === 'waiting'
|
|
? 'bg-orange-500/10'
|
|
: 'bg-white/5',
|
|
)}
|
|
>
|
|
<Smartphone
|
|
className={cn(
|
|
'w-[18px] h-[18px] transition-colors duration-500',
|
|
state === 'live'
|
|
? 'text-[#ccff00]'
|
|
: state === 'waiting'
|
|
? 'text-orange-400'
|
|
: 'text-white/70',
|
|
)}
|
|
/>
|
|
</div>
|
|
{enabled && (
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black motion-reduce:animate-none',
|
|
state === 'live' && 'bg-[#ccff00] animate-status-live',
|
|
state === 'searching' && 'bg-[#ccff00] animate-status-breathe',
|
|
state === 'waiting' && 'bg-orange-500',
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-bold text-white flex items-center gap-2 leading-none">
|
|
{t('settingsUi.settingsPage.localRelay')}
|
|
<span
|
|
className={cn(
|
|
'text-[9px] font-black uppercase tracking-[0.15em] transition-colors',
|
|
state === 'live' ? 'text-[#ccff00]' : 'text-white/40',
|
|
)}
|
|
>
|
|
Citrine
|
|
</span>
|
|
</p>
|
|
<p
|
|
className={cn(
|
|
'text-xs mt-1 transition-colors duration-300',
|
|
state === 'live'
|
|
? 'text-[#ccff00]/90'
|
|
: state === 'waiting'
|
|
? 'text-orange-300/80'
|
|
: 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{state === 'off' && t('settingsUi.settingsPage.citrineOff')}
|
|
{state === 'searching' && t('settingsUi.settingsPage.citrineSearching')}
|
|
{state === 'live' && t('settingsUi.settingsPage.citrineLive')}
|
|
{state === 'waiting' && t('settingsUi.settingsPage.citrineWaiting')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toggle */}
|
|
<button
|
|
onClick={handleToggle}
|
|
className={cn(
|
|
'relative w-11 h-6 rounded-full transition-all duration-300 flex-shrink-0 active:scale-95',
|
|
enabled ? 'bg-[#ccff00]' : 'bg-white/10',
|
|
)}
|
|
role="switch"
|
|
aria-checked={enabled}
|
|
aria-label={t('settingsUi.settingsPage.toggleCitrine')}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'absolute top-0.5 w-5 h-5 rounded-full bg-black transition-all duration-300 ease-out',
|
|
enabled ? 'left-[calc(100%-22px)]' : 'left-0.5',
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Expanding detail — only when enabled */}
|
|
{enabled && (
|
|
<div className="px-4 pb-4 space-y-3 animate-in fade-in slide-in-from-top-1 duration-300 motion-reduce:animate-none">
|
|
{/* Status row + recheck */}
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'inline-flex w-2 h-2 rounded-full motion-reduce:animate-none',
|
|
state === 'live' && 'bg-[#ccff00] animate-status-live',
|
|
state === 'searching' && 'bg-[#ccff00] animate-status-breathe',
|
|
state === 'waiting' && 'bg-orange-500',
|
|
)}
|
|
/>
|
|
<span
|
|
role="status"
|
|
aria-live="polite"
|
|
className={cn(
|
|
'font-medium',
|
|
state === 'live'
|
|
? 'text-[#ccff00]'
|
|
: state === 'searching'
|
|
? 'text-[#ccff00]/70'
|
|
: 'text-orange-400',
|
|
)}
|
|
>
|
|
{state === 'live' && t('settingsUi.settingsPage.citrineDetected')}
|
|
{state === 'searching' && t('settingsUi.settingsPage.citrineLooking')}
|
|
{state === 'waiting' && t('settingsUi.settingsPage.citrineNotDetected')}
|
|
</span>
|
|
<button
|
|
onClick={() => 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',
|
|
)}
|
|
>
|
|
<RefreshCw className={cn('w-3 h-3 motion-reduce:animate-none', probing && 'animate-spin')} />
|
|
{probing ? t('settingsUi.settingsPage.checking') : t('settingsUi.settingsPage.recheck')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* LIVE: benefit chips — the reward for connecting */}
|
|
{state === 'live' && (
|
|
<div className="flex flex-wrap gap-1.5 animate-pop-in motion-reduce:animate-none">
|
|
{[
|
|
{ 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 }) => (
|
|
<span
|
|
key={label}
|
|
className="inline-flex items-center gap-1 rounded-full bg-[#ccff00]/12 border border-[#ccff00]/25 px-2.5 py-1 text-[10px] font-bold text-[#ccff00]"
|
|
>
|
|
<Icon className="w-3 h-3" />
|
|
{label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* WAITING: install / start CTA */}
|
|
{state === 'waiting' && (
|
|
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.06] p-3 space-y-2.5">
|
|
<p className="text-[11px] leading-relaxed text-orange-100/90">
|
|
<strong className="text-orange-300">{t('settingsUi.settingsPage.cantReachStrong')}</strong>{' '}
|
|
{t('settingsUi.settingsPage.cantReachBefore')}
|
|
<em> {t('settingsUi.settingsPage.started')}</em>{t('settingsUi.settingsPage.cantReachAfter')}
|
|
</p>
|
|
<ol className="text-[11px] leading-relaxed text-orange-100/70 list-decimal list-inside space-y-0.5">
|
|
<li>{t('settingsUi.settingsPage.citrineStep1Before')} <strong className="text-orange-200">{t('settingsUi.settingsPage.start')}</strong></li>
|
|
<li>{t('settingsUi.settingsPage.citrineStep2Before')} <strong className="text-orange-200">{t('settingsUi.settingsPage.recheck')}</strong></li>
|
|
</ol>
|
|
<a
|
|
href="https://github.com/greenart7c3/Citrine"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 rounded-full bg-[#ccff00] px-3 py-1.5 text-[11px] font-black uppercase tracking-wide text-black hover:bg-[#ccff00]/90 active:scale-95 transition-all"
|
|
>
|
|
<Download className="w-3.5 h-3.5" /> {t('settingsUi.settingsPage.getCitrine')}
|
|
<ExternalLink className="w-2.5 h-2.5" />
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* Privacy reassurance — always present when enabled */}
|
|
<div className="flex items-start gap-2 rounded-xl bg-black/20 border border-white/5 p-2.5">
|
|
<Shield className="w-3.5 h-3.5 text-white/40 flex-shrink-0 mt-0.5" />
|
|
<p className="text-[10px] leading-relaxed text-muted-foreground/70">
|
|
{t('settingsUi.settingsPage.citrinePrivacy')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.relays')} onBack={onBack} />
|
|
<div className="space-y-4">
|
|
{/* Privacy note — next to the relay list, where the thought actually occurs.
|
|
Deliberately neutral: no VPN brand, just "free is a warning sign". */}
|
|
<div className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/3 p-4">
|
|
<Shield className="h-4 w-4 shrink-0 mt-0.5 text-[#ccff00]" />
|
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
{t('settingsUi.settingsPage.relayPrivacyBefore')} <strong className="text-white">{t('settingsUi.settingsPage.ipAddress')}</strong> {t('settingsUi.settingsPage.relayPrivacyAfter')}{' '}
|
|
<Link to="/privacy" className="text-[#ccff00]/80 underline hover:text-[#ccff00]">
|
|
{t('common.learnMore')}
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
{user && (
|
|
<div className="flex items-center justify-between p-4 rounded-2xl bg-white/3 border border-white/8">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.communityRelay')}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{useSkateRelay ? t('settingsUi.settingsPage.syncingGlobal') : t('settingsUi.settingsPage.usingOnlyPersonalShort')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleToggle}
|
|
className={cn(
|
|
'relative w-11 h-6 rounded-full transition-all flex-shrink-0',
|
|
useSkateRelay ? 'bg-[#ccff00]' : 'bg-white/10'
|
|
)}
|
|
>
|
|
<span className={cn(
|
|
'absolute top-0.5 w-5 h-5 rounded-full bg-black transition-all',
|
|
useSkateRelay ? 'left-[calc(100%-22px)]' : 'left-0.5'
|
|
)} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<CitrineCard />
|
|
<RelayListManager />
|
|
<RelayDivergenceCard />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Appearance sub-screen ────────────────────────────────────────────────────
|
|
|
|
function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
|
const { t } = useTranslation();
|
|
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
|
|
return (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
|
|
<div className="space-y-4">
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">{t('settingsUi.settingsPage.language')}</p>
|
|
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
|
|
<LanguageSelector />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">{t('translateUi.title')}</p>
|
|
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
|
|
<TranslationSettings />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sasquatch assistant — the floating map bubble can be distracting;
|
|
let people turn it off and back on. Device-local. */}
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">{t('settingsUi.settingsPage.assistant')}</p>
|
|
<div className="rounded-2xl border border-white/8 p-4 bg-white/2 flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.showSasquatch')}</p>
|
|
<p className="text-xs text-muted-foreground">{t('settingsUi.settingsPage.showSasquatchDesc')}</p>
|
|
</div>
|
|
<Switch checked={!sasquatchHidden} onCheckedChange={(v) => setSasquatchHidden(!v)} aria-label={t('settingsUi.settingsPage.showSasquatch')} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">
|
|
<div className="text-3xl mb-3">🌙</div>
|
|
<p className="font-black text-white uppercase tracking-wide text-sm mb-2">{t('settingsUi.settingsPage.darkModeOnly')}</p>
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{t('settingsUi.settingsPage.darkModeDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<div className="hidden md:block">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">{t('settingsUi.settingsPage.navigation')}</p>
|
|
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
|
|
<DesktopNavManager />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.advanced')} onBack={onBack} />
|
|
<div className="space-y-4">
|
|
<RelatrSettings />
|
|
{/* Owner-only — render null for everyone except platform owners. */}
|
|
<CVMHealthCheck />
|
|
<SasquatchAiStatusCard />
|
|
<GrantProCard />
|
|
<AdminReviewCard />
|
|
<RegionBackfillCard />
|
|
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
|
|
<div className="px-4 py-3.5 border-b border-white/5">
|
|
<p className="text-sm font-bold text-amber-400">{t('settingsUi.settingsPage.sasquatchGuide')}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">{t('settingsUi.settingsPage.sasquatchResetDesc')}</p>
|
|
</div>
|
|
<div className="px-4 py-3.5">
|
|
<button
|
|
onClick={handleResetSasquatch}
|
|
className="flex items-center gap-2 text-sm font-bold text-amber-400 hover:text-amber-300 transition-colors"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
{t('settingsUi.settingsPage.expandSasquatch')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Wallet sub-screen ────────────────────────────────────────────────────────
|
|
|
|
function WalletScreen({ onBack }: { onBack: () => void }) {
|
|
const { user } = useCurrentUser();
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.wallet')} onBack={onBack} />
|
|
{!user ? (
|
|
<div className="text-center py-16">
|
|
<Wallet className="h-10 w-10 mx-auto mb-3 text-muted-foreground/40" />
|
|
<p className="text-muted-foreground text-sm mb-5">{t('settingsUi.settingsPage.loginToWallet')}</p>
|
|
<LoginArea className="justify-center" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<Link to="/wallet" className="flex items-center justify-between p-4 rounded-2xl bg-orange-500/10 border border-orange-500/20 hover:bg-orange-500/15 active:scale-[0.99] transition-all">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
|
<Wallet className="h-4 w-4 text-orange-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.openWallet')}</p>
|
|
<p className="text-xs text-muted-foreground">{t('settingsUi.settingsPage.balanceSendReceive')}</p>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground/50" />
|
|
</Link>
|
|
|
|
<WalletModal>
|
|
<button className="w-full flex items-center justify-between p-4 rounded-2xl bg-purple-500/10 border border-purple-500/20 hover:bg-purple-500/15 active:scale-[0.99] transition-all">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
|
<ExternalLink className="h-4 w-4 text-purple-400" />
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.connectNwc')}</p>
|
|
<p className="text-xs text-muted-foreground">Alby, Minibits, Zeus, Coinos</p>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground/50" />
|
|
</button>
|
|
</WalletModal>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main settings menu ───────────────────────────────────────────────────────
|
|
|
|
function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
|
|
const { user, metadata } = useCurrentUser();
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Account */}
|
|
<MenuGroup title={t('settingsUi.settingsPage.account')}>
|
|
<MenuRow
|
|
icon={User}
|
|
label={t('settingsUi.settingsPage.editProfile')}
|
|
sublabel={metadata?.name ?? (user ? t('settingsUi.settingsPage.notLoaded') : t('settingsUi.settingsPage.notLoggedIn'))}
|
|
iconColor="text-[#ccff00]"
|
|
iconBg="bg-[#ccff00]/15"
|
|
onClick={() => onNavigate('profile')}
|
|
/>
|
|
<MenuRow
|
|
icon={Shield}
|
|
label={t('settingsUi.settingsPage.security')}
|
|
sublabel={t('settingsUi.settingsPage.securitySub')}
|
|
iconColor="text-red-400"
|
|
iconBg="bg-red-500/15"
|
|
onClick={() => onNavigate('security')}
|
|
/>
|
|
<MenuRow
|
|
icon={User}
|
|
label={t('settingsUi.settingsPage.nip05Title')}
|
|
sublabel={t('settingsUi.settingsPage.nip05Sub')}
|
|
iconColor="text-blue-400"
|
|
iconBg="bg-blue-500/15"
|
|
onClick={() => onNavigate('nip05')}
|
|
/>
|
|
</MenuGroup>
|
|
|
|
{/* App */}
|
|
<MenuGroup title={t('settingsUi.settingsPage.app')}>
|
|
<MenuRow
|
|
icon={Wallet}
|
|
label={t('settingsUi.settingsPage.wallet')}
|
|
sublabel="Lightning, Cashu, NWC"
|
|
iconColor="text-orange-400"
|
|
iconBg="bg-orange-500/15"
|
|
onClick={() => onNavigate('wallet')}
|
|
/>
|
|
<MenuRow
|
|
icon={Package}
|
|
label={t('settingsUi.settingsPage.myStash')}
|
|
sublabel={t('settingsUi.settingsPage.myStashSub')}
|
|
iconColor="text-purple-400"
|
|
iconBg="bg-purple-500/15"
|
|
href="/stash"
|
|
/>
|
|
<MenuRow
|
|
icon={Radio}
|
|
label={t('settingsUi.settingsPage.relays')}
|
|
sublabel={t('settingsUi.settingsPage.relaysSub')}
|
|
iconColor="text-cyan-400"
|
|
iconBg="bg-cyan-500/15"
|
|
onClick={() => onNavigate('relays')}
|
|
/>
|
|
<MenuRow
|
|
icon={Languages}
|
|
label={t('settingsUi.settingsPage.appearance')}
|
|
sublabel={t('settingsUi.settingsPage.appearanceSub')}
|
|
iconColor="text-pink-400"
|
|
iconBg="bg-pink-500/15"
|
|
onClick={() => onNavigate('appearance')}
|
|
/>
|
|
<MenuRow
|
|
icon={Crown}
|
|
label={t('settingsUi.proSupport.title')}
|
|
sublabel={t('settingsUi.proSupport.subtitle')}
|
|
iconColor="text-[#ccff00]"
|
|
iconBg="bg-[#ccff00]/15"
|
|
onClick={() => onNavigate('pro')}
|
|
/>
|
|
<MenuRow
|
|
icon={Bell}
|
|
label={t('settingsUi.settingsPage.notifications')}
|
|
sublabel={t('settingsUi.settingsPage.notificationsSub')}
|
|
iconColor="text-yellow-400"
|
|
iconBg="bg-yellow-500/15"
|
|
onClick={() => onNavigate('notifications')}
|
|
/>
|
|
<MenuRow
|
|
icon={Server}
|
|
label={t('settingsUi.settingsPage.coordinator')}
|
|
sublabel={t('settingsUi.settingsPage.coordinatorSub')}
|
|
iconColor="text-cyan-400"
|
|
iconBg="bg-cyan-500/15"
|
|
onClick={() => onNavigate('coordinator')}
|
|
/>
|
|
</MenuGroup>
|
|
|
|
{/* Data & Advanced */}
|
|
<MenuGroup title={t('settingsUi.settingsPage.data')}>
|
|
<MenuRow
|
|
icon={Database}
|
|
label={t('settingsUi.settingsPage.myData')}
|
|
sublabel={t('settingsUi.settingsPage.myDataSub')}
|
|
iconColor="text-green-400"
|
|
iconBg="bg-green-500/15"
|
|
onClick={() => onNavigate('mydata')}
|
|
/>
|
|
<MenuRow
|
|
icon={Shield}
|
|
label={t('settingsUi.settingsPage.trust')}
|
|
sublabel={t('settingsUi.settingsPage.trustSub')}
|
|
iconColor="text-[#13d2e5]"
|
|
iconBg="bg-[#13d2e5]/15"
|
|
onClick={() => onNavigate('trust')}
|
|
/>
|
|
<MenuRow
|
|
icon={Wrench}
|
|
label={t('settingsUi.settingsPage.advanced')}
|
|
sublabel={t('settingsUi.settingsPage.advancedSub')}
|
|
iconColor="text-gray-400"
|
|
iconBg="bg-white/8"
|
|
onClick={() => onNavigate('advanced')}
|
|
/>
|
|
</MenuGroup>
|
|
|
|
{/* Login area */}
|
|
{!user && (
|
|
<div className="pt-2">
|
|
<LoginArea className="w-full" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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', 'trust',
|
|
];
|
|
const requestedSection = searchParams.get('section') as Section;
|
|
const initialSection =
|
|
requestedSection && RENDERABLE_SECTIONS.includes(requestedSection) ? requestedSection : null;
|
|
const [section, setSection] = useState<Section>(initialSection);
|
|
|
|
const handleNavigate = (s: Section) => setSection(s);
|
|
const handleBack = () => setSection(null);
|
|
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-5 max-w-lg mx-auto">
|
|
{/* Header — only shown on the main menu */}
|
|
{!section && (
|
|
<div className="mb-5">
|
|
<h1 className="text-4xl md:text-5xl font-black uppercase leading-none mb-1">
|
|
<span className="text-white">{t('settingsUi.settingsPage.heading')}</span>
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm">{t('settingsUi.settingsPage.subtitle')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sub-screens */}
|
|
{section === 'profile' && <ProfileScreen onBack={handleBack} />}
|
|
{section === 'security' && (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.security')} onBack={handleBack} />
|
|
<SecuritySettings />
|
|
</div>
|
|
)}
|
|
{section === 'nip05' && <Nip05Screen onBack={handleBack} />}
|
|
{section === 'mydata' && (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.myData')} onBack={handleBack} />
|
|
<MyDataTab />
|
|
</div>
|
|
)}
|
|
{section === 'relays' && <RelaysScreen onBack={handleBack} />}
|
|
{section === 'appearance' && <AppearanceScreen onBack={handleBack} />}
|
|
{section === 'pro' && <ProSupportScreen onBack={handleBack} />}
|
|
{section === 'wallet' && <WalletScreen onBack={handleBack} />}
|
|
{section === 'notifications' && (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.notifications')} onBack={handleBack} />
|
|
<NotificationSettingsCard />
|
|
</div>
|
|
)}
|
|
{section === 'coordinator' && (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.coordinator')} onBack={handleBack} />
|
|
<CoordinatorSettingsCard />
|
|
</div>
|
|
)}
|
|
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />}
|
|
{section === 'trust' && (
|
|
<div>
|
|
<BackHeader title={t('settingsUi.settingsPage.trust')} onBack={handleBack} />
|
|
<TrustSettingsCard />
|
|
</div>
|
|
)}
|
|
|
|
{/* Main menu */}
|
|
{!section && <SettingsMenu onNavigate={handleNavigate} />}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|