SPB Git

Repository Public

App.tsx 13.2 KB 137 lines tsx

1import { useQuery, useQueryClient } from '@tanstack/react-query';2import { lazy, Suspense, useCallback, useEffect, useState } from 'react';3import { get, post, setUnauthHandler } from './api';4import { I18n, useT, type Key, type Locale } from './i18n';5import { Activity } from './screens/Activity';6import { Agent, Agents } from './screens/Agents';7import { Asset } from './screens/Asset';8import { Assets } from './screens/Assets';9import type { FuturesView } from './screens/Futures';10import { Gate } from './screens/Gate';11import { Home } from './screens/Home';12import { Receive } from './screens/Receive';13import { Security } from './screens/Security';14import { Send } from './screens/Send';15import { Settings } from './screens/Settings';16import { Swap } from './screens/Swap';17import { Btn, Icon, Notice, Skeleton } from './ui';18import { useAlertsRuntime, useUnread } from './alerts'; // v6-alerts1920// the Futures terminal is its own chunk: the wallet's first load does not pay for it21const Futures = lazy(() => import('./screens/Futures').then((m) => ({ default: m.Futures })));22// v6-alerts: the Alerts and System status screens are their own chunks too23const Alerts = lazy(() => import('./screens/Alerts').then((m) => ({ default: m.Alerts })));24const Monitor = lazy(() => import('./screens/Monitor').then((m) => ({ default: m.Monitor })));2526const TABS = [['home', '/', 'home'], ['swap', '/swap', 'swap'], ['agents', '/agents', 'key'], ['receive', '/receive', 'down'], ['send', '/send', 'up'], ['activity', '/activity', 'list'], ['security', '/security', 'shield'], ['settings', '/settings', 'gear']] as const;27export type Tab = (typeof TABS)[number][0] | 'asset' | 'assets' | 'agent' | 'futures' | 'alerts' | 'monitor';28/** Where to go, with optional pre-selection (asset page, or send/receive pre-filled). */29export interface Route { tab: Tab; network?: string; asset?: string; /** sub-view of a screen (Futures: compare, accounts) */ view?: string }30export type Nav = (r: Route) => void;31const pathOf = (r: Route) => r.tab === 'alerts' ? `/alerts${r.view === 'settings' ? '?v=settings' : ''}` : r.tab === 'monitor' ? '/monitor' : r.tab === 'futures' ? `/futures${r.asset ? `/${encodeURIComponent(r.asset)}` : ''}${r.view ? `?v=${r.view}` : ''}` : r.tab === 'agent' ? `/agents/${r.asset}` : (r.tab === 'asset' ? `/asset/${r.network}/${encodeURIComponent(r.asset ?? 'native')}` : r.tab === 'assets' ? '/assets' : TABS.find((x) => x[0] === r.tab)![1]) + (r.tab !== 'asset' && r.network ? `?n=${r.network}${r.asset ? `&a=${encodeURIComponent(r.asset)}` : ''}` : '');32function routeOf(): Route {33  const m = /^\/asset\/([a-z0-9-]+)\/(.+)$/.exec(location.pathname);34  if (m) return { tab: 'asset', network: m[1], asset: decodeURIComponent(m[2]!) };35  if (location.pathname === '/assets') return { tab: 'assets' };36  const ag = /^\/agents\/([0-9a-f-]{36})$/.exec(location.pathname);37  if (ag) return { tab: 'agent', asset: ag[1] };38  const q = new URLSearchParams(location.search);39  if (location.pathname === '/alerts') return { tab: 'alerts', view: q.get('v') ?? undefined };   // v6-alerts40  if (location.pathname === '/monitor') return { tab: 'monitor' };41  const fu = /^\/futures(?:\/(.+))?$/.exec(location.pathname);   // /futures, /futures/<venue:symbol>42  if (fu) return { tab: 'futures', asset: fu[1] ? decodeURIComponent(fu[1]) : undefined, view: q.get('v') ?? undefined };43  return { tab: TABS.find((t) => t[1] === location.pathname)?.[0] ?? 'home', network: q.get('n') ?? undefined, asset: q.get('a') ?? undefined };44}4546export function App() {47  const qc = useQueryClient();48  const [locale, setLocaleState] = useState<Locale>(() => (localStorage.getItem('spw-locale') === 'en' ? 'en' : 'fr'));49  const [route, setRoute] = useState<Route>(routeOf);50  const tab = route.tab;51  const state = useQuery({ queryKey: ['auth'], queryFn: () => get<{ setupRequired: boolean; authenticated: boolean }>('/auth/state'), staleTime: 0 });52  const setLocale = useCallback((l: Locale) => { localStorage.setItem('spw-locale', l); document.documentElement.lang = l; setLocaleState(l); }, []);53  const nav = useCallback<Nav>((r) => { history.pushState(null, '', pathOf(r)); setRoute(r); window.scrollTo(0, 0); }, []);54  const go = useCallback((t: Tab) => nav({ tab: t }), [nav]);55  // v6-alerts: open an in-app path (a tapped alert or notification)56  const openPath = useCallback((path: string) => { history.pushState(null, '', path); setRoute(routeOf()); window.scrollTo(0, 0); }, []);57  useEffect(() => { const f = () => setRoute(routeOf()); addEventListener('popstate', f); return () => removeEventListener('popstate', f); }, []);58  useEffect(() => { setUnauthHandler(() => { qc.clear(); void qc.invalidateQueries({ queryKey: ['auth'] }); }); }, [qc]);59  useEffect(() => { document.documentElement.lang = locale; }, [locale]);60  // lock when the app has been in the background for a while (server also enforces a 30-minute idle timeout)61  useEffect(() => { const f = () => { if (document.visibilityState === 'visible') void qc.invalidateQueries({ queryKey: ['auth'] }); }; document.addEventListener('visibilitychange', f); return () => document.removeEventListener('visibilitychange', f); }, [qc]);6263  let body;64  if (state.isLoading) body = <div className="grid h-full place-items-center text-mute"><Icon n="spin" className="h-6 w-6" /></div>;65  else if (state.isError || !state.data) body = <Offline retry={() => void state.refetch()} />;66  else if (!state.data.authenticated) body = <Gate setup={state.data.setupRequired} onDone={() => void state.refetch()} />;67  else body = <Shell tab={tab} route={route} nav={nav} go={go} openPath={openPath} locale={locale} lock={async () => { await post('/auth/logout').catch(() => {}); qc.clear(); void state.refetch(); }} />;68  return <I18n.Provider value={{ locale, setLocale }}>{body}</I18n.Provider>;69}7071function Offline({ retry }: { retry: () => void }) {72  const t = useT();73  return <div className="mx-auto grid h-full max-w-sm place-items-center px-6"><div className="w-full space-y-4"><Notice tone="warn">{t('offline')}</Notice><Btn full kind="soft" onClick={retry}>{t('retry')}</Btn></div></div>;74}7576function Shell({ tab, route, nav, go, lock, openPath, locale }: { tab: Tab; route: Route; nav: Nav; go: (t: Tab) => void; lock: () => void; openPath: (path: string) => void; locale: Locale }) {77  const t = useT();78  const unread = useUnread(); useAlertsRuntime(locale, openPath); // v6-alerts79  const bell = (cls: string) => <button onClick={() => go('alerts')} data-testid="alerts-bell" aria-label={`${t('alBell')}${unread.unread ? ` (${unread.unread})` : ''}`} aria-current={tab === 'alerts' ? 'page' : undefined} className={cls}>80    <span className="relative"><Icon n="bell" />{unread.unread > 0 && <span data-testid="alerts-badge" className={`absolute -right-2 -top-2 grid h-[18px] min-w-[18px] place-items-center rounded-full px-1 text-[10.5px] font-bold leading-none text-white ${unread.critical ? 'bg-danger' : 'bg-blue'}`}>{unread.unread > 99 ? '99+' : unread.unread}</span>}</span>{cls.includes('w-full') && t('alTitle')}</button>;81  const MOBILE: [Tab, string][] = [['home', 'home'], ['swap', 'swap'], ['send', 'up'], ['activity', 'list'], ['settings', 'gear']];82  const trade = tab === 'swap' || tab === 'futures';   // Futures lives inside the trade area: the five phone tabs stay as they are83  const side = (id: Tab, icon: string) => { const on = tab === id || (id === 'swap' && tab === 'futures'); return (84    <button key={id} onClick={() => go(id)} aria-current={on ? 'page' : undefined} className={`flex w-full items-center gap-3 rounded-2xl px-3.5 py-3 text-[15px] font-medium transition ${on ? 'bg-primary text-onprimary' : 'text-mute hover:bg-elev hover:text-ink'}`}><Icon n={icon} />{t(id as Key)}</button>); };85  return (86    <div className={`mx-auto flex min-h-full ${tab === 'futures' ? 'max-w-[1680px]' : 'max-w-6xl'}`}>87      <aside className="sticky top-0 hidden h-screen w-64 shrink-0 flex-col p-5 lg:flex">88        <div className="mb-8 flex items-center gap-3 px-2 pt-2"><img src="/icon.svg" alt="" className="h-9 w-9 rounded-xl" /><span className="text-[19px] font-semibold tracking-tight">SP Wallet</span></div>89        <nav className="space-y-1.5" aria-label="Navigation">{TABS.map(([id, , icon]) => side(id, icon))}</nav>90        <div className="mt-auto space-y-1.5">{/* v6-alerts */}91          {bell(`flex w-full items-center gap-3 rounded-2xl px-3.5 py-3 text-[15px] font-medium transition ${tab === 'alerts' ? 'bg-primary text-onprimary' : 'text-mute hover:bg-elev hover:text-ink'}`)}92          <button onClick={() => go('monitor')} aria-current={tab === 'monitor' ? 'page' : undefined} className={`flex w-full items-center gap-3 rounded-2xl px-3.5 py-3 text-[15px] font-medium transition ${tab === 'monitor' ? 'bg-primary text-onprimary' : 'text-mute hover:bg-elev hover:text-ink'}`}><Icon n="bars" />{t('moTitle')}</button>93          <button onClick={lock} className="flex w-full items-center gap-3 rounded-2xl px-3.5 py-3 text-[15px] font-medium text-mute hover:bg-elev hover:text-ink"><Icon n="lock" />{t('logout')}</button>94        </div>95      </aside>96      <div className="min-w-0 flex-1">97        <header className="safe-t nav-glass sticky top-0 z-10 lg:hidden">98          <div className="flex items-center justify-between px-5 py-3">99            <div className="flex items-center gap-2.5"><img src="/icon.svg" alt="" className="h-8 w-8 rounded-[10px]" /><span className="text-[17px] font-semibold tracking-tight">SP Wallet</span></div>100            <div className="flex items-center gap-1">101              {bell(`grid h-11 w-11 place-items-center rounded-full ${tab === 'alerts' ? 'bg-elev text-ink' : 'text-mute'}`)}102              <button onClick={() => go('security')} aria-label={t('security')} aria-current={tab === 'security' ? 'page' : undefined} className={`grid h-11 w-11 place-items-center rounded-full ${tab === 'security' ? 'bg-elev text-ink' : 'text-mute'}`}><Icon n="shield" /></button>103              <button onClick={lock} aria-label={t('logout')} className="grid h-11 w-11 place-items-center rounded-full text-mute"><Icon n="lock" /></button>104            </div>105          </div>106        </header>107        <main key={tab === 'futures' ? 'futures' : tab + (route.network ?? '') + (route.asset ?? '')} className={`rise mx-auto w-full px-5 pb-32 pt-3 lg:pb-12 ${tab === 'futures' ? 'max-w-none lg:pt-6' : 'max-w-xl lg:max-w-2xl lg:pt-10'}`}>108          {trade && <div role="tablist" aria-label={t('fxTradeArea')} className={`mb-3 flex gap-1 rounded-2xl bg-elev p-1 ${tab === 'futures' ? 'max-w-sm' : ''}`}>{([['swap', 'fxSpot'], ['futures', 'fxFutures']] as const).map(([id, k]) =>109            <button key={id} role="tab" aria-selected={tab === id} onClick={() => { if (tab !== id) go(id); }} className={`min-h-11 flex-1 rounded-xl px-3 text-[14px] font-semibold transition ${tab === id ? 'bg-surface text-ink shadow-[0_1px_2px_rgba(15,23,42,0.12)]' : 'text-mute hover:text-ink'}`}>{t(k)}</button>)}</div>}110          {tab === 'home' && <Home go={go} nav={nav} />}111          {tab === 'assets' && <Assets nav={nav} />}112          {tab === 'asset' && <Asset network={route.network ?? ''} asset={route.asset ?? 'native'} nav={nav} />}113          {tab === 'receive' && <Receive initial={route.network} />}114          {tab === 'send' && <Send go={go} initial={route} />}115          {tab === 'swap' && <Swap />}116          {tab === 'futures' && <Suspense fallback={<Skeleton h="h-72" />}><Futures instrument={route.asset} view={(['compare', 'accounts', 'router'].includes(route.view ?? '') ? route.view : 'terminal') as FuturesView} nav={nav} /></Suspense>}117          {tab === 'agents' && <Agents nav={nav} />}118          {tab === 'agent' && <Agent id={route.asset ?? ''} nav={nav} />}119          {tab === 'activity' && <Activity />}120          {tab === 'security' && <Security />}121          {tab === 'settings' && <Settings lock={lock} go={go} />}122          {tab === 'alerts' && <Suspense fallback={<Skeleton h="h-72" />}><Alerts view={route.view === 'settings' ? 'settings' : 'list'} open={openPath} setView={(v) => nav({ tab: 'alerts', view: v === 'settings' ? 'settings' : undefined })} /></Suspense>}123          {tab === 'monitor' && <Suspense fallback={<Skeleton h="h-72" />}><Monitor open={openPath} /></Suspense>}124        </main>125        <nav aria-label="Navigation" className="safe-b nav-glass fixed inset-x-0 bottom-0 z-10 border-t border-line lg:hidden">126          <div className="mx-auto flex max-w-xl items-end px-2 pt-2">{MOBILE.map(([id, icon]) => { const on = tab === id || (id === 'home' && (tab === 'asset' || tab === 'assets')) || (id === 'swap' && tab === 'futures');127            return id === 'send'128              ? <button key={id} onClick={() => go(id)} aria-current={on ? 'page' : undefined} aria-label={t('send')} className="flex flex-1 flex-col items-center gap-1 text-[11px] font-medium text-ink">129                  <span className="-mt-5 grid h-[52px] w-[52px] place-items-center rounded-full bg-primary text-onprimary shadow-[0_10px_24px_-8px_rgba(11,16,32,0.55)]"><Icon n="up" className="h-6 w-6" /></span><span>{t('send')}</span></button>130              : <button key={id} onClick={() => go(id)} aria-current={on ? 'page' : undefined} className={`flex min-h-[50px] flex-1 flex-col items-center justify-center gap-1 text-[11px] font-medium ${on ? 'text-ink' : 'text-mute'}`}>131                  <Icon n={icon} className="h-6 w-6" /><span>{t(id as Key)}</span></button>; })}</div>132        </nav>133      </div>134    </div>135  );136}137
Files main
Menu

Theme