SkateSpots/src/pages/SettingsPage.tsx
shakespeare.diy dd85644fca UX audit fixes: iOS directions, Me tab profile link, onboarding banner, empty states, stats deltas, newsletter pill
- Fix iOS directions: Apple Maps first on Apple platforms, removed broken geo: URI,
  added Waze option, fixed popover z-index clipping behind Sheet component
- Fix 'Me' bottom nav: now deep-links to /settings?section=profile for logged-in users
  so tapping 'Me' shows your profile, not a generic settings menu
- Add SettingsPage URL param support: ?section=X opens sub-screen directly
- Add onboarding welcome banner: dismissible strip at top of map for logged-out users
  with 'Get Started' CTA pointing to /onboarding, stored in localStorage
- Fix Lab empty state: FAQ link + login prompt for logged-out users
- Fix Events empty state: FAQ link added to all empty state instances
- Fix GearSwap empty state: actionable 'Clear Filters' button + FAQ link
- Community stats: add 'this month' delta badges (+N this month) on all stat cards
- Newsletter pill: hide on map page to eliminate bottom sheet overlap conflict

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-23 21:36:33 +07:00

566 lines
22 KiB
TypeScript

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 { 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 } 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 {
User,
Palette,
Radio,
Wallet,
Settings,
Wrench,
RotateCcw,
Shield,
ExternalLink,
Languages,
RefreshCw,
AlertCircle,
Database,
Package,
ChevronRight,
ChevronLeft,
} 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 (
<div className="flex items-center gap-3 mb-5">
<button
onClick={onBack}
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'];
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 (
<div>
<BackHeader title="Edit Profile" 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">Log in to edit your profile</p>
<LoginArea className="justify-center" />
</div>
) : (
<div className="space-y-4">
{!metadata?.name && (
<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">Profile not loaded from relays.</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>
)}
<EditProfileForm />
</div>
)}
</div>
);
}
// ─── 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 (
<div>
<BackHeader title="NIP-05 Verification" onBack={onBack} />
<div className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Get a verified Nostr address (like name@domain.com) to build trust. Required for creating events, coaching profiles, and shops.
</p>
<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 ? 'Free' : '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">
After getting your NIP-05, add it in the Profile field and save.
</div>
</div>
</div>
);
}
// ─── 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 (
<div>
<BackHeader title="Relays" onBack={onBack} />
<div className="space-y-4">
{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">Community Relay</p>
<p className="text-xs text-muted-foreground mt-0.5">
{useSkateRelay ? 'Syncing with global skate community' : 'Using only personal relays'}
</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>
)}
<RelayListManager />
</div>
</div>
);
}
// ─── Appearance sub-screen ────────────────────────────────────────────────────
function AppearanceScreen({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
return (
<div>
<BackHeader title="Appearance" onBack={onBack} />
<div className="space-y-4">
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">Language</p>
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
<LanguageSelector />
</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">Dark Mode Only</p>
<p className="text-xs text-muted-foreground leading-relaxed">
SkateSpots runs a dark neon aesthetic inspired by skate culture. Black background, neon pink, lime, and orange.
</p>
</div>
</div>
</div>
);
}
// ─── 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 (
<div>
<BackHeader title="Advanced" onBack={onBack} />
<div className="space-y-4">
<RelatrSettings />
<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">🦧 Sasquatch Guide</p>
<p className="text-xs text-muted-foreground mt-0.5">Reset the onboarding guide to expanded view</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" />
Expand The Sasquatch
</button>
</div>
</div>
</div>
</div>
);
}
// ─── Wallet sub-screen ────────────────────────────────────────────────────────
function WalletScreen({ onBack }: { onBack: () => void }) {
const { user } = useCurrentUser();
return (
<div>
<BackHeader title="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">Log in to manage your wallet</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">Open Wallet</p>
<p className="text-xs text-muted-foreground">Balance, send, receive</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">Connect NWC Wallet</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();
return (
<div className="space-y-5">
{/* Account */}
<MenuGroup title="Account">
<MenuRow
icon={User}
label="Profile"
sublabel={metadata?.name ?? (user ? 'Not loaded' : 'Not logged in')}
iconColor="text-[#ccff00]"
iconBg="bg-[#ccff00]/15"
onClick={() => onNavigate('profile')}
/>
<MenuRow
icon={Shield}
label="Security"
sublabel="Keys, signing, Amber"
iconColor="text-red-400"
iconBg="bg-red-500/15"
onClick={() => onNavigate('security')}
/>
<MenuRow
icon={User}
label="NIP-05 Verification"
sublabel="Get a verified Nostr address"
iconColor="text-blue-400"
iconBg="bg-blue-500/15"
onClick={() => onNavigate('nip05')}
/>
</MenuGroup>
{/* App */}
<MenuGroup title="App">
<MenuRow
icon={Wallet}
label="Wallet"
sublabel="Lightning, Cashu, NWC"
iconColor="text-orange-400"
iconBg="bg-orange-500/15"
onClick={() => onNavigate('wallet')}
/>
<MenuRow
icon={Package}
label="My Stash"
sublabel="Private gear inventory"
iconColor="text-purple-400"
iconBg="bg-purple-500/15"
href="/stash"
/>
<MenuRow
icon={Radio}
label="Relays"
sublabel="Nostr relay connections"
iconColor="text-cyan-400"
iconBg="bg-cyan-500/15"
onClick={() => onNavigate('relays')}
/>
<MenuRow
icon={Languages}
label="Appearance"
sublabel="Language and theme"
iconColor="text-pink-400"
iconBg="bg-pink-500/15"
onClick={() => onNavigate('appearance')}
/>
</MenuGroup>
{/* Data & Advanced */}
<MenuGroup title="Data">
<MenuRow
icon={Database}
label="My Data"
sublabel="Export, import, manage"
iconColor="text-green-400"
iconBg="bg-green-500/15"
onClick={() => onNavigate('mydata')}
/>
<MenuRow
icon={Wrench}
label="Advanced"
sublabel="Relatr, Sasquatch, tools"
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() {
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<Section>(initialSection);
const handleNavigate = (s: Section) => setSection(s);
const handleBack = () => setSection(null);
const sectionTitles: Record<NonNullable<Section>, string> = {
profile: 'Edit Profile',
follows: 'Follows',
nip05: 'NIP-05 Verification',
security: 'Security',
mydata: 'My Data',
language: 'Language',
appearance: 'Appearance',
relays: 'Relays',
wallet: 'Wallet',
stash: 'My Stash',
advanced: 'Advanced',
};
return (
<Layout>
<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-3xl font-black uppercase leading-none mb-0.5">
<span className="text-white">SETTINGS</span>
</h1>
<p className="text-muted-foreground text-sm">Account and app preferences</p>
</div>
)}
{/* Sub-screens */}
{section === 'profile' && <ProfileScreen onBack={handleBack} />}
{section === 'security' && (
<div>
<BackHeader title="Security" onBack={handleBack} />
<SecuritySettings />
</div>
)}
{section === 'nip05' && <Nip05Screen onBack={handleBack} />}
{section === 'mydata' && (
<div>
<BackHeader title="My Data" onBack={handleBack} />
<MyDataTab />
</div>
)}
{section === 'relays' && <RelaysScreen onBack={handleBack} />}
{section === 'appearance' && <AppearanceScreen onBack={handleBack} />}
{section === 'wallet' && <WalletScreen onBack={handleBack} />}
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />}
{/* Main menu */}
{!section && <SettingsMenu onNavigate={handleNavigate} />}
</div>
</Layout>
);
}