From e1b5a5d833c3dc4d676b16377ad3bd4a7e9eab5c Mon Sep 17 00:00:00 2001 From: deanokk Date: Mon, 13 Apr 2026 23:44:14 +0800 Subject: [PATCH] refactor: replace window.location with useNavigate for routing in auth components (#1470) Co-authored-by: Dean --- web/src/App.tsx | 714 +----------------- web/src/components/auth/LoginPage.tsx | 33 +- .../components/auth/LoginRequiredOverlay.tsx | 53 +- web/src/components/auth/RegisterPage.tsx | 93 ++- .../auth/RegistrationDisabled.test.tsx | 32 +- .../components/auth/RegistrationDisabled.tsx | 5 +- web/src/components/auth/ResetPasswordPage.tsx | 10 +- web/src/components/common/HeaderBar.tsx | 278 +++++-- web/src/components/common/SiteFooter.tsx | 103 +++ .../components/common/WhitelistFullPage.tsx | 26 +- web/src/components/landing/HeroSection.tsx | 35 +- web/src/components/landing/LoginModal.tsx | 5 +- web/src/components/landing/core/AgentGrid.tsx | 287 +++---- web/src/components/trader/AITradersPage.tsx | 287 ++++--- web/src/contexts/AuthContext.tsx | 30 +- web/src/pages/BeginnerOnboardingPage.tsx | 72 +- web/src/pages/LandingPage.tsx | 16 - web/src/pages/SettingsPage.tsx | 215 ++++-- web/src/pages/StrategyMarketPage.tsx | 255 +++++-- web/src/router/AppRoutes.tsx | 539 +++++++++++++ web/src/router/paths.test.ts | 32 + web/src/router/paths.ts | 83 ++ 22 files changed, 1930 insertions(+), 1273 deletions(-) create mode 100644 web/src/components/common/SiteFooter.tsx create mode 100644 web/src/router/AppRoutes.tsx create mode 100644 web/src/router/paths.test.ts create mode 100644 web/src/router/paths.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index c547726d..538c7d30 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,718 +1,14 @@ -import { useEffect, useState } from 'react' -import { motion, AnimatePresence } from 'framer-motion' -import useSWR from 'swr' -import { api } from './lib/api' -import { TraderDashboardPage } from './pages/TraderDashboardPage' - -import { AITradersPage } from './components/trader/AITradersPage' -import { LoginPage } from './components/auth/LoginPage' -import { SetupPage } from './components/modals/SetupPage' -import { SettingsPage } from './pages/SettingsPage' -import { ResetPasswordPage } from './components/auth/ResetPasswordPage' -import { CompetitionPage } from './components/trader/CompetitionPage' -import { LandingPage } from './pages/LandingPage' -import { FAQPage } from './pages/FAQPage' -import { StrategyStudioPage } from './pages/StrategyStudioPage' -import { StrategyMarketPage } from './pages/StrategyMarketPage' -import { DataPage } from './pages/DataPage' -import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage' -import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay' -import HeaderBar from './components/common/HeaderBar' -import { LanguageProvider, useLanguage } from './contexts/LanguageContext' -import { AuthProvider, useAuth } from './contexts/AuthContext' import { ConfirmDialogProvider } from './components/common/ConfirmDialog' -import { t } from './i18n/translations' -import { useSystemConfig } from './hooks/useSystemConfig' -import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding' +import { AuthProvider } from './contexts/AuthContext' +import { LanguageProvider } from './contexts/LanguageContext' +import { AppRoutes } from './router/AppRoutes' -import { OFFICIAL_LINKS } from './constants/branding' -import type { - SystemStatus, - AccountInfo, - Position, - DecisionRecord, - Statistics, - TraderInfo, - Exchange, -} from './types' - -type Page = - | 'competition' - | 'traders' - | 'trader' - | 'strategy' - | 'strategy-market' - | 'data' - | 'faq' - | 'login' - | 'register' - - - -function App() { - const { language, setLanguage } = useLanguage() - const { user, token, logout, isLoading } = useAuth() - const { config: systemConfig, loading: configLoading } = useSystemConfig() - const [route, setRoute] = useState(window.location.pathname) - - // 从URL路径读取初始页面状态(支持刷新保持页面) - const getInitialPage = (): Page => { - const path = window.location.pathname - const hash = window.location.hash.slice(1) // 去掉 # - - if (path === '/welcome') return 'traders' - if (path === '/traders' || hash === 'traders') return 'traders' - if (path === '/strategy' || hash === 'strategy') return 'strategy' - if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market' - if (path === '/data' || hash === 'data') return 'data' - if (path === '/dashboard' || hash === 'trader' || hash === 'details') - return 'trader' - return 'competition' // 默认为竞赛页面 - } - - // Login required overlay state - const [loginOverlayOpen, setLoginOverlayOpen] = useState(false) - const [loginOverlayFeature, setLoginOverlayFeature] = useState('') - - const handleLoginRequired = (featureName: string) => { - setLoginOverlayFeature(featureName) - setLoginOverlayOpen(true) - } - - // Unified page navigation handler - const navigateToPage = (page: Page) => { - const pathMap: Record = { - 'competition': '/competition', - 'strategy-market': '/strategy-market', - 'data': '/data', - 'traders': '/traders', - 'trader': '/dashboard', - 'strategy': '/strategy', - 'faq': '/faq', - 'login': '/login', - 'register': '/register', - } - const path = pathMap[page] - if (path) { - window.history.pushState({}, '', path) - setRoute(path) - setCurrentPage(page) - } - } - - const [currentPage, setCurrentPage] = useState(getInitialPage()) - // 从 URL 参数读取初始 trader 标识(格式: name-id前4位) - const [selectedTraderSlug, setSelectedTraderSlug] = useState(() => { - const params = new URLSearchParams(window.location.search) - return params.get('trader') || undefined - }) - const [selectedTraderId, setSelectedTraderId] = useState() - - // 生成 trader URL slug(name + ID 前 4 位) - const getTraderSlug = (trader: TraderInfo) => { - const idPrefix = trader.trader_id.slice(0, 4) - return `${trader.trader_name}-${idPrefix}` - } - - // 从 slug 解析并匹配 trader - const findTraderBySlug = (slug: string, traderList: TraderInfo[]) => { - // slug 格式: name-xxxx (xxxx 是 ID 前 4 位) - const lastDashIndex = slug.lastIndexOf('-') - if (lastDashIndex === -1) { - // 没有 dash,直接按 name 匹配 - return traderList.find(t => t.trader_name === slug) - } - const name = slug.slice(0, lastDashIndex) - const idPrefix = slug.slice(lastDashIndex + 1) - return traderList.find(t => - t.trader_name === name && t.trader_id.startsWith(idPrefix) - ) - } - const [lastUpdate, setLastUpdate] = useState('--:--:--') - const [decisionsLimit, setDecisionsLimit] = useState(5) - const hasPersistedAuth = - !!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user') - - // Poll-off states: stop polling after 3 consecutive failures - const [accountPollOff, setAccountPollOff] = useState(false) - const [positionsPollOff, setPositionsPollOff] = useState(false) - const [decisionsPollOff, setDecisionsPollOff] = useState(false) - - // Reset poll-off states when trader changes - useEffect(() => { - setAccountPollOff(false) - setPositionsPollOff(false) - setDecisionsPollOff(false) - }, [selectedTraderId]) - - // 监听URL变化,同步页面状态 - useEffect(() => { - const handleRouteChange = () => { - const path = window.location.pathname - const hash = window.location.hash.slice(1) - const params = new URLSearchParams(window.location.search) - const traderParam = params.get('trader') - - if (path === '/welcome') { - setCurrentPage('traders') - } else if (path === '/traders' || hash === 'traders') { - setCurrentPage('traders') - } else if (path === '/strategy' || hash === 'strategy') { - setCurrentPage('strategy') - } else if (path === '/strategy-market' || hash === 'strategy-market') { - setCurrentPage('strategy-market') - } else if (path === '/data' || hash === 'data') { - setCurrentPage('data') - } else if ( - path === '/dashboard' || - hash === 'trader' || - hash === 'details' - ) { - setCurrentPage('trader') - // 如果 URL 中有 trader 参数(slug 格式),更新选中的 trader - setSelectedTraderSlug(traderParam || undefined) - } else if ( - path === '/competition' || - hash === 'competition' || - hash === '' - ) { - setCurrentPage('competition') - } - setRoute(path) - } - - window.addEventListener('hashchange', handleRouteChange) - window.addEventListener('popstate', handleRouteChange) - return () => { - window.removeEventListener('hashchange', handleRouteChange) - window.removeEventListener('popstate', handleRouteChange) - } - }, []) - - // 切换页面时更新URL hash (当前通过按钮直接调用setCurrentPage,这个函数暂时保留用于未来扩展) - // const navigateToPage = (page: Page) => { - // setCurrentPage(page); - // window.location.hash = page === 'competition' ? '' : 'trader'; - // }; - - // 获取trader列表(仅在用户登录时) - const { data: traders, error: tradersError } = useSWR( - user && token ? 'traders' : null, - () => api.getTraders(currentPage === 'trader'), - { - refreshInterval: 10000, - shouldRetryOnError: false, // 避免在后端未运行时无限重试 - } - ) - - // 获取exchanges列表(用于显示交易所名称) - const { data: exchanges } = useSWR( - user && token ? 'exchanges' : null, - api.getExchangeConfigs, - { - refreshInterval: 60000, // 1分钟刷新一次 - shouldRetryOnError: false, - } - ) - - // 当获取到traders后,根据 URL 中的 trader slug 设置选中的 trader,或默认选中第一个 - useEffect(() => { - if (!traders || traders.length === 0) { - return - } - - if (selectedTraderSlug) { - // 通过 slug 找到对应的 trader - const trader = findTraderBySlug(selectedTraderSlug, traders) - const nextTraderId = trader?.trader_id || traders[0].trader_id - if (nextTraderId !== selectedTraderId) { - setSelectedTraderId(nextTraderId) - } - return - } - - if (!selectedTraderId) { - setSelectedTraderId(traders[0].trader_id) - } - }, [traders, selectedTraderId, selectedTraderSlug]) - - // 如果在trader页面,获取该trader的数据 - const { data: status } = useSWR( - currentPage === 'trader' && selectedTraderId - ? `status-${selectedTraderId}` - : null, - () => api.getStatus(selectedTraderId, true), - { - refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存) - revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求 - dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求 - } - ) - - const { data: account } = useSWR( - currentPage === 'trader' && selectedTraderId - ? `account-${selectedTraderId}` - : null, - () => api.getAccount(selectedTraderId, true), - { - refreshInterval: accountPollOff ? 0 : 15000, - revalidateOnFocus: false, - dedupingInterval: 10000, - onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { - if (retryCount >= 2) { setAccountPollOff(true); return } - setTimeout(() => revalidate({ retryCount }), 500) - }, - onSuccess: () => { if (accountPollOff) setAccountPollOff(false) }, - } - ) - - const { data: positions } = useSWR( - currentPage === 'trader' && selectedTraderId - ? `positions-${selectedTraderId}` - : null, - () => api.getPositions(selectedTraderId, true), - { - refreshInterval: positionsPollOff ? 0 : 15000, - revalidateOnFocus: false, - dedupingInterval: 10000, - onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { - if (retryCount >= 2) { setPositionsPollOff(true); return } - setTimeout(() => revalidate({ retryCount }), 500) - }, - onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) }, - } - ) - - const { data: decisions } = useSWR( - currentPage === 'trader' && selectedTraderId - ? `decisions/latest-${selectedTraderId}-${decisionsLimit}` - : null, - () => api.getLatestDecisions(selectedTraderId, decisionsLimit, true), - { - refreshInterval: decisionsPollOff ? 0 : 30000, - revalidateOnFocus: false, - dedupingInterval: 20000, - onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { - if (retryCount >= 2) { setDecisionsPollOff(true); return } - setTimeout(() => revalidate({ retryCount }), 500) - }, - onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) }, - } - ) - - const { data: stats } = useSWR( - currentPage === 'trader' && selectedTraderId - ? `statistics-${selectedTraderId}` - : null, - () => api.getStatistics(selectedTraderId, true), - { - refreshInterval: 30000, // 30秒刷新(统计数据更新频率较低) - revalidateOnFocus: false, - dedupingInterval: 20000, - } - ) - - useEffect(() => { - if (account) { - const now = new Date().toLocaleTimeString() - setLastUpdate(now) - } - }, [account]) - - const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId) - - const effectiveAccount = account - const effectivePositions = positions - const effectiveDecisions = decisions - - // Handle routing - useEffect(() => { - const handlePopState = () => { - setRoute(window.location.pathname) - } - window.addEventListener('popstate', handlePopState) - return () => window.removeEventListener('popstate', handlePopState) - }, []) - - // Set current page based on route for consistent navigation state - useEffect(() => { - if (route === '/welcome') { - setCurrentPage('traders') - } else if (route === '/competition') { - setCurrentPage('competition') - } else if (route === '/traders') { - setCurrentPage('traders') - } else if (route === '/dashboard') { - setCurrentPage('trader') - } - }, [route]) - - const showBeginnerOnboarding = - route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding() - - // Show loading spinner while checking auth or config - if (isLoading || configLoading) { - return ( -
-
- NoFx Logo -

{t('loading', language)}

-
-
- ) - } - - // First-time setup: redirect to /setup if system not initialized - if (systemConfig && !systemConfig.initialized && !user) { - return - } - - // Handle specific routes regardless of authentication - if (route === '/login') { - return - } - if (route === '/setup') { - // If already initialized, redirect to login - if (systemConfig?.initialized) { - window.location.href = '/login' - return null - } - return - } - if (route === '/welcome') { - if ((!user || !token) && !hasPersistedAuth) { - window.location.href = '/login' - return null - } - if (getUserMode() !== 'beginner') { - window.location.href = '/traders' - return null - } - } - if (route === '/faq') { - return ( -
- - - setLoginOverlayOpen(false)} - featureName={loginOverlayFeature} - /> -
- ) - } - if (route === '/reset-password') { - return - } - if (route === '/settings') { - if ((!user || !token) && !hasPersistedAuth) { - window.location.href = '/login' - return null - } - return ( -
- - -
- ) - } - // Data page - publicly accessible with embedded dashboard - if (route === '/data') { - const dataPageNavigate = (page: Page) => { - navigateToPage(page) - } - return ( -
- -
- -
- setLoginOverlayOpen(false)} - featureName={loginOverlayFeature} - /> -
- ) - } - // Show landing page for root route - if (route === '/' || route === '') { - return - } - - // Redirect unauthenticated users to landing page - if (!user || !token) { - return - } - - return ( -
- - - {/* Main Content with Page Transitions */} -
- - - {currentPage === 'competition' ? ( - - ) : currentPage === 'data' ? ( - - ) : currentPage === 'strategy-market' ? ( - - ) : currentPage === 'traders' ? ( - { - setSelectedTraderId(traderId) - const trader = traders?.find((item) => item.trader_id === traderId) - const url = new URL(window.location.href) - url.pathname = '/dashboard' - if (trader) { - const slug = getTraderSlug(trader) - url.searchParams.set('trader', slug) - setSelectedTraderSlug(slug) - } else { - url.searchParams.delete('trader') - setSelectedTraderSlug(undefined) - } - window.history.pushState({}, '', url.toString()) - setRoute('/dashboard') - setCurrentPage('trader') - }} - /> - ) : currentPage === 'strategy' ? ( - - ) : ( - { - setSelectedTraderId(traderId) - // 更新 URL 参数(使用 slug: name-id前4位) - const trader = traders?.find(t => t.trader_id === traderId) - if (trader) { - const slug = getTraderSlug(trader) - setSelectedTraderSlug(slug) - const url = new URL(window.location.href) - url.searchParams.set('trader', slug) - window.history.replaceState({}, '', url.toString()) - } - }} - onNavigateToTraders={() => { - window.history.pushState({}, '', '/traders') - setRoute('/traders') - setCurrentPage('traders') - }} - exchanges={exchanges} - /> - )} - - -
- - {/* Footer */} - - - {/* Login Required Overlay */} - setLoginOverlayOpen(false)} - featureName={loginOverlayFeature} - /> - - {showBeginnerOnboarding && } -
- ) -} - - -// Wrap App with providers -export default function AppWithProviders() { +export default function App() { return ( - + diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index b9ff9097..f5a37689 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react' import { Eye, EyeOff } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' import { useAuth } from '../../contexts/AuthContext' import { useLanguage } from '../../contexts/LanguageContext' @@ -13,12 +14,15 @@ import { invalidateSystemConfig } from '../../lib/config' export function LoginPage() { const { language } = useLanguage() const { login } = useAuth() + const navigate = useNavigate() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(false) - const [expiredToastId, setExpiredToastId] = useState(null) + const [expiredToastId, setExpiredToastId] = useState( + null + ) const [mode, setMode] = useState('beginner') // Clean up stale auth state once on mount @@ -31,7 +35,9 @@ export function LoginPage() { // Show session-expired toast (re-runs on language change to update text) useEffect(() => { if (sessionStorage.getItem('from401') === 'true') { - const id = toast.warning(t('sessionExpired', language), { duration: Infinity }) + const id = toast.warning(t('sessionExpired', language), { + duration: Infinity, + }) setExpiredToastId(id) sessionStorage.removeItem('from401') } @@ -48,7 +54,9 @@ export function LoginPage() { sessionStorage.removeItem('from401') invalidateSystemConfig() toast.success(t('forgotAccountSuccess', language)) - setTimeout(() => { window.location.href = '/setup' }, 1500) + setTimeout(() => { + navigate('/setup') + }, 1500) } else { const data = await res.json() toast.error(data.error || 'Reset failed') @@ -79,23 +87,27 @@ export function LoginPage() {
- {/* Logo + Title */}
- NOFX + NOFX
-

Welcome back

+

+ Welcome back +

Sign in to your account

{/* Card */}
- {/* Email */}
@@ -178,7 +192,6 @@ export function LoginPage() {
-
diff --git a/web/src/components/auth/LoginRequiredOverlay.tsx b/web/src/components/auth/LoginRequiredOverlay.tsx index 46f51fd7..c55179b6 100644 --- a/web/src/components/auth/LoginRequiredOverlay.tsx +++ b/web/src/components/auth/LoginRequiredOverlay.tsx @@ -1,5 +1,6 @@ import { motion, AnimatePresence } from 'framer-motion' import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react' +import { Link } from 'react-router-dom' import { DeepVoidBackground } from '../common/DeepVoidBackground' import { useLanguage } from '../../contexts/LanguageContext' import { t } from '../../i18n/translations' @@ -10,7 +11,11 @@ interface LoginRequiredOverlayProps { featureName?: string } -export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequiredOverlayProps) { +export function LoginRequiredOverlay({ + isOpen, + onClose, + featureName, +}: LoginRequiredOverlayProps) { const { language } = useLanguage() const tr = (key: string, params?: Record) => @@ -20,11 +25,7 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ ? tr('subtitleWithFeature', { featureName }) : tr('subtitleDefault') - const benefits = [ - tr('benefit1'), - tr('benefit2'), - tr('benefit4'), - ] + const benefits = [tr('benefit1'), tr('benefit2'), tr('benefit4')] return ( @@ -40,7 +41,6 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ disableAnimation onClick={onClose} > -
- auth_protocol.exe + + auth_protocol.exe +
- {tr('accessDenied')} + + {tr('accessDenied')} +
@@ -83,8 +87,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ {/* Terminal Text */}
-

{tr('title')}

-

{subtitle}

+

+ {tr('title')} +

+

+ {subtitle} +

@@ -96,7 +104,10 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
{benefits.map((benefit, i) => ( -
+
{benefit}
))} @@ -105,22 +116,24 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ {/* Action Buttons */}
@@ -131,14 +144,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ [ {tr('abort')} ]
-
{/* Corner Accents */}
- diff --git a/web/src/components/auth/RegisterPage.tsx b/web/src/components/auth/RegisterPage.tsx index e03af180..7ea20a5c 100644 --- a/web/src/components/auth/RegisterPage.tsx +++ b/web/src/components/auth/RegisterPage.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react' import { Eye, EyeOff } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import PasswordChecklist from 'react-password-checklist' import { toast } from 'sonner' import { useAuth } from '../../contexts/AuthContext' @@ -13,6 +14,7 @@ import { WhitelistFullPage } from '../common/WhitelistFullPage' export function RegisterPage() { const { language } = useLanguage() const { register } = useAuth() + const navigate = useNavigate() const [view, setView] = useState<'register' | 'whitelist-full'>('register') const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -61,7 +63,11 @@ export function RegisterPage() { setLoading(true) try { - const result = await register(email, password, betaCode.trim() || undefined) + const result = await register( + email, + password, + betaCode.trim() || undefined + ) const isWhitelistError = (msg: string) => { const lowerMsg = msg.toLowerCase() @@ -86,7 +92,10 @@ export function RegisterPage() { // success path is handled in AuthContext (auto login + navigation) } catch (e) { console.error('Registration error:', e) - const errorMsg = e instanceof Error ? e.message : 'Registration failed due to server error' + const errorMsg = + e instanceof Error + ? e.message + : 'Registration failed due to server error' const lowerMsg = errorMsg.toLowerCase() if ( lowerMsg.includes('whitelist') || @@ -106,15 +115,20 @@ export function RegisterPage() { } return ( - +
@@ -122,7 +136,11 @@ export function RegisterPage() {
- NoFx Logo + NoFx Logo

@@ -140,7 +158,7 @@ export function RegisterPage() {
(window.location.href = '/')} + onClick={() => navigate('/')} title="Close / Return Home" >
@@ -155,7 +173,9 @@ export function RegisterPage() {
- System Check: READY + + System Check: READY +
@@ -165,7 +185,9 @@ export function RegisterPage() {
- +
- +
- +
@@ -227,7 +259,14 @@ export function RegisterPage() {
- + setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())} + onChange={(e) => + setBetaCode( + e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase() + ) + } className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono tracking-widest" placeholder="XXXXXX" maxLength={6} required={betaMode} /> -

* CASE SENSITIVE ALPHANUMERIC

+

+ * CASE SENSITIVE ALPHANUMERIC +

)} @@ -270,7 +317,9 @@ export function RegisterPage() { @@ -295,14 +346,14 @@ export function RegisterPage() {

EXISTING_OPERATOR?{' '}

@@ -164,7 +204,12 @@ export default function HeaderBar({ className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-white hover:bg-white/5" title="GitHub" > - + @@ -176,7 +221,12 @@ export default function HeaderBar({ className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#1DA1F2] hover:bg-[#1DA1F2]/10" title="Twitter" > - + @@ -188,7 +238,12 @@ export default function HeaderBar({ className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#0088cc] hover:bg-[#0088cc]/10" title="Telegram" > - + @@ -227,7 +282,7 @@ export default function HeaderBar({
{onLogout && (
) )} @@ -361,17 +425,67 @@ export default function HeaderBar({ {/* Navigation Links */}
{(() => { - const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [ - { page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false }, - { page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true }, - { page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true }, - { page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true }, - { page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true }, - { page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true }, - { page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false }, + const navTabs: { + page: Page + path: string + label: string + requiresAuth: boolean + }[] = [ + { + page: 'data', + path: ROUTES.data, + label: + language === 'zh' + ? '数据' + : language === 'id' + ? 'Data' + : 'Data', + requiresAuth: false, + }, + { + page: 'strategy-market', + path: ROUTES.strategyMarket, + label: + language === 'zh' + ? '策略市场' + : language === 'id' + ? 'Pasar' + : 'Market', + requiresAuth: true, + }, + { + page: 'traders', + path: ROUTES.traders, + label: t('configNav', language), + requiresAuth: true, + }, + { + page: 'trader', + path: ROUTES.dashboard, + label: t('dashboardNav', language), + requiresAuth: true, + }, + { + page: 'strategy', + path: ROUTES.strategy, + label: t('strategyNav', language), + requiresAuth: true, + }, + { + page: 'competition', + path: ROUTES.competition, + label: t('realtimeNav', language), + requiresAuth: true, + }, + { + page: 'faq', + path: ROUTES.faq, + label: t('faqNav', language), + requiresAuth: false, + }, ] - const handleMobileNavClick = (tab: typeof navTabs[0]) => { + const handleMobileNavClick = (tab: (typeof navTabs)[0]) => { if (tab.requiresAuth && !isLoggedIn) { onLoginRequired?.(tab.label) setMobileMenuOpen(false) @@ -380,7 +494,7 @@ export default function HeaderBar({ if (onPageChange) { onPageChange(tab.page) } - navigate(tab.path) + navigateInApp(tab.path) setMobileMenuOpen(false) } @@ -392,9 +506,9 @@ export default function HeaderBar({ transition={{ delay: 0.1 + i * 0.05 }} onClick={() => handleMobileNavClick(tab)} className={`text-2xl font-black tracking-tight text-left flex items-center gap-3 - ${currentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`} + ${resolvedCurrentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`} > - {currentPage === tab.page && ( + {resolvedCurrentPage === tab.page && ( {[ - { href: OFFICIAL_LINKS.github, icon: }, - { href: OFFICIAL_LINKS.twitter, icon: }, - { href: OFFICIAL_LINKS.telegram, icon: } + { + href: OFFICIAL_LINKS.github, + icon: ( + + ), + }, + { + href: OFFICIAL_LINKS.twitter, + icon: ( + + ), + }, + { + href: OFFICIAL_LINKS.telegram, + icon: ( + + ), + }, ].map((link, i) => ( - + {link.icon} @@ -467,10 +601,11 @@ export default function HeaderBar({ onLanguageChange?.(lang as Language) setMobileMenuOpen(false) }} - className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${language === lang - ? 'bg-zinc-800 text-white shadow-sm' - : 'text-zinc-500' - }`} + className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${ + language === lang + ? 'bg-zinc-800 text-white shadow-sm' + : 'text-zinc-500' + }`} > {lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'} @@ -489,13 +624,18 @@ export default function HeaderBar({ {t('exitLogin', language)} ) : ( - currentPage !== 'login' && currentPage !== 'register' && ( - { + navigateInApp(ROUTES.login) + setMobileMenuOpen(false) + }} className="flex items-center justify-center bg-nofx-gold text-black rounded-lg font-bold text-sm hover:bg-yellow-400 transition-colors" > {t('signIn', language)} - + ) )}
diff --git a/web/src/components/common/SiteFooter.tsx b/web/src/components/common/SiteFooter.tsx new file mode 100644 index 00000000..6ee10322 --- /dev/null +++ b/web/src/components/common/SiteFooter.tsx @@ -0,0 +1,103 @@ +import { OFFICIAL_LINKS } from '../../constants/branding' +import { t, type Language } from '../../i18n/translations' + +interface SiteFooterProps { + language: Language +} + +export function SiteFooter({ language }: SiteFooterProps) { + return ( + + ) +} diff --git a/web/src/components/common/WhitelistFullPage.tsx b/web/src/components/common/WhitelistFullPage.tsx index d18593fa..a0bd8eaa 100644 --- a/web/src/components/common/WhitelistFullPage.tsx +++ b/web/src/components/common/WhitelistFullPage.tsx @@ -1,5 +1,6 @@ import { motion } from 'framer-motion' import { ShieldAlert, ArrowLeft, Twitter, Send, Lock } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { OFFICIAL_LINKS } from '../../constants/branding' interface WhitelistFullPageProps { @@ -7,11 +8,13 @@ interface WhitelistFullPageProps { } export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) { + const navigate = useNavigate() + const handleBackToLogin = () => { if (onBack) { onBack() } else { - window.location.href = '/login' + navigate('/login') } } @@ -29,7 +32,6 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) { className="max-w-lg w-full relative z-10" >
- {/* Top Bar */}
@@ -60,9 +62,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) { {/* Description */}

- [SYSTEM_MESSAGE]: YOUR IDENTIFIER IS NOT ON THE ACTIVE WHITELIST. -

- Platform capacity limits have been reached for the current beta phase. Prioritized access is currently reserved for authorized operators only. + [SYSTEM_MESSAGE]: YOUR + IDENTIFIER IS NOT ON THE ACTIVE WHITELIST. +
+
+ Platform capacity limits have been reached for the current beta + phase. Prioritized access is currently reserved for authorized + operators only.

{/* Info Box */} @@ -70,9 +76,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
-

Authorization Protocol

+

+ Authorization Protocol +

- Access is rolled out in batches. If you believe this is an error, please verify your credentials or contact system administrators. + Access is rolled out in batches. If you believe this is an + error, please verify your credentials or contact system + administrators.

@@ -109,14 +119,12 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
-
{/* Footer */}
ERR_CODE: WLIST_0x403 // SECURITY_LAYER_ACTIVE
-
diff --git a/web/src/components/landing/HeroSection.tsx b/web/src/components/landing/HeroSection.tsx index a8fca091..74581576 100644 --- a/web/src/components/landing/HeroSection.tsx +++ b/web/src/components/landing/HeroSection.tsx @@ -1,5 +1,6 @@ import { motion } from 'framer-motion' import { ArrowRight, Play, Github, Zap } from 'lucide-react' +import { Link } from 'react-router-dom' import { t, Language } from '../../i18n/translations' import { useGitHubStats } from '../../hooks/useGitHubStats' import { useCounterAnimation } from '../../hooks/useCounterAnimation' @@ -33,7 +34,8 @@ export default function HeroSection({ language }: HeroSectionProps) {
{/* Floating Orbs */} @@ -138,8 +140,7 @@ export default function HeroSection({ language }: HeroSectionProps) { transition={{ duration: 0.6, delay: 0.3 }} className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12" > - - - {t('liveCompetition', language) || 'Live Competition'} - - + + + {t('liveCompetition', language) || 'Live Competition'} + + + {[ { label: 'GitHub Stars', value: `${(stars / 1000).toFixed(1)}K+` }, - { label: language === 'zh' ? '支持交易所' : 'Exchanges', value: '5+' }, - { label: language === 'zh' ? 'AI 模型' : 'AI Models', value: '10+' }, - { label: language === 'zh' ? '开源免费' : 'Open Source', value: '100%' }, + { + label: language === 'zh' ? '支持交易所' : 'Exchanges', + value: '5+', + }, + { + label: language === 'zh' ? 'AI 模型' : 'AI Models', + value: '10+', + }, + { + label: language === 'zh' ? '开源免费' : 'Open Source', + value: '100%', + }, ].map((stat, index) => ( void @@ -7,6 +8,7 @@ interface LoginModalProps { } export default function LoginModal({ onClose, language }: LoginModalProps) { + const navigate = useNavigate() return ( { - window.history.pushState({}, '', '/login') - window.dispatchEvent(new PopStateEvent('popstate')) + navigate('/login') onClose() }} className="block w-full px-6 py-3 rounded-lg font-semibold text-center" diff --git a/web/src/components/landing/core/AgentGrid.tsx b/web/src/components/landing/core/AgentGrid.tsx index 668be660..10f62c03 100644 --- a/web/src/components/landing/core/AgentGrid.tsx +++ b/web/src/components/landing/core/AgentGrid.tsx @@ -1,149 +1,178 @@ import { motion } from 'framer-motion' import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { useAuth } from '../../../contexts/AuthContext' const agents = [ - { - name: "ALPHA-1", - // ... (rest of agents array remains, but I can't skip lines in replacement content easily without context. Wait, let's just replace the top section) - // Actually, I'll use multi_replace for targeted cleanup. - class: "SCALPER", - desc: "High-frequency microstructure exploitation.", - apy: "142%", - winRate: "68%", - risk: "HIGH", - color: "text-nofx-gold", - border: "border-nofx-gold/50", - bg_glow: "shadow-[0_0_30px_rgba(240,185,11,0.1)]", - icon: Zap - }, - { - name: "BETA-X", - class: "SWING_OPS", - desc: "Multi-day trend extraction engine.", - apy: "89%", - winRate: "55%", - risk: "MED", - color: "text-blue-400", - border: "border-blue-400/30", - bg_glow: "shadow-[0_0_30px_rgba(96,165,250,0.1)]", - icon: TrendingUp - }, - { - name: "GAMMA-RAY", - class: "ARBITRAGE", - desc: "Low-risk spatial price equalization.", - apy: "24%", - winRate: "99%", - risk: "LOW", - color: "text-purple-400", - border: "border-purple-400/30", - bg_glow: "shadow-[0_0_30px_rgba(192,132,252,0.1)]", - icon: Layers - }, + { + name: 'ALPHA-1', + // ... (rest of agents array remains, but I can't skip lines in replacement content easily without context. Wait, let's just replace the top section) + // Actually, I'll use multi_replace for targeted cleanup. + class: 'SCALPER', + desc: 'High-frequency microstructure exploitation.', + apy: '142%', + winRate: '68%', + risk: 'HIGH', + color: 'text-nofx-gold', + border: 'border-nofx-gold/50', + bg_glow: 'shadow-[0_0_30px_rgba(240,185,11,0.1)]', + icon: Zap, + }, + { + name: 'BETA-X', + class: 'SWING_OPS', + desc: 'Multi-day trend extraction engine.', + apy: '89%', + winRate: '55%', + risk: 'MED', + color: 'text-blue-400', + border: 'border-blue-400/30', + bg_glow: 'shadow-[0_0_30px_rgba(96,165,250,0.1)]', + icon: TrendingUp, + }, + { + name: 'GAMMA-RAY', + class: 'ARBITRAGE', + desc: 'Low-risk spatial price equalization.', + apy: '24%', + winRate: '99%', + risk: 'LOW', + color: 'text-purple-400', + border: 'border-purple-400/30', + bg_glow: 'shadow-[0_0_30px_rgba(192,132,252,0.1)]', + icon: Layers, + }, ] export default function AgentGrid() { - const { user } = useAuth() + const { user } = useAuth() + const navigate = useNavigate() - const handleInitialize = () => { - if (user) { - window.location.href = '/strategy-market' - } else { - window.location.href = '/login' - } + const handleInitialize = () => { + if (user) { + navigate('/strategy-market') + } else { + navigate('/login') } + } - return ( -
+ return ( +
+ {/* Background Details */} +
+ +
- {/* Background Details */} -
- +
+
+
+
+ MARKET SELECT
+

+ STRATEGY{' '} + + UNITS + +

+
+
+ SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE + PRE-TRAINED ON HISTORICAL TICKS. +
+
-
+ {/* Grid Container - Removing scroll tracking for stability test */} +
+ {agents.map((agent, i) => { + const Icon = agent.icon -
-
-
- MARKET SELECT -
-

- STRATEGY UNITS -

+ return ( + + {/* Top "Hinge" decoration */} +
+ +
+ {/* Header */} +
+
+
-
- SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE PRE-TRAINED ON HISTORICAL TICKS. +
+
+ Class +
+
+ {agent.class} +
+
+ + {/* Name & Desc */} +

+ {agent.name} +

+

+ {agent.desc} +

+ + {/* Stats Grid */} +
+
+
+ APY +
+
+ {agent.apy} +
+
+
+
+ Win % +
+
+ {agent.winRate} +
+
+
+
+ Risk +
+
+ {agent.risk} +
+
+
+ + {/* Action Btn */} +
- {/* Grid Container - Removing scroll tracking for stability test */} -
- {agents.map((agent, i) => { - const Icon = agent.icon - - return ( - - {/* Top "Hinge" decoration */} -
- -
- {/* Header */} -
-
- -
-
-
Class
-
{agent.class}
-
-
- - {/* Name & Desc */} -

{agent.name}

-

{agent.desc}

- - {/* Stats Grid */} -
-
-
APY
-
{agent.apy}
-
-
-
Win %
-
{agent.winRate}
-
-
-
Risk
-
{agent.risk}
-
-
- - {/* Action Btn */} - -
- - {/* Decorative Background Elements */} -
-
- -
- ) - })} -
-
-
- ) + {/* Decorative Background Elements */} +
+
+ + ) + })} +
+
+ + ) } diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index 7160ccf8..cd0bc908 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -20,12 +20,7 @@ import { ModelConfigModal } from './ModelConfigModal' import { ConfigStatusGrid } from './ConfigStatusGrid' import { TradersList } from './TradersList' import { BeginnerGuideCards } from './BeginnerGuideCards' -import { - AlertTriangle, - Bot, - Plus, - MessageCircle, -} from 'lucide-react' +import { AlertTriangle, Bot, Plus, MessageCircle } from 'lucide-react' import { confirmToast } from '../../lib/notify' import { toast } from 'sonner' import { @@ -55,11 +50,17 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const [allModels, setAllModels] = useState([]) const [allExchanges, setAllExchanges] = useState([]) const [supportedModels, setSupportedModels] = useState([]) - const [visibleTraderAddresses, setVisibleTraderAddresses] = useState>(new Set()) - const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState>(new Set()) + const [visibleTraderAddresses, setVisibleTraderAddresses] = useState< + Set + >(new Set()) + const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState< + Set + >(new Set()) const [copiedId, setCopiedId] = useState(null) const [quickSetupLoading, setQuickSetupLoading] = useState(false) - const [beginnerWalletAddress, setBeginnerWalletAddress] = useState(() => getBeginnerWalletAddress()) + const [beginnerWalletAddress, setBeginnerWalletAddress] = useState< + string | null + >(() => getBeginnerWalletAddress()) const isBeginnerMode = getUserMode() === 'beginner' const getErrorMessage = (error: unknown, fallback: string) => { if (error instanceof Error && error.message.trim() !== '') { @@ -74,54 +75,98 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { ) => { const traderName = params.trader_name || params.traderName || 'this trader' const modelName = params.model_name || params.modelName || 'selected model' - const exchangeName = params.exchange_name || params.exchangeName || 'selected exchange account' - const reason = localizeTraderReason(params.reason_key, params.reason || fallback) + const exchangeName = + params.exchange_name || params.exchangeName || 'selected exchange account' + const reason = localizeTraderReason( + params.reason_key, + params.reason || fallback + ) const symbol = params.symbol || '' const zh = language === 'zh' switch (errorKey) { case 'trader.create.invalid_request': - return zh ? '提交的信息不完整,或者格式不正确。请检查后重新提交。' : 'The submitted information is incomplete or invalid. Please review it and try again.' + return zh + ? '提交的信息不完整,或者格式不正确。请检查后重新提交。' + : 'The submitted information is incomplete or invalid. Please review it and try again.' case 'trader.create.invalid_btc_eth_leverage': - return zh ? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。' : 'BTC/ETH leverage must be between 1x and 50x.' + return zh + ? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。' + : 'BTC/ETH leverage must be between 1x and 50x.' case 'trader.create.invalid_altcoin_leverage': - return zh ? '山寨币杠杆倍数需要在 1 到 20 倍之间。' : 'Altcoin leverage must be between 1x and 20x.' + return zh + ? '山寨币杠杆倍数需要在 1 到 20 倍之间。' + : 'Altcoin leverage must be between 1x and 20x.' case 'trader.create.invalid_symbol': - return zh ? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。` : `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.` + return zh + ? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。` + : `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.` case 'trader.create.model_not_found': - return zh ? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。' : 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.' + return zh + ? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。' + : 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.' case 'trader.create.model_disabled': - return zh ? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。` : `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.` + return zh + ? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。` + : `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.` case 'trader.create.model_missing_credentials': - return zh ? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。` : `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.` + return zh + ? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。` + : `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.` case 'trader.create.strategy_required': - return zh ? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。' : 'No trading strategy is selected yet. Please choose a strategy before creating a trader.' + return zh + ? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。' + : 'No trading strategy is selected yet. Please choose a strategy before creating a trader.' case 'trader.create.strategy_not_found': - return zh ? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。' : 'The selected strategy no longer exists. Please choose another available strategy.' + return zh + ? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。' + : 'The selected strategy no longer exists. Please choose another available strategy.' case 'trader.create.exchange_not_found': - return zh ? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。' : 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.' + return zh + ? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。' + : 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.' case 'trader.create.exchange_disabled': - return zh ? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。` : `Exchange account "${exchangeName}" is currently disabled. Please enable it first.` + return zh + ? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。` + : `Exchange account "${exchangeName}" is currently disabled. Please enable it first.` case 'trader.create.exchange_missing_fields': - return zh ? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。` : `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.` + return zh + ? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。` + : `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.` case 'trader.create.exchange_unsupported': - return zh ? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。` : `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.` + return zh + ? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。` + : `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.` case 'trader.create.exchange_probe_failed': - return zh ? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}` : `Exchange account "${exchangeName}" failed initialization checks: ${reason}` + return zh + ? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}` + : `Exchange account "${exchangeName}" failed initialization checks: ${reason}` case 'trader.start.strategy_missing': - return zh ? `机器人「${traderName}」缺少有效的交易策略配置。` : `Trader "${traderName}" does not have a valid strategy configuration.` + return zh + ? `机器人「${traderName}」缺少有效的交易策略配置。` + : `Trader "${traderName}" does not have a valid strategy configuration.` case 'trader.start.model_not_found': - return zh ? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。` : `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.` + return zh + ? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。` + : `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.` case 'trader.start.model_disabled': - return zh ? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。` : `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.` + return zh + ? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。` + : `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.` case 'trader.start.exchange_not_found': - return zh ? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。` : `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.` + return zh + ? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。` + : `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.` case 'trader.start.exchange_disabled': - return zh ? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。` : `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.` + return zh + ? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。` + : `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.` case 'trader.start.setup_invalid': case 'trader.start.load_failed': - return zh ? `机器人「${traderName}」暂时还不能启动,原因是:${reason}` : `Trader "${traderName}" cannot be started yet because ${reason}` + return zh + ? `机器人「${traderName}」暂时还不能启动,原因是:${reason}` + : `Trader "${traderName}" cannot be started yet because ${reason}` default: return fallback } @@ -131,34 +176,69 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { switch (reasonKey) { case 'trader.reason.strategy_config_invalid': - return zh ? '当前策略配置内容已损坏,系统暂时无法解析' : 'the current strategy configuration is corrupted and cannot be parsed' + return zh + ? '当前策略配置内容已损坏,系统暂时无法解析' + : 'the current strategy configuration is corrupted and cannot be parsed' case 'trader.reason.strategy_missing': - return zh ? '当前机器人缺少有效的交易策略配置' : 'the trader is missing a valid strategy configuration' + return zh + ? '当前机器人缺少有效的交易策略配置' + : 'the trader is missing a valid strategy configuration' case 'trader.reason.private_key_invalid': - return zh ? '私钥格式不正确,系统无法识别' : 'the private key format is invalid and cannot be recognized' + return zh + ? '私钥格式不正确,系统无法识别' + : 'the private key format is invalid and cannot be recognized' case 'trader.reason.hyperliquid_init_failed': - return zh ? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确' : 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration' + return zh + ? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确' + : 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration' case 'trader.reason.aster_init_failed': - return zh ? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确' : 'Aster account initialization failed. Please verify the Aster User, Signer, and private key' + return zh + ? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确' + : 'Aster account initialization failed. Please verify the Aster User, Signer, and private key' case 'trader.reason.exchange_meta_unavailable': - return zh ? '系统暂时无法从交易所读取账户元信息' : 'the system could not read account metadata from the exchange' + return zh + ? '系统暂时无法从交易所读取账户元信息' + : 'the system could not read account metadata from the exchange' case 'trader.reason.hyperliquid_agent_balance_too_high': - return zh ? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求' : 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements' + return zh + ? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求' + : 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements' case 'trader.reason.exchange_account_init_failed': - return zh ? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配' : 'exchange account initialization failed. Please verify that the wallet address and API key match' + return zh + ? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配' + : 'exchange account initialization failed. Please verify that the wallet address and API key match' case 'trader.reason.exchange_unsupported': - return zh ? '当前交易所类型暂不支持机器人初始化' : 'the selected exchange type is not currently supported for trader initialization' + return zh + ? '当前交易所类型暂不支持机器人初始化' + : 'the selected exchange type is not currently supported for trader initialization' case 'trader.reason.exchange_balance_unavailable': - return zh ? '系统暂时无法从交易所读取账户余额' : 'the system could not read the account balance from the exchange' + return zh + ? '系统暂时无法从交易所读取账户余额' + : 'the system could not read the account balance from the exchange' case 'trader.reason.exchange_service_unreachable': - return zh ? '系统暂时无法连接交易所服务' : 'the system could not reach the exchange service right now' + return zh + ? '系统暂时无法连接交易所服务' + : 'the system could not reach the exchange service right now' default: - return fallback || (zh ? '系统返回了一个未知错误' : 'an unknown error was returned by the system') + return ( + fallback || + (zh + ? '系统返回了一个未知错误' + : 'an unknown error was returned by the system') + ) } } - const normalizeActionableDescription = (error: unknown, message: string, title: string) => { + const normalizeActionableDescription = ( + error: unknown, + message: string, + title: string + ) => { if (error instanceof ApiError && error.errorKey) { - return formatActionableDescriptionByKey(error.errorKey, error.errorParams, message) + return formatActionableDescriptionByKey( + error.errorKey, + error.errorParams, + message + ) } const prefixes = [ @@ -247,12 +327,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const navigateInApp = (path: string) => { navigate(path) - window.dispatchEvent(new PopStateEvent('popstate')) } // Toggle wallet address visibility for a trader const toggleTraderAddressVisibility = (traderId: string) => { - setVisibleTraderAddresses(prev => { + setVisibleTraderAddresses((prev) => { const next = new Set(prev) if (next.has(traderId)) { next.delete(traderId) @@ -265,7 +344,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { // Toggle wallet address visibility for an exchange const toggleExchangeAddressVisibility = (exchangeId: string) => { - setVisibleExchangeAddresses(prev => { + setVisibleExchangeAddresses((prev) => { const next = new Set(prev) if (next.has(exchangeId)) { next.delete(exchangeId) @@ -287,11 +366,13 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } } - const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR( - user && token ? 'traders' : null, - api.getTraders, - { refreshInterval: 5000 } - ) + const { + data: traders, + mutate: mutateTraders, + isLoading: isTradersLoading, + } = useSWR(user && token ? 'traders' : null, api.getTraders, { + refreshInterval: 5000, + }) const { data: exchangeAccountStateData, mutate: mutateExchangeAccountStates, @@ -323,18 +404,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } try { - const [ - modelConfigs, - exchangeConfigs, - models, - ] = await Promise.all([ + const [modelConfigs, exchangeConfigs, models] = await Promise.all([ api.getModelConfigs(), api.getExchangeConfigs(), api.getSupportedModels(), ]) setAllModels(modelConfigs) const clawWalletAddress = - modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null + modelConfigs.find((model) => model.provider === 'claw402') + ?.walletAddress || null if (clawWalletAddress) { setBeginnerWalletAddress(clawWalletAddress) persistBeginnerWalletAddress(clawWalletAddress) @@ -365,10 +443,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { }) || [] const enabledModels = allModels?.filter((m) => m.enabled) || [] - const enabledClaw402Model = enabledModels.find((model) => model.provider === 'claw402') || null - const enabledClaw402Balance = parseBalanceUsdc(enabledClaw402Model?.balanceUsdc) + const enabledClaw402Model = + enabledModels.find((model) => model.provider === 'claw402') || null + const enabledClaw402Balance = parseBalanceUsdc( + enabledClaw402Model?.balanceUsdc + ) const claw402BalanceAlert = - enabledClaw402Model && enabledClaw402Balance !== null && enabledClaw402Balance < 1 + enabledClaw402Model && + enabledClaw402Balance !== null && + enabledClaw402Balance < 1 ? { blocking: enabledClaw402Balance <= 0, title: @@ -379,7 +462,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { : enabledClaw402Balance <= 0 ? 'Claw402 wallet balance is zero' : 'Claw402 wallet balance is low', - description: getClaw402BalanceMessage(enabledClaw402Balance, enabledClaw402Balance <= 0), + description: getClaw402BalanceMessage( + enabledClaw402Balance, + enabledClaw402Balance <= 0 + ), } : null const enabledExchanges = @@ -415,7 +501,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } const getExchangeUsageInfo = (exchangeId: string) => { - const usingTraders = traders?.filter((tr) => tr.exchange_id === exchangeId) || [] + const usingTraders = + traders?.filter((tr) => tr.exchange_id === exchangeId) || [] const runningCount = usingTraders.filter((tr) => tr.is_running).length const totalCount = usingTraders.length return { runningCount, totalCount, usingTraders } @@ -548,17 +635,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } catch (error) { console.error('Failed to toggle trader:', error) showActionableError( - running ? t('aiTradersToast.stopFailed', language) : t('aiTradersToast.startFailed', language), + running + ? t('aiTradersToast.stopFailed', language) + : t('aiTradersToast.startFailed', language), error ) } } - const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => { + const handleToggleCompetition = async ( + traderId: string, + currentShowInCompetition: boolean + ) => { try { const newValue = !currentShowInCompetition await api.toggleCompetition(traderId, newValue) - toast.success(newValue ? t('aiTradersToast.showInCompetition', language) : t('aiTradersToast.hideInCompetition', language)) + toast.success( + newValue + ? t('aiTradersToast.showInCompetition', language) + : t('aiTradersToast.hideInCompetition', language) + ) await mutateTraders() } catch (error) { @@ -695,12 +791,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { allModels?.map((m) => m.id === modelId ? { - ...m, - apiKey, - customApiUrl: customApiUrl || '', - customModelName: customModelName || '', - enabled: true, - } + ...m, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + } : m ) || [] } else { @@ -816,7 +912,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } await api.updateExchangeConfigsEncrypted(request) - toast.success(t('aiTradersToast.exchangeConfigUpdated', language)) + toast.success(t('aiTradersToast.exchangeConfigUpdated', language)) } else { const createRequest = { exchange_type: exchangeType, @@ -837,7 +933,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } await api.createExchangeEncrypted(createRequest) - toast.success(t('aiTradersToast.exchangeCreated', language)) + toast.success(t('aiTradersToast.exchangeCreated', language)) } const refreshedExchanges = await api.getExchangeConfigs() @@ -888,10 +984,13 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } } - const claw402Configured = configuredModels.some((model) => model.provider === 'claw402') + const claw402Configured = configuredModels.some( + (model) => model.provider === 'claw402' + ) const hasStrategies = (strategies?.length || 0) > 0 const hasCreatedTrader = (traders?.length || 0) > 0 - const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0 + const canCreateTrader = + configuredModels.length > 0 && configuredExchanges.length > 0 return ( @@ -952,7 +1051,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {

- {isZh ? '$5-$10 可以用很久' : '$5-$10 usually lasts a long time'} + {isZh + ? '$5-$10 可以用很久' + : '$5-$10 usually lasts a long time'}
@@ -187,7 +196,9 @@ export function BeginnerOnboardingPage() {
diff --git a/web/src/pages/LandingPage.tsx b/web/src/pages/LandingPage.tsx index 9e36e26c..495921c2 100644 --- a/web/src/pages/LandingPage.tsx +++ b/web/src/pages/LandingPage.tsx @@ -34,24 +34,8 @@ export function LandingPage() { user={user} onLogout={logout} onLoginRequired={handleLoginRequired} - onPageChange={(page) => { - const pathMap: Record = { - 'data': '/data', - 'competition': '/competition', - 'strategy-market': '/strategy-market', - 'traders': '/traders', - 'trader': '/dashboard', - 'strategy': '/strategy', - 'faq': '/faq', - } - const path = pathMap[page] - if (path) { - window.location.href = path - } - }} />
- diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 78c50808..47aad93a 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,6 +1,17 @@ import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' -import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react' +import { + User, + Cpu, + Building2, + MessageCircle, + Eye, + EyeOff, + ChevronRight, + Plus, + Pencil, +} from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../contexts/LanguageContext' import { api } from '../lib/api' @@ -20,8 +31,11 @@ type Tab = 'account' | 'models' | 'exchanges' | 'telegram' export function SettingsPage() { const { user } = useAuth() const { language } = useLanguage() + const navigate = useNavigate() const [activeTab, setActiveTab] = useState('account') - const [userMode, setUserModeState] = useState(() => getUserMode() ?? 'advanced') + const [userMode, setUserModeState] = useState( + () => getUserMode() ?? 'advanced' + ) // Account state const [newPassword, setNewPassword] = useState('') @@ -53,7 +67,8 @@ export function SettingsPage() { .catch(() => toast.error('Failed to load AI models')) } if (activeTab === 'exchanges') { - api.getExchangeConfigs() + api + .getExchangeConfigs() .then(setExchanges) .catch(() => toast.error('Failed to load exchanges')) } @@ -82,7 +97,9 @@ export function SettingsPage() { toast.success('Password updated successfully') setNewPassword('') } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to update password') + toast.error( + err instanceof Error ? err.message : 'Failed to update password' + ) } finally { setChangingPassword(false) } @@ -104,8 +121,7 @@ export function SettingsPage() { ) const nextPath = getPostAuthPath(nextMode) - window.history.pushState({}, '', nextPath) - window.dispatchEvent(new PopStateEvent('popstate')) + navigate(nextPath) } const handleSaveModel = async ( @@ -118,33 +134,48 @@ export function SettingsPage() { const existingModel = configuredModels.find((m) => m.id === modelId) const modelTemplate = supportedModels.find((m) => m.id === modelId) const modelToUpdate = existingModel || modelTemplate - if (!modelToUpdate) { toast.error('Model not found'); return } + if (!modelToUpdate) { + toast.error('Model not found') + return + } let updatedModels: AIModel[] if (existingModel) { updatedModels = configuredModels.map((m) => m.id === modelId - ? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true } + ? { + ...m, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + } : m ) } else { - updatedModels = [...configuredModels, { - ...modelToUpdate, - apiKey, - customApiUrl: customApiUrl || '', - customModelName: customModelName || '', - enabled: true, - }] + updatedModels = [ + ...configuredModels, + { + ...modelToUpdate, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + }, + ] } const request = { models: Object.fromEntries( - updatedModels.map((m) => [m.provider, { - enabled: m.enabled, - api_key: m.apiKey || '', - custom_api_url: m.customApiUrl || '', - custom_model_name: m.customModelName || '', - }]) + updatedModels.map((m) => [ + m.provider, + { + enabled: m.enabled, + api_key: m.apiKey || '', + custom_api_url: m.customApiUrl || '', + custom_model_name: m.customModelName || '', + }, + ]) ), } await api.updateModelConfigs(request) @@ -161,16 +192,27 @@ export function SettingsPage() { const handleDeleteModel = async (modelId: string) => { try { const updatedModels = configuredModels.map((m) => - m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m + m.id === modelId + ? { + ...m, + apiKey: '', + customApiUrl: '', + customModelName: '', + enabled: false, + } + : m ) const request = { models: Object.fromEntries( - updatedModels.map((m) => [m.provider, { - enabled: m.enabled, - api_key: m.apiKey || '', - custom_api_url: m.customApiUrl || '', - custom_model_name: m.customModelName || '', - }]) + updatedModels.map((m) => [ + m.provider, + { + enabled: m.enabled, + api_key: m.apiKey || '', + custom_api_url: m.customApiUrl || '', + custom_model_name: m.customModelName || '', + }, + ]) ), } await api.updateModelConfigs(request) @@ -223,7 +265,7 @@ export function SettingsPage() { }, } await api.updateExchangeConfigsEncrypted(request) - toast.success('Exchange config updated') + toast.success('Exchange config updated') } else { const createRequest = { exchange_type: exchangeType, @@ -243,7 +285,7 @@ export function SettingsPage() { lighter_api_key_index: lighterApiKeyIndex || 0, } await api.createExchangeEncrypted(createRequest) - toast.success('Exchange account created') + toast.success('Exchange account created') } const refreshed = await api.getExchangeConfigs() setExchanges(refreshed) @@ -275,7 +317,10 @@ export function SettingsPage() { ] return ( -
+

Settings

@@ -286,9 +331,10 @@ export function SettingsPage() { key={tab.key} onClick={() => setActiveTab(tab.key)} className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all - ${activeTab === tab.key - ? 'bg-nofx-gold text-black' - : 'text-zinc-400 hover:text-white' + ${ + activeTab === tab.key + ? 'bg-nofx-gold text-black' + : 'text-zinc-400 hover:text-white' }`} > {tab.icon} @@ -299,7 +345,6 @@ export function SettingsPage() { {/* Tab Content */}
- {/* Account Tab */} {activeTab === 'account' && (
@@ -322,8 +367,12 @@ export function SettingsPage() {
{userMode === 'beginner' - ? language === 'zh' ? '当前:新手模式' : 'Current: Beginner' - : language === 'zh' ? '当前:老手模式' : 'Current: Advanced'} + ? language === 'zh' + ? '当前:新手模式' + : 'Current: Beginner' + : language === 'zh' + ? '当前:老手模式' + : 'Current: Advanced'}
@@ -369,10 +418,14 @@ export function SettingsPage() {
-

Change Password

+

+ Change Password +

- +
setShowPassword(!showPassword)} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors" > - {showPassword ? : } + {showPassword ? ( + + ) : ( + + )}
@@ -408,10 +465,14 @@ export function SettingsPage() {

- {configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured + {configuredModels.length} model + {configuredModels.length !== 1 ? 's' : ''} configured

- + {model.enabled ? 'Active' : 'Inactive'} - +
))} @@ -458,10 +531,14 @@ export function SettingsPage() {

- {exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected + {exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '} + connected

- + ))}
@@ -502,7 +589,8 @@ export function SettingsPage() { {activeTab === 'telegram' && (

- Connect a Telegram bot to receive trading notifications and interact with your traders. + Connect a Telegram bot to receive trading notifications and + interact with your traders.

- +
)} @@ -530,7 +623,10 @@ export function SettingsPage() { editingModelId={editingModel} onSave={handleSaveModel} onDelete={handleDeleteModel} - onClose={() => { setShowModelModal(false); setEditingModel(null) }} + onClose={() => { + setShowModelModal(false) + setEditingModel(null) + }} language={language} />
@@ -544,7 +640,10 @@ export function SettingsPage() { editingExchangeId={editingExchange} onSave={handleSaveExchange} onDelete={handleDeleteExchange} - onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }} + onClose={() => { + setShowExchangeModal(false) + setEditingExchange(null) + }} language={language} />
diff --git a/web/src/pages/StrategyMarketPage.tsx b/web/src/pages/StrategyMarketPage.tsx index dbf64c4f..cf49a477 100644 --- a/web/src/pages/StrategyMarketPage.tsx +++ b/web/src/pages/StrategyMarketPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' +import { useNavigate } from 'react-router-dom' import useSWR from 'swr' import { TrendingUp, @@ -15,7 +16,7 @@ import { Activity, Terminal, Cpu, - Database + Database, } from 'lucide-react' import { useLanguage } from '../contexts/LanguageContext' import { useAuth } from '../contexts/AuthContext' @@ -39,14 +40,24 @@ interface PublicStrategy { updated_at: string } -const strategyStyles: Record = { +const strategyStyles: Record< + string, + { + color: string + border: string + glow: string + shadow: string + icon: any + bg: string + } +> = { scalper: { color: 'text-[#F0B90B]', border: 'border-[#F0B90B]/30', glow: 'shadow-[0_0_20px_rgba(240,185,11,0.15)]', shadow: 'hover:shadow-[0_0_30px_rgba(240,185,11,0.25)]', bg: 'bg-[#F0B90B]/5', - icon: Zap + icon: Zap, }, swing: { color: 'text-cyan-400', @@ -54,7 +65,7 @@ const strategyStyles: Record { - if (searchQuery) { - const query = searchQuery.toLowerCase() - return s.name.toLowerCase().includes(query) || - s.description?.toLowerCase().includes(query) - } - return true - }) || [] + const filteredStrategies = + strategies?.filter((s) => { + if (searchQuery) { + const query = searchQuery.toLowerCase() + return ( + s.name.toLowerCase().includes(query) || + s.description?.toLowerCase().includes(query) + ) + } + return true + }) || [] const handleCopyConfig = async (strategy: PublicStrategy) => { if (!strategy.config) return try { - await navigator.clipboard.writeText(JSON.stringify(strategy.config, null, 2)) + await navigator.clipboard.writeText( + JSON.stringify(strategy.config, null, 2) + ) setCopiedId(strategy.id) toast.success(tr('copied')) setTimeout(() => setCopiedId(null), 2000) @@ -147,14 +166,16 @@ export function StrategyMarketPage() { const formatDate = (dateStr: string) => { const date = new Date(dateStr) - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }).replace(',', '') + return date + .toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + .replace(',', '') } const getIndicatorList = (config: any) => { @@ -174,15 +195,15 @@ export function StrategyMarketPage() { return (
-
- {/* Header Section */}
- SYSTEM_STATUS: ONLINE + SYSTEM_STATUS:{' '} + ONLINE
- MARKET_UPLINK: ESTABLISHED + MARKET_UPLINK:{' '} + ESTABLISHED
@@ -191,11 +212,15 @@ export function StrategyMarketPage() {
-

+

{tr('title')}

- // {tr('subtitle')} + {'// '} + {tr('subtitle')}

@@ -232,16 +257,21 @@ export function StrategyMarketPage() {
-

{tr('loading')}

+

+ {tr('loading')} +

-
-
-
+
+
+
)} @@ -279,7 +320,9 @@ export function StrategyMarketPage() {

[{tr('noStrategies')}]

-

{tr('noStrategiesDesc')}

+

+ {tr('noStrategiesDesc')} +

)} @@ -290,9 +333,10 @@ export function StrategyMarketPage() { {filteredStrategies.map((strategy, i) => { const style = getStrategyStyle(strategy.name) const Icon = style.icon - const indicators = strategy.config_visible && strategy.config - ? getIndicatorList(strategy.config) - : [] + const indicators = + strategy.config_visible && strategy.config + ? getIndicatorList(strategy.config) + : [] return ( {/* Holographic Border Highlight */} -
-
+
+
{/* Category Side Strip */} -
+
{/* Header */}
-
+
@@ -332,7 +384,9 @@ export function StrategyMarketPage() {
{/* Name and Description */} -

+

{strategy.name}

@@ -343,12 +397,22 @@ export function StrategyMarketPage() { {/* Meta Data */}
- {tr('author')} - @{strategy.author_email?.split('@')[0] || 'UNKNOWN'} + + {tr('author')} + + + @ + {strategy.author_email?.split('@')[0] || + 'UNKNOWN'} +
- {tr('createdAt')} - {formatDate(strategy.created_at)} + + {tr('createdAt')} + + + {formatDate(strategy.created_at)} +
@@ -358,14 +422,20 @@ export function StrategyMarketPage() {
{/* Indicators */}
- {indicators.length > 0 ? indicators.map((ind) => ( - - {ind} + {indicators.length > 0 ? ( + indicators.map((ind) => ( + + {ind} + + )) + ) : ( + + NO_INDICATORS - )) : NO_INDICATORS} + )}
{/* Risk Control */} @@ -373,22 +443,38 @@ export function StrategyMarketPage() {
- LEV - {strategy.config.risk_control.btc_eth_max_leverage || '-'}x + + LEV + + + {strategy.config.risk_control + .btc_eth_max_leverage || '-'} + x +
- POS - {strategy.config.risk_control.max_positions || '-'} + + POS + + + {strategy.config.risk_control + .max_positions || '-'} +
- +
)}
) : (
- {tr('configHiddenDesc')} + + {tr('configHiddenDesc')} +
)}
@@ -403,7 +489,9 @@ export function StrategyMarketPage() { {copiedId === strategy.id ? ( <> - {tr('copied')} + + {tr('copied')} + ) : ( <> @@ -413,13 +501,15 @@ export function StrategyMarketPage() { )} ) : ( - )}
-
) @@ -436,13 +526,23 @@ export function StrategyMarketPage() { transition={{ delay: 0.3 }} className="mt-16 mb-20 flex justify-center" > -
window.location.href = '/strategy'}> +
navigate('/strategy')} + >
- +
-
{tr('shareYours')}
-
CONTRIBUTE TO THE GLOBAL DATABASE
+
+ {tr('shareYours')} +
+
+ CONTRIBUTE TO THE GLOBAL DATABASE +
@@ -452,7 +552,6 @@ export function StrategyMarketPage() {
)} -
diff --git a/web/src/router/AppRoutes.tsx b/web/src/router/AppRoutes.tsx new file mode 100644 index 00000000..6469643e --- /dev/null +++ b/web/src/router/AppRoutes.tsx @@ -0,0 +1,539 @@ +import { type ReactNode, useEffect, useState } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import useSWR from 'swr' +import { + Navigate, + Route, + Routes, + useLocation, + useNavigate, + useSearchParams, +} from 'react-router-dom' +import HeaderBar from '../components/common/HeaderBar' +import { SiteFooter } from '../components/common/SiteFooter' +import { LoginRequiredOverlay } from '../components/auth/LoginRequiredOverlay' +import { LoginPage } from '../components/auth/LoginPage' +import { RegisterPage } from '../components/auth/RegisterPage' +import { ResetPasswordPage } from '../components/auth/ResetPasswordPage' +import { SetupPage } from '../components/modals/SetupPage' +import { CompetitionPage } from '../components/trader/CompetitionPage' +import { AITradersPage } from '../components/trader/AITradersPage' +import { FAQPage } from '../pages/FAQPage' +import { LandingPage } from '../pages/LandingPage' +import { BeginnerOnboardingPage } from '../pages/BeginnerOnboardingPage' +import { DataPage } from '../pages/DataPage' +import { SettingsPage } from '../pages/SettingsPage' +import { StrategyMarketPage } from '../pages/StrategyMarketPage' +import { StrategyStudioPage } from '../pages/StrategyStudioPage' +import { TraderDashboardPage } from '../pages/TraderDashboardPage' +import { useAuth } from '../contexts/AuthContext' +import { useLanguage } from '../contexts/LanguageContext' +import { useSystemConfig } from '../hooks/useSystemConfig' +import { t } from '../i18n/translations' +import { api } from '../lib/api' +import { getUserMode } from '../lib/onboarding' +import type { + AccountInfo, + DecisionRecord, + Exchange, + Position, + Statistics, + SystemStatus, + TraderInfo, +} from '../types' +import { + buildDashboardPath, + LEGACY_HASH_ROUTES, + ROUTES, + type Page, +} from './paths' + +function getTraderSlug(trader: TraderInfo) { + const idPrefix = trader.trader_id.slice(0, 4) + return `${trader.trader_name}-${idPrefix}` +} + +function findTraderBySlug(slug: string, traderList: TraderInfo[]) { + const lastDashIndex = slug.lastIndexOf('-') + if (lastDashIndex === -1) { + return traderList.find((trader) => trader.trader_name === slug) + } + + const name = slug.slice(0, lastDashIndex) + const idPrefix = slug.slice(lastDashIndex + 1) + return traderList.find( + (trader) => + trader.trader_name === name && trader.trader_id.startsWith(idPrefix) + ) +} + +function LoadingScreen() { + const { language } = useLanguage() + + return ( +
+
+ NoFx Logo +

{t('loading', language)}

+
+
+ ) +} + +function LegacyHashRedirect() { + const location = useLocation() + const navigate = useNavigate() + + useEffect(() => { + const hashRoute = LEGACY_HASH_ROUTES[location.hash.slice(1)] + if (!hashRoute) { + return + } + + if (hashRoute === location.pathname && location.hash === '') { + return + } + + navigate( + { + pathname: hashRoute, + search: location.search, + }, + { replace: true } + ) + }, [location.hash, location.pathname, location.search, navigate]) + + return null +} + +interface AppChromeProps { + children: ReactNode + currentPage?: Page + showFooter?: boolean + wrapInMain?: boolean + animateContent?: boolean + extraContent?: ReactNode +} + +function AppChrome({ + children, + currentPage, + showFooter = true, + wrapInMain = true, + animateContent = false, + extraContent, +}: AppChromeProps) { + const location = useLocation() + const { language, setLanguage } = useLanguage() + const { user, logout } = useAuth() + const [loginOverlayOpen, setLoginOverlayOpen] = useState(false) + const [loginOverlayFeature, setLoginOverlayFeature] = useState('') + + const handleLoginRequired = (featureName: string) => { + setLoginOverlayFeature(featureName) + setLoginOverlayOpen(true) + } + + const content = animateContent ? ( + + + {children} + + + ) : ( + children + ) + + return ( +
+ + + {wrapInMain ? ( +
{content}
+ ) : ( + content + )} + + {showFooter ? : null} + + setLoginOverlayOpen(false)} + featureName={loginOverlayFeature} + /> + + {extraContent} +
+ ) +} + +function TradersRoute({ + showBeginnerOnboarding = false, +}: { + showBeginnerOnboarding?: boolean +}) { + const navigate = useNavigate() + const { user, token } = useAuth() + const { data: traders } = useSWR( + user && token ? 'traders-route' : null, + api.getTraders, + { + refreshInterval: 5000, + shouldRetryOnError: false, + } + ) + + return ( + : null} + > + { + const trader = traders?.find((item) => item.trader_id === traderId) + navigate( + buildDashboardPath(trader ? getTraderSlug(trader) : undefined) + ) + }} + /> + + ) +} + +function DashboardRoute() { + const { language } = useLanguage() + const { user, token } = useAuth() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const selectedTraderSlug = searchParams.get('trader') || undefined + const [selectedTraderId, setSelectedTraderId] = useState() + const [lastUpdate, setLastUpdate] = useState('--:--:--') + const [decisionsLimit, setDecisionsLimit] = useState(5) + const [accountPollOff, setAccountPollOff] = useState(false) + const [positionsPollOff, setPositionsPollOff] = useState(false) + const [decisionsPollOff, setDecisionsPollOff] = useState(false) + + useEffect(() => { + setAccountPollOff(false) + setPositionsPollOff(false) + setDecisionsPollOff(false) + }, [selectedTraderId]) + + const { data: traders, error: tradersError } = useSWR( + user && token ? 'traders-dashboard' : null, + () => api.getTraders(true), + { + refreshInterval: 10000, + shouldRetryOnError: false, + } + ) + + const { data: exchanges } = useSWR( + user && token ? 'exchanges-dashboard' : null, + api.getExchangeConfigs, + { + refreshInterval: 60000, + shouldRetryOnError: false, + } + ) + + useEffect(() => { + if (!traders || traders.length === 0) { + return + } + + if (selectedTraderSlug) { + const trader = findTraderBySlug(selectedTraderSlug, traders) + const nextTraderId = trader?.trader_id || traders[0].trader_id + if (nextTraderId !== selectedTraderId) { + setSelectedTraderId(nextTraderId) + } + return + } + + if (!selectedTraderId) { + setSelectedTraderId(traders[0].trader_id) + } + }, [selectedTraderId, selectedTraderSlug, traders]) + + const { data: status } = useSWR( + selectedTraderId ? `status-${selectedTraderId}` : null, + () => api.getStatus(selectedTraderId, true), + { + refreshInterval: 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + } + ) + + const { data: account } = useSWR( + selectedTraderId ? `account-${selectedTraderId}` : null, + () => api.getAccount(selectedTraderId, true), + { + refreshInterval: accountPollOff ? 0 : 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { + setAccountPollOff(true) + return + } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { + if (accountPollOff) { + setAccountPollOff(false) + } + }, + } + ) + + const { data: positions } = useSWR( + selectedTraderId ? `positions-${selectedTraderId}` : null, + () => api.getPositions(selectedTraderId, true), + { + refreshInterval: positionsPollOff ? 0 : 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { + setPositionsPollOff(true) + return + } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { + if (positionsPollOff) { + setPositionsPollOff(false) + } + }, + } + ) + + const { data: decisions } = useSWR( + selectedTraderId + ? `decisions/latest-${selectedTraderId}-${decisionsLimit}` + : null, + () => api.getLatestDecisions(selectedTraderId, decisionsLimit, true), + { + refreshInterval: decisionsPollOff ? 0 : 30000, + revalidateOnFocus: false, + dedupingInterval: 20000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { + setDecisionsPollOff(true) + return + } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { + if (decisionsPollOff) { + setDecisionsPollOff(false) + } + }, + } + ) + + const { data: stats } = useSWR( + selectedTraderId ? `statistics-${selectedTraderId}` : null, + () => api.getStatistics(selectedTraderId, true), + { + refreshInterval: 30000, + revalidateOnFocus: false, + dedupingInterval: 20000, + } + ) + + useEffect(() => { + if (account) { + setLastUpdate(new Date().toLocaleTimeString()) + } + }, [account]) + + const selectedTrader = traders?.find( + (trader) => trader.trader_id === selectedTraderId + ) + + return ( + + { + setSelectedTraderId(traderId) + const trader = traders?.find((item) => item.trader_id === traderId) + navigate( + buildDashboardPath(trader ? getTraderSlug(trader) : undefined), + { + replace: true, + } + ) + }} + onNavigateToTraders={() => navigate(ROUTES.traders)} + exchanges={exchanges} + /> + + ) +} + +export function AppRoutes() { + const { user, token, isLoading } = useAuth() + const { config: systemConfig, loading: configLoading } = useSystemConfig() + const isAuthenticated = !!user && !!token + + if (isLoading || configLoading) { + return + } + + if (systemConfig && !systemConfig.initialized && !user) { + return + } + + return ( + <> + + + } /> + } /> + } /> + } /> + + ) : ( + + ) + } + /> + + + + } + /> + + + + } + /> + + + + ) : ( + + ) + } + /> + + ) : ( + + ) + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + : } + /> + : } + /> + + + + ) : ( + + ) + } + /> + } /> + + + ) +} diff --git a/web/src/router/paths.test.ts b/web/src/router/paths.test.ts new file mode 100644 index 00000000..d78eba87 --- /dev/null +++ b/web/src/router/paths.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + buildDashboardPath, + getCurrentPageForPath, + LEGACY_HASH_ROUTES, + ROUTES, +} from './paths' + +describe('router paths helpers', () => { + it('maps pathname to current navigation page', () => { + expect(getCurrentPageForPath(ROUTES.home)).toBeUndefined() + expect(getCurrentPageForPath(ROUTES.welcome)).toBe('traders') + expect(getCurrentPageForPath(ROUTES.dashboard)).toBe('trader') + expect(getCurrentPageForPath(ROUTES.strategyMarket)).toBe('strategy-market') + }) + + it('builds dashboard path with optional trader query', () => { + expect(buildDashboardPath()).toBe(ROUTES.dashboard) + expect(buildDashboardPath('alpha-1234')).toBe( + '/dashboard?trader=alpha-1234' + ) + expect(buildDashboardPath('alpha beta')).toBe( + '/dashboard?trader=alpha%20beta' + ) + }) + + it('keeps legacy hash redirects aligned with current routes', () => { + expect(LEGACY_HASH_ROUTES.trader).toBe(ROUTES.dashboard) + expect(LEGACY_HASH_ROUTES.details).toBe(ROUTES.dashboard) + expect(LEGACY_HASH_ROUTES.strategy).toBe(ROUTES.strategy) + }) +}) diff --git a/web/src/router/paths.ts b/web/src/router/paths.ts new file mode 100644 index 00000000..b12b55b5 --- /dev/null +++ b/web/src/router/paths.ts @@ -0,0 +1,83 @@ +export type Page = + | 'competition' + | 'traders' + | 'trader' + | 'strategy' + | 'strategy-market' + | 'data' + | 'faq' + | 'login' + | 'register' + +export const ROUTES = { + home: '/', + login: '/login', + register: '/register', + setup: '/setup', + welcome: '/welcome', + faq: '/faq', + resetPassword: '/reset-password', + settings: '/settings', + data: '/data', + competition: '/competition', + traders: '/traders', + dashboard: '/dashboard', + strategy: '/strategy', + strategyMarket: '/strategy-market', +} as const + +export const PAGE_PATHS: Record = { + competition: ROUTES.competition, + traders: ROUTES.traders, + trader: ROUTES.dashboard, + strategy: ROUTES.strategy, + 'strategy-market': ROUTES.strategyMarket, + data: ROUTES.data, + faq: ROUTES.faq, + login: ROUTES.login, + register: ROUTES.register, +} + +export const LEGACY_HASH_ROUTES: Record = { + competition: ROUTES.competition, + traders: ROUTES.traders, + trader: ROUTES.dashboard, + details: ROUTES.dashboard, + strategy: ROUTES.strategy, + 'strategy-market': ROUTES.strategyMarket, + data: ROUTES.data, +} + +export function getCurrentPageForPath(pathname: string): Page | undefined { + switch (pathname) { + case ROUTES.welcome: + case ROUTES.traders: + return 'traders' + case ROUTES.dashboard: + return 'trader' + case ROUTES.strategy: + return 'strategy' + case ROUTES.strategyMarket: + return 'strategy-market' + case ROUTES.data: + return 'data' + case ROUTES.faq: + return 'faq' + case ROUTES.login: + return 'login' + case ROUTES.register: + return 'register' + case ROUTES.competition: + return 'competition' + default: + return undefined + } +} + +export function buildDashboardPath(traderSlug?: string): string { + if (!traderSlug) { + return ROUTES.dashboard + } + + return `${ROUTES.dashboard}?trader=${encodeURIComponent(traderSlug)}` +}