mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
refactor: replace window.location with useNavigate for routing in auth components (#1470)
Co-authored-by: Dean <afei.wuhao@gmail.com>
This commit is contained in:
+5
-709
@@ -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<Page, string> = {
|
||||
'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<Page>(getInitialPage())
|
||||
// 从 URL 参数读取初始 trader 标识(格式: name-id前4位)
|
||||
const [selectedTraderSlug, setSelectedTraderSlug] = useState<string | undefined>(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('trader') || undefined
|
||||
})
|
||||
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>()
|
||||
|
||||
// 生成 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<string>('--:--:--')
|
||||
const [decisionsLimit, setDecisionsLimit] = useState<number>(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<TraderInfo[]>(
|
||||
user && token ? 'traders' : null,
|
||||
() => api.getTraders(currentPage === 'trader'),
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
shouldRetryOnError: false, // 避免在后端未运行时无限重试
|
||||
}
|
||||
)
|
||||
|
||||
// 获取exchanges列表(用于显示交易所名称)
|
||||
const { data: exchanges } = useSWR<Exchange[]>(
|
||||
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<SystemStatus>(
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `status-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getStatus(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
||||
}
|
||||
)
|
||||
|
||||
const { data: account } = useSWR<AccountInfo>(
|
||||
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<Position[]>(
|
||||
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<DecisionRecord[]>(
|
||||
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<Statistics>(
|
||||
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 (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: '#0B0E11' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 mx-auto mb-4 animate-pulse"
|
||||
/>
|
||||
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// First-time setup: redirect to /setup if system not initialized
|
||||
if (systemConfig && !systemConfig.initialized && !user) {
|
||||
return <SetupPage />
|
||||
}
|
||||
|
||||
// Handle specific routes regardless of authentication
|
||||
if (route === '/login') {
|
||||
return <LoginPage />
|
||||
}
|
||||
if (route === '/setup') {
|
||||
// If already initialized, redirect to login
|
||||
if (systemConfig?.initialized) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
return <SetupPage />
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage="faq"
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={navigateToPage}
|
||||
/>
|
||||
<FAQPage />
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (route === '/reset-password') {
|
||||
return <ResetPasswordPage />
|
||||
}
|
||||
if (route === '/settings') {
|
||||
if ((!user || !token) && !hasPersistedAuth) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={navigateToPage}
|
||||
/>
|
||||
<SettingsPage />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Data page - publicly accessible with embedded dashboard
|
||||
if (route === '/data') {
|
||||
const dataPageNavigate = (page: Page) => {
|
||||
navigateToPage(page)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage="data"
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={dataPageNavigate}
|
||||
/>
|
||||
<main className="pt-16">
|
||||
<DataPage />
|
||||
</main>
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Show landing page for root route
|
||||
if (route === '/' || route === '') {
|
||||
return <LandingPage />
|
||||
}
|
||||
|
||||
// Redirect unauthenticated users to landing page
|
||||
if (!user || !token) {
|
||||
return <LandingPage />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage={currentPage}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={navigateToPage}
|
||||
/>
|
||||
|
||||
{/* Main Content with Page Transitions */}
|
||||
<main className="min-h-screen pt-16">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentPage}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
>
|
||||
{currentPage === 'competition' ? (
|
||||
<CompetitionPage />
|
||||
) : currentPage === 'data' ? (
|
||||
<DataPage />
|
||||
) : currentPage === 'strategy-market' ? (
|
||||
<StrategyMarketPage />
|
||||
) : currentPage === 'traders' ? (
|
||||
<AITradersPage
|
||||
onTraderSelect={(traderId) => {
|
||||
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' ? (
|
||||
<StrategyStudioPage />
|
||||
) : (
|
||||
<TraderDashboardPage
|
||||
selectedTrader={selectedTrader}
|
||||
status={status}
|
||||
account={effectiveAccount}
|
||||
accountFailed={accountPollOff}
|
||||
positions={effectivePositions}
|
||||
positionsFailed={positionsPollOff}
|
||||
decisions={effectiveDecisions}
|
||||
decisionsFailed={decisionsPollOff}
|
||||
decisionsLimit={decisionsLimit}
|
||||
onDecisionsLimitChange={setDecisionsLimit}
|
||||
stats={stats}
|
||||
lastUpdate={lastUpdate}
|
||||
language={language}
|
||||
traders={traders}
|
||||
tradersError={tradersError}
|
||||
selectedTraderId={selectedTraderId}
|
||||
onTraderSelect={(traderId) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer
|
||||
className="mt-16"
|
||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||
>
|
||||
<div
|
||||
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
|
||||
style={{ color: '#5E6673' }}
|
||||
>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
|
||||
{/* GitHub */}
|
||||
<a
|
||||
href={OFFICIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#F0B90B'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
{/* Twitter/X */}
|
||||
<a
|
||||
href={OFFICIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#1DA1F2'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
Twitter
|
||||
</a>
|
||||
{/* Telegram */}
|
||||
<a
|
||||
href={OFFICIAL_LINKS.telegram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#0088cc'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Login Required Overlay */}
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
|
||||
{showBeginnerOnboarding && <BeginnerOnboardingPage />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Wrap App with providers
|
||||
export default function AppWithProviders() {
|
||||
export default function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<App />
|
||||
<AppRoutes />
|
||||
</ConfirmDialogProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -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<string | number | null>(null)
|
||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(
|
||||
null
|
||||
)
|
||||
const [mode, setMode] = useState<UserMode>('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() {
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
{/* Logo + Title */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" />
|
||||
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10" />
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NOFX"
|
||||
className="w-14 h-14 relative z-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome back</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1.5">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-zinc-500 text-sm">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-8 shadow-2xl">
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">
|
||||
@@ -120,7 +132,7 @@ export function LoginPage() {
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = '/reset-password'}
|
||||
onClick={() => navigate('/reset-password')}
|
||||
className="text-xs text-zinc-500 hover:text-nofx-gold transition-colors"
|
||||
>
|
||||
{t('forgotPassword', language)}
|
||||
@@ -164,7 +176,9 @@ export function LoginPage() {
|
||||
disabled={loading}
|
||||
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||
>
|
||||
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
|
||||
{loading
|
||||
? t('loggingIn', language) || 'Signing in...'
|
||||
: t('signIn', language) || 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -178,7 +192,6 @@ export function LoginPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
|
||||
@@ -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<string, string | number>) =>
|
||||
@@ -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 (
|
||||
<AnimatePresence>
|
||||
@@ -40,7 +41,6 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
disableAnimation
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
@@ -53,7 +53,9 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-nofx-bg-lighter border-b border-nofx-gold/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={12} className="text-nofx-gold" />
|
||||
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">auth_protocol.exe</span>
|
||||
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">
|
||||
auth_protocol.exe
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -75,7 +77,9 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
|
||||
<div className="bg-nofx-bg border border-red-500/50 text-red-500 px-4 py-2 flex items-center gap-3 shadow-[0_0_15px_rgba(239,68,68,0.2)]">
|
||||
<AlertTriangle size={18} className="animate-pulse" />
|
||||
<span className="font-bold tracking-widest text-sm uppercase">{tr('accessDenied')}</span>
|
||||
<span className="font-bold tracking-widest text-sm uppercase">
|
||||
{tr('accessDenied')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,8 +87,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
{/* Terminal Text */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{tr('title')}</h2>
|
||||
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">{subtitle}</p>
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">
|
||||
{tr('title')}
|
||||
</h2>
|
||||
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-nofx-bg-lighter border-l-2 border-nofx-gold/20 p-3 my-4">
|
||||
@@ -96,7 +104,10 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{benefits.map((benefit, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide"
|
||||
>
|
||||
<span className="text-nofx-gold">✓</span> {benefit}
|
||||
</div>
|
||||
))}
|
||||
@@ -105,22 +116,24 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="/login"
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-black font-bold text-xs uppercase tracking-widest hover:bg-yellow-400 transition-all shadow-neon hover:shadow-[0_0_25px_rgba(240,185,11,0.4)] group"
|
||||
>
|
||||
<LogIn size={14} />
|
||||
<span>{tr('loginButton')}</span>
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-></span>
|
||||
</a>
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">
|
||||
->
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="/register"
|
||||
<Link
|
||||
to="/register"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-nofx-gold/20 text-nofx-text-muted hover:text-white hover:border-nofx-gold font-bold text-xs uppercase tracking-widest transition-all hover:bg-nofx-gold/10"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>{tr('registerButton')}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
@@ -131,14 +144,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
[ {tr('abort')} ]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner Accents */}
|
||||
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div>
|
||||
<div className="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-nofx-gold"></div>
|
||||
|
||||
</motion.div>
|
||||
</DeepVoidBackground>
|
||||
</motion.div>
|
||||
|
||||
@@ -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 (
|
||||
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
|
||||
<DeepVoidBackground
|
||||
className="min-h-screen flex items-center justify-center py-12 font-mono"
|
||||
disableAnimation
|
||||
>
|
||||
<div className="w-full max-w-lg relative z-10 px-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<button
|
||||
onClick={() => (window.location.href = '/')}
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-2 text-zinc-500 hover:text-white transition-colors group px-3 py-1.5 rounded border border-transparent hover:border-zinc-700 bg-black/20 backdrop-blur-sm"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 group-hover:animate-pulse"></div>
|
||||
<span className="text-xs font-mono uppercase tracking-widest">< ABORT_REGISTRATION</span>
|
||||
<span className="text-xs font-mono uppercase tracking-widest">
|
||||
< ABORT_REGISTRATION
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +136,11 @@ export function RegisterPage() {
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-nofx-gold/20 rounded-full blur-xl animate-pulse"></div>
|
||||
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain relative z-10 opacity-90" />
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 object-contain relative z-10 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase mb-2">
|
||||
@@ -140,7 +158,7 @@ export function RegisterPage() {
|
||||
<div className="flex gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full bg-red-500/50 hover:bg-red-500 cursor-pointer transition-colors"
|
||||
onClick={() => (window.location.href = '/')}
|
||||
onClick={() => navigate('/')}
|
||||
title="Close / Return Home"
|
||||
></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></div>
|
||||
@@ -155,7 +173,9 @@ export function RegisterPage() {
|
||||
<div className="mb-6 font-mono text-xs space-y-1 text-zinc-500 border-b border-zinc-800/50 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span>System Check: <span className="text-emerald-500">READY</span></span>
|
||||
<span>
|
||||
System Check: <span className="text-emerald-500">READY</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
@@ -165,7 +185,9 @@ export function RegisterPage() {
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('email', language)}</label>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -178,7 +200,9 @@ export function RegisterPage() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('password', language)}</label>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -199,7 +223,9 @@ export function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('confirmPassword', language)}</label>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
@@ -211,10 +237,16 @@ export function RegisterPage() {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,7 +259,14 @@ export function RegisterPage() {
|
||||
</div>
|
||||
<div className="text-xs font-mono text-zinc-400">
|
||||
<PasswordChecklist
|
||||
rules={['minLength', 'capital', 'lowercase', 'number', 'specialChar', 'match']}
|
||||
rules={[
|
||||
'minLength',
|
||||
'capital',
|
||||
'lowercase',
|
||||
'number',
|
||||
'specialChar',
|
||||
'match',
|
||||
]}
|
||||
minLength={8}
|
||||
value={password}
|
||||
valueAgain={confirmPassword}
|
||||
@@ -248,17 +287,25 @@ export function RegisterPage() {
|
||||
|
||||
{betaMode && (
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">Priority Access Code</label>
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">
|
||||
Priority Access Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={betaCode}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">* CASE SENSITIVE ALPHANUMERIC</p>
|
||||
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">
|
||||
* CASE SENSITIVE ALPHANUMERIC
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -270,7 +317,9 @@ export function RegisterPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (betaMode && !betaCode.trim()) || !passwordValid}
|
||||
disabled={
|
||||
loading || (betaMode && !betaCode.trim()) || !passwordValid
|
||||
}
|
||||
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_15px_rgba(255,215,0,0.1)] hover:shadow-[0_0_25px_rgba(255,215,0,0.25)] flex items-center justify-center gap-2 group mt-4"
|
||||
>
|
||||
{loading ? (
|
||||
@@ -278,7 +327,9 @@ export function RegisterPage() {
|
||||
) : (
|
||||
<>
|
||||
<span>CREATE_ACCOUNT</span>
|
||||
<span className="group-hover:translate-x-1 transition-transform">-></span>
|
||||
<span className="group-hover:translate-x-1 transition-transform">
|
||||
->
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -295,14 +346,14 @@ export function RegisterPage() {
|
||||
<p className="text-xs font-mono text-zinc-500">
|
||||
EXISTING_OPERATOR?{' '}
|
||||
<button
|
||||
onClick={() => (window.location.href = '/login')}
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
|
||||
>
|
||||
ACCESS TERMINAL
|
||||
</button>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => (window.location.href = '/')}
|
||||
onClick={() => navigate('/')}
|
||||
className="text-[10px] text-zinc-600 hover:text-red-500 transition-colors uppercase tracking-widest hover:underline decoration-red-500/30 font-mono"
|
||||
>
|
||||
[ ABORT_REGISTRATION_RETURN_HOME ]
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { RegistrationDisabled } from './RegistrationDisabled'
|
||||
import { LanguageProvider } from '../../contexts/LanguageContext'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useLanguage hook
|
||||
vi.mock('../../contexts/LanguageContext', async () => {
|
||||
const actual = await vi.importActual('../../contexts/LanguageContext')
|
||||
@@ -21,9 +32,11 @@ vi.mock('../../contexts/LanguageContext', async () => {
|
||||
describe('RegistrationDisabled Component', () => {
|
||||
const renderComponent = () => {
|
||||
return render(
|
||||
<LanguageProvider>
|
||||
<RegistrationDisabled />
|
||||
</LanguageProvider>
|
||||
<MemoryRouter>
|
||||
<LanguageProvider>
|
||||
<RegistrationDisabled />
|
||||
</LanguageProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,7 +61,9 @@ describe('RegistrationDisabled Component', () => {
|
||||
|
||||
it('should display registration closed message', () => {
|
||||
renderComponent()
|
||||
const message = screen.getByText(/User registration is currently disabled/i)
|
||||
const message = screen.getByText(
|
||||
/User registration is currently disabled/i
|
||||
)
|
||||
expect(message).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -61,19 +76,12 @@ describe('RegistrationDisabled Component', () => {
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate to login page when button is clicked', () => {
|
||||
const pushStateSpy = vi.spyOn(window.history, 'pushState')
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
|
||||
renderComponent()
|
||||
const button = screen.getByRole('button', { name: /back to login/i })
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/login')
|
||||
expect(dispatchEventSpy).toHaveBeenCalled()
|
||||
|
||||
pushStateSpy.mockRestore()
|
||||
dispatchEventSpy.mockRestore()
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export function RegistrationDisabled() {
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
@@ -11,6 +12,7 @@ import { toast } from 'sonner'
|
||||
export function ResetPasswordPage() {
|
||||
const { language } = useLanguage()
|
||||
const { resetPassword } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
@@ -41,8 +43,7 @@ export function ResetPasswordPage() {
|
||||
toast.success(t('resetPasswordSuccess', language) || '重置成功')
|
||||
// 3秒后跳转到登录页面
|
||||
setTimeout(() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate('/login')
|
||||
}, 3000)
|
||||
} else {
|
||||
const msg = result.message || t('resetPasswordFailed', language)
|
||||
@@ -64,10 +65,7 @@ export function ResetPasswordPage() {
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back to Login */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}}
|
||||
onClick={() => navigate('/login')}
|
||||
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
@@ -10,17 +10,7 @@ import {
|
||||
setUserMode,
|
||||
type UserMode,
|
||||
} from '../../lib/onboarding'
|
||||
|
||||
type Page =
|
||||
| 'competition'
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
import { getCurrentPageForPath, ROUTES, type Page } from '../../router/paths'
|
||||
|
||||
interface HeaderBarProps {
|
||||
onLoginClick?: () => void
|
||||
@@ -47,16 +37,20 @@ export default function HeaderBar({
|
||||
onLoginRequired,
|
||||
}: HeaderBarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(
|
||||
() => getUserMode() ?? 'advanced'
|
||||
)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const resolvedCurrentPage =
|
||||
currentPage ?? getCurrentPageForPath(location.pathname)
|
||||
|
||||
const navigateInApp = (path: string) => {
|
||||
navigate(path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleSwitchMode = (nextMode: UserMode) => {
|
||||
@@ -94,14 +88,12 @@ export default function HeaderBar({
|
||||
{/* Logo - Always go to home page */}
|
||||
<div
|
||||
onClick={() => {
|
||||
window.location.href = '/'
|
||||
navigateInApp(ROUTES.home)
|
||||
}}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
|
||||
<span className="text-lg font-bold text-nofx-gold">
|
||||
NOFX
|
||||
</span>
|
||||
<span className="text-lg font-bold text-nofx-gold">NOFX</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
@@ -111,17 +103,67 @@ export default function HeaderBar({
|
||||
{/* Navigation tabs configuration */}
|
||||
{(() => {
|
||||
// Define all navigation tabs
|
||||
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 handleNavClick = (tab: typeof navTabs[0]) => {
|
||||
const handleNavClick = (tab: (typeof navTabs)[0]) => {
|
||||
// If requires auth and not logged in, show login prompt
|
||||
if (tab.requiresAuth && !isLoggedIn) {
|
||||
onLoginRequired?.(tab.label)
|
||||
@@ -131,7 +173,7 @@ export default function HeaderBar({
|
||||
if (onPageChange) {
|
||||
onPageChange(tab.page)
|
||||
}
|
||||
navigate(tab.path)
|
||||
navigateInApp(tab.path)
|
||||
}
|
||||
|
||||
return navTabs.map((tab) => (
|
||||
@@ -139,12 +181,10 @@ export default function HeaderBar({
|
||||
key={tab.page}
|
||||
onClick={() => handleNavClick(tab)}
|
||||
className={`text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 px-3 py-2 rounded-lg
|
||||
${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
|
||||
${resolvedCurrentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
|
||||
>
|
||||
{currentPage === tab.page && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
|
||||
/>
|
||||
{resolvedCurrentPage === tab.page && (
|
||||
<span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" />
|
||||
)}
|
||||
{tab.label}
|
||||
</button>
|
||||
@@ -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"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
</a>
|
||||
@@ -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"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
@@ -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"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
</a>
|
||||
@@ -227,7 +282,7 @@ export default function HeaderBar({
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = '/settings'
|
||||
navigateInApp(ROUTES.settings)
|
||||
setUserDropdownOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
|
||||
@@ -236,13 +291,21 @@ export default function HeaderBar({
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSwitchMode(userMode === 'beginner' ? 'advanced' : 'beginner')}
|
||||
onClick={() =>
|
||||
handleSwitchMode(
|
||||
userMode === 'beginner' ? 'advanced' : 'beginner'
|
||||
)
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
{userMode === 'beginner'
|
||||
? language === 'zh' ? '切到老手模式' : 'Switch to Advanced'
|
||||
: language === 'zh' ? '切到新手模式' : 'Switch to Beginner'}
|
||||
? language === 'zh'
|
||||
? '切到老手模式'
|
||||
: 'Switch to Advanced'
|
||||
: language === 'zh'
|
||||
? '切到新手模式'
|
||||
: 'Switch to Beginner'}
|
||||
</button>
|
||||
{onLogout && (
|
||||
<button
|
||||
@@ -261,15 +324,16 @@ export default function HeaderBar({
|
||||
</div>
|
||||
) : (
|
||||
/* Show login/register buttons when not logged in and not on login/register pages */
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
resolvedCurrentPage !== 'login' &&
|
||||
resolvedCurrentPage !== 'register' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/login"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateInApp(ROUTES.login)}
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -361,17 +425,67 @@ export default function HeaderBar({
|
||||
{/* Navigation Links */}
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
{(() => {
|
||||
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 && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
|
||||
@@ -438,9 +552,24 @@ export default function HeaderBar({
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
{[
|
||||
{ href: OFFICIAL_LINKS.github, icon: <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" /> },
|
||||
{ href: OFFICIAL_LINKS.twitter, icon: <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> },
|
||||
{ href: OFFICIAL_LINKS.telegram, icon: <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /> }
|
||||
{
|
||||
href: OFFICIAL_LINKS.github,
|
||||
icon: (
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
),
|
||||
},
|
||||
{
|
||||
href: OFFICIAL_LINKS.twitter,
|
||||
icon: (
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
),
|
||||
},
|
||||
{
|
||||
href: OFFICIAL_LINKS.telegram,
|
||||
icon: (
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
),
|
||||
},
|
||||
].map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
@@ -449,7 +578,12 @@ export default function HeaderBar({
|
||||
rel="noopener noreferrer"
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 hover:text-nofx-gold hover:border-nofx-gold transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
{link.icon}
|
||||
</svg>
|
||||
</a>
|
||||
@@ -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'}
|
||||
</button>
|
||||
@@ -489,13 +624,18 @@ export default function HeaderBar({
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
) : (
|
||||
currentPage !== 'login' && currentPage !== 'register' && (
|
||||
<a
|
||||
href="/login"
|
||||
resolvedCurrentPage !== 'login' &&
|
||||
resolvedCurrentPage !== 'register' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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)}
|
||||
</a>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<footer
|
||||
className="mt-16"
|
||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||
>
|
||||
<div
|
||||
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
|
||||
style={{ color: '#5E6673' }}
|
||||
>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={OFFICIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#F0B90B'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#1DA1F2'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
Twitter
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_LINKS.telegram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#0088cc'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<div className="bg-zinc-900/40 backdrop-blur-md border border-red-500/30 rounded-lg overflow-hidden relative group">
|
||||
|
||||
{/* Top Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-red-900/20 border-b border-red-500/30">
|
||||
<div className="flex gap-1.5 opacity-50">
|
||||
@@ -60,9 +62,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-zinc-400 mb-8 leading-relaxed font-mono px-4">
|
||||
<span className="text-red-400">[SYSTEM_MESSAGE]:</span> YOUR IDENTIFIER IS NOT ON THE ACTIVE WHITELIST.
|
||||
<br /><br />
|
||||
Platform capacity limits have been reached for the current beta phase. Prioritized access is currently reserved for authorized operators only.
|
||||
<span className="text-red-400">[SYSTEM_MESSAGE]:</span> YOUR
|
||||
IDENTIFIER IS NOT ON THE ACTIVE WHITELIST.
|
||||
<br />
|
||||
<br />
|
||||
Platform capacity limits have been reached for the current beta
|
||||
phase. Prioritized access is currently reserved for authorized
|
||||
operators only.
|
||||
</p>
|
||||
|
||||
{/* Info Box */}
|
||||
@@ -70,9 +76,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="w-4 h-4 text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-red-400 uppercase mb-1">Authorization Protocol</h3>
|
||||
<h3 className="text-xs font-bold text-red-400 uppercase mb-1">
|
||||
Authorization Protocol
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 leading-tight">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,14 +119,12 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-black/80 p-2 text-[9px] text-zinc-700 text-center border-t border-zinc-800 font-mono uppercase">
|
||||
ERR_CODE: WLIST_0x403 // SECURITY_LAYER_ACTIVE
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(240, 185, 11, 0.08) 0%, transparent 70%)',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(240, 185, 11, 0.08) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* 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"
|
||||
>
|
||||
<motion.a
|
||||
href="/competition"
|
||||
<motion.div
|
||||
className="group flex items-center gap-3 px-8 py-4 rounded-xl font-bold text-lg transition-all"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
@@ -152,10 +153,12 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
{t('liveCompetition', language) || 'Live Competition'}
|
||||
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||
</motion.a>
|
||||
<Link to="/competition" className="flex items-center gap-3">
|
||||
<Play className="w-5 h-5" />
|
||||
{t('liveCompetition', language) || 'Live Competition'}
|
||||
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.a
|
||||
href={OFFICIAL_LINKS.github}
|
||||
@@ -188,9 +191,18 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
>
|
||||
{[
|
||||
{ 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) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
@@ -202,7 +214,8 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
<div
|
||||
className="text-3xl sm:text-4xl font-bold mb-1"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
background:
|
||||
'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t, Language } from '../../i18n/translations'
|
||||
interface LoginModalProps {
|
||||
onClose: () => void
|
||||
@@ -7,6 +8,7 @@ interface LoginModalProps {
|
||||
}
|
||||
|
||||
export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -49,8 +51,7 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
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"
|
||||
|
||||
@@ -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 (
|
||||
<section id="market-scanner" className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden">
|
||||
return (
|
||||
<section
|
||||
id="market-scanner"
|
||||
className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden"
|
||||
>
|
||||
{/* Background Details */}
|
||||
<div className="absolute top-0 right-0 p-10 opacity-20 pointer-events-none">
|
||||
<Hexagon className="w-64 h-64 text-zinc-800" strokeWidth={0.5} />
|
||||
</div>
|
||||
|
||||
{/* Background Details */}
|
||||
<div className="absolute top-0 right-0 p-10 opacity-20 pointer-events-none">
|
||||
<Hexagon className="w-64 h-64 text-zinc-800" strokeWidth={0.5} />
|
||||
<div className="max-w-7xl mx-auto px-6 relative z-10">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-10 md:mb-16 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase">
|
||||
<Crosshair className="w-4 h-4" /> MARKET SELECT
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black text-white uppercase tracking-tighter">
|
||||
STRATEGY{' '}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">
|
||||
UNITS
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="font-mono text-right text-xs text-zinc-500 max-w-xs">
|
||||
SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE
|
||||
PRE-TRAINED ON HISTORICAL TICKS.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 relative z-10">
|
||||
{/* Grid Container - Removing scroll tracking for stability test */}
|
||||
<div className="flex flex-row md:grid md:grid-cols-3 gap-4 md:gap-8 overflow-x-auto md:overflow-visible pb-12 md:pb-0 snap-x snap-mandatory -mx-6 px-6 md:mx-0 md:px-0 scrollbar-hide">
|
||||
{agents.map((agent, i) => {
|
||||
const Icon = agent.icon
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-10 md:mb-16 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase">
|
||||
<Crosshair className="w-4 h-4" /> MARKET SELECT
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black text-white uppercase tracking-tighter">
|
||||
STRATEGY <span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">UNITS</span>
|
||||
</h2>
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className={`group relative bg-black/40 backdrop-blur-xl border ${agent.border} overflow-hidden transition-all duration-300 min-w-[85vw] md:min-w-0 snap-center shrink-0 rounded-xl md:rounded-none`}
|
||||
>
|
||||
{/* Top "Hinge" decoration */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>
|
||||
|
||||
<div className="p-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="p-3 bg-zinc-900/80 rounded border border-zinc-700">
|
||||
<Icon className={`w-8 h-8 ${agent.color}`} />
|
||||
</div>
|
||||
<div className="font-mono text-right text-xs text-zinc-500 max-w-xs">
|
||||
SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE PRE-TRAINED ON HISTORICAL TICKS.
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-mono text-zinc-500 uppercase">
|
||||
Class
|
||||
</div>
|
||||
<div
|
||||
className={`font-bold font-mono tracking-wider ${agent.color}`}
|
||||
>
|
||||
{agent.class}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name & Desc */}
|
||||
<h3 className="text-3xl font-bold text-white mb-2 tracking-tight group-hover:text-nofx-accent transition-colors">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-sm mb-8 leading-relaxed h-10">
|
||||
{agent.desc}
|
||||
</p>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-px bg-zinc-800/50 border border-zinc-800 rounded overflow-hidden mb-8">
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
|
||||
APY
|
||||
</div>
|
||||
<div className="text-green-400 font-bold">
|
||||
{agent.apy}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
|
||||
Win %
|
||||
</div>
|
||||
<div className="text-white font-bold">
|
||||
{agent.winRate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
|
||||
Risk
|
||||
</div>
|
||||
<div className={`${agent.color} font-bold`}>
|
||||
{agent.risk}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Btn */}
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${agent.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white cursor-pointer`}
|
||||
>
|
||||
<span className={agent.color}>[</span> INITIALIZE{' '}
|
||||
<span className={agent.color}>]</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid Container - Removing scroll tracking for stability test */}
|
||||
<div className="flex flex-row md:grid md:grid-cols-3 gap-4 md:gap-8 overflow-x-auto md:overflow-visible pb-12 md:pb-0 snap-x snap-mandatory -mx-6 px-6 md:mx-0 md:px-0 scrollbar-hide">
|
||||
{agents.map((agent, i) => {
|
||||
const Icon = agent.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className={`group relative bg-black/40 backdrop-blur-xl border ${agent.border} overflow-hidden transition-all duration-300 min-w-[85vw] md:min-w-0 snap-center shrink-0 rounded-xl md:rounded-none`}
|
||||
>
|
||||
{/* Top "Hinge" decoration */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>
|
||||
|
||||
<div className="p-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="p-3 bg-zinc-900/80 rounded border border-zinc-700">
|
||||
<Icon className={`w-8 h-8 ${agent.color}`} />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-mono text-zinc-500 uppercase">Class</div>
|
||||
<div className={`font-bold font-mono tracking-wider ${agent.color}`}>{agent.class}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name & Desc */}
|
||||
<h3 className="text-3xl font-bold text-white mb-2 tracking-tight group-hover:text-nofx-accent transition-colors">{agent.name}</h3>
|
||||
<p className="text-zinc-500 text-sm mb-8 leading-relaxed h-10">{agent.desc}</p>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-px bg-zinc-800/50 border border-zinc-800 rounded overflow-hidden mb-8">
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">APY</div>
|
||||
<div className="text-green-400 font-bold">{agent.apy}</div>
|
||||
</div>
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">Win %</div>
|
||||
<div className="text-white font-bold">{agent.winRate}</div>
|
||||
</div>
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">Risk</div>
|
||||
<div className={`${agent.color} font-bold`}>{agent.risk}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Btn */}
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${agent.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white cursor-pointer`}
|
||||
>
|
||||
<span className={agent.color}>[</span> INITIALIZE <span className={agent.color}>]</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full blur-2xl group-hover:opacity-50 transition-opacity opacity-20"></div>
|
||||
<div className="absolute inset-0 bg-scanlines opacity-20 pointer-events-none"></div>
|
||||
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full blur-2xl group-hover:opacity-50 transition-opacity opacity-20"></div>
|
||||
<div className="absolute inset-0 bg-scanlines opacity-20 pointer-events-none"></div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AIModel[]>([])
|
||||
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
|
||||
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
|
||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
|
||||
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => 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<TraderInfo[]>(
|
||||
user && token ? 'traders' : null,
|
||||
api.getTraders,
|
||||
{ refreshInterval: 5000 }
|
||||
)
|
||||
const {
|
||||
data: traders,
|
||||
mutate: mutateTraders,
|
||||
isLoading: isTradersLoading,
|
||||
} = useSWR<TraderInfo[]>(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 (
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
@@ -952,7 +1051,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
|
||||
disabled={
|
||||
configuredModels.length === 0 ||
|
||||
configuredExchanges.length === 0
|
||||
}
|
||||
className="group relative px-6 py-2 rounded text-xs font-bold font-mono uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap overflow-hidden bg-nofx-gold text-black hover:bg-yellow-400 shadow-[0_0_20px_rgba(240,185,11,0.2)] hover:shadow-[0_0_30px_rgba(240,185,11,0.4)]"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
@@ -984,15 +1086,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
<div
|
||||
className="mb-6 rounded-xl border px-4 py-4 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start md:justify-between gap-3"
|
||||
style={{
|
||||
borderColor: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.55)' : 'rgba(245, 158, 11, 0.45)',
|
||||
background: claw402BalanceAlert.blocking ? 'rgba(127, 29, 29, 0.22)' : 'rgba(120, 53, 15, 0.18)',
|
||||
borderColor: claw402BalanceAlert.blocking
|
||||
? 'rgba(239, 68, 68, 0.55)'
|
||||
: 'rgba(245, 158, 11, 0.45)',
|
||||
background: claw402BalanceAlert.blocking
|
||||
? 'rgba(127, 29, 29, 0.22)'
|
||||
: 'rgba(120, 53, 15, 0.18)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="mt-0.5 rounded-full p-2"
|
||||
style={{
|
||||
background: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.16)' : 'rgba(245, 158, 11, 0.14)',
|
||||
background: claw402BalanceAlert.blocking
|
||||
? 'rgba(239, 68, 68, 0.16)'
|
||||
: 'rgba(245, 158, 11, 0.14)',
|
||||
color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
|
||||
}}
|
||||
>
|
||||
@@ -1001,11 +1109,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
<div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A' }}
|
||||
style={{
|
||||
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
|
||||
}}
|
||||
>
|
||||
{claw402BalanceAlert.title}
|
||||
</div>
|
||||
<div className="text-sm mt-1 leading-6" style={{ color: '#D4D4D8' }}>
|
||||
<div
|
||||
className="text-sm mt-1 leading-6"
|
||||
style={{ color: '#D4D4D8' }}
|
||||
>
|
||||
{claw402BalanceAlert.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1013,10 +1126,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => enabledClaw402Model && handleModelClick(enabledClaw402Model.id)}
|
||||
onClick={() =>
|
||||
enabledClaw402Model && handleModelClick(enabledClaw402Model.id)
|
||||
}
|
||||
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
|
||||
style={{
|
||||
borderColor: claw402BalanceAlert.blocking ? 'rgba(248, 113, 113, 0.45)' : 'rgba(251, 191, 36, 0.35)',
|
||||
borderColor: claw402BalanceAlert.blocking
|
||||
? 'rgba(248, 113, 113, 0.45)'
|
||||
: 'rgba(251, 191, 36, 0.35)',
|
||||
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
|
||||
background: 'rgba(0, 0, 0, 0.18)',
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getSystemConfig, invalidateSystemConfig } from '../lib/config'
|
||||
import { reset401Flag, httpClient } from '../lib/httpClient'
|
||||
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
|
||||
import { ROUTES } from '../router/paths'
|
||||
import { useLanguage } from './LanguageContext'
|
||||
|
||||
interface User {
|
||||
@@ -43,6 +45,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -120,8 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', nextPath)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(nextPath)
|
||||
}
|
||||
|
||||
const login = async (email: string, password: string, mode?: UserMode) => {
|
||||
@@ -145,7 +147,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Unexpected success response
|
||||
return { success: false, message: data.message || 'Unexpected login response' }
|
||||
return {
|
||||
success: false,
|
||||
message: data.message || 'Unexpected login response',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
@@ -184,12 +189,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(returnUrl)
|
||||
} else {
|
||||
// Redirect to dashboard
|
||||
window.history.pushState({}, '', '/dashboard')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(ROUTES.dashboard)
|
||||
}
|
||||
return { success: true }
|
||||
} else {
|
||||
@@ -244,13 +247,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
message: result.message || 'Registration failed',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth register error:', error);
|
||||
console.error('Auth register error:', error)
|
||||
// Re-throw if it's a critical error, or return structured error
|
||||
// Since httpClient throws on 500, we should return a structured error response
|
||||
// to let the UI display it gracefully without crashing.
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Detailed server error'
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Detailed server error',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +280,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return { success: false, message: data.error }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Password reset failed, please try again' }
|
||||
return {
|
||||
success: false,
|
||||
message: 'Password reset failed, please try again',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,8 +302,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
invalidateSystemConfig()
|
||||
window.history.pushState({}, '', '/')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(ROUTES.home)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ArrowRight, Copy, RefreshCw, Shield, Wallet, X } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import type { BeginnerOnboardingResponse } from '../types'
|
||||
import { setBeginnerWalletAddress, markBeginnerOnboardingCompleted } from '../lib/onboarding'
|
||||
import {
|
||||
setBeginnerWalletAddress,
|
||||
markBeginnerOnboardingCompleted,
|
||||
} from '../lib/onboarding'
|
||||
|
||||
export function BeginnerOnboardingPage() {
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -79,8 +77,7 @@ export function BeginnerOnboardingPage() {
|
||||
|
||||
const handleContinue = () => {
|
||||
markBeginnerOnboardingCompleted()
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate('/traders')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -104,7 +101,9 @@ export function BeginnerOnboardingPage() {
|
||||
<div>
|
||||
<div
|
||||
className={`font-semibold uppercase text-nofx-gold/80 ${
|
||||
isZh ? 'text-[11px] tracking-[0.34em]' : 'text-[10px] tracking-[0.2em]'
|
||||
isZh
|
||||
? 'text-[11px] tracking-[0.34em]'
|
||||
: 'text-[10px] tracking-[0.2em]'
|
||||
}`}
|
||||
>
|
||||
{isZh ? '新手保护' : 'Beginner Guard'}
|
||||
@@ -136,7 +135,9 @@ export function BeginnerOnboardingPage() {
|
||||
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,11,16,0.94),rgba(5,7,10,0.88))] shadow-[0_24px_120px_rgba(0,0,0,0.58)] backdrop-blur-2xl">
|
||||
{loading ? (
|
||||
<div className="flex min-h-[390px] items-center justify-center px-6 text-sm text-zinc-400">
|
||||
{isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
|
||||
{isZh
|
||||
? '正在准备你的 Base 钱包...'
|
||||
: 'Preparing your Base wallet...'}
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="grid lg:grid-cols-[0.82fr_1.18fr]">
|
||||
@@ -147,13 +148,17 @@ export function BeginnerOnboardingPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-[15px] font-medium text-zinc-300">
|
||||
{isZh ? '充值地址(Base USDC)' : 'Deposit address (Base USDC)'}
|
||||
{isZh
|
||||
? '充值地址(Base USDC)'
|
||||
: 'Deposit address (Base USDC)'}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-[24px] border border-emerald-400/20 bg-emerald-500/7 px-5 py-3.5 shadow-[0_0_0_1px_rgba(16,185,129,0.08)]">
|
||||
<div className="text-left">
|
||||
<div className="flex items-baseline gap-3 font-mono font-bold tracking-tight text-emerald-300">
|
||||
<span className="text-[22px]">{data.balance_usdc}</span>
|
||||
<span className="text-[22px]">
|
||||
{data.balance_usdc}
|
||||
</span>
|
||||
<span className="text-[20px]">USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,12 +169,16 @@ export function BeginnerOnboardingPage() {
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-emerald-300/20 bg-black/20 text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label={isZh ? '刷新余额' : 'Refresh balance'}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-zinc-500">
|
||||
{isZh ? '$5-$10 可以用很久' : '$5-$10 usually lasts a long time'}
|
||||
{isZh
|
||||
? '$5-$10 可以用很久'
|
||||
: '$5-$10 usually lasts a long time'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -187,7 +196,9 @@ export function BeginnerOnboardingPage() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.address, isZh ? '地址' : 'Address')}
|
||||
onClick={() =>
|
||||
copyText(data.address, isZh ? '地址' : 'Address')
|
||||
}
|
||||
className="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-zinc-300 transition hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
aria-label={isZh ? '复制地址' : 'Copy address'}
|
||||
>
|
||||
@@ -199,16 +210,27 @@ export function BeginnerOnboardingPage() {
|
||||
<div className="pt-1">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-nofx-gold">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>{isZh ? '私钥,请立即备份' : 'Private key, back it up now'}</span>
|
||||
<span>
|
||||
{isZh
|
||||
? '私钥,请立即备份'
|
||||
: 'Private key, back it up now'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-stretch gap-3">
|
||||
<div className="min-w-0 flex-1 rounded-[24px] border border-nofx-gold/20 bg-[linear-gradient(180deg,rgba(32,25,7,0.44),rgba(14,10,3,0.28))] px-5 py-3 font-mono text-[13px] leading-6 text-amber-100 shadow-[0_0_0_1px_rgba(240,185,11,0.05)]">
|
||||
<div className="overflow-x-auto whitespace-nowrap">{data.private_key}</div>
|
||||
<div className="overflow-x-auto whitespace-nowrap">
|
||||
{data.private_key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.private_key, isZh ? '私钥' : 'Private key')}
|
||||
onClick={() =>
|
||||
copyText(
|
||||
data.private_key,
|
||||
isZh ? '私钥' : 'Private key'
|
||||
)
|
||||
}
|
||||
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-nofx-gold/20 bg-nofx-gold/10 text-nofx-gold transition hover:bg-nofx-gold/15"
|
||||
aria-label={isZh ? '复制私钥' : 'Copy private key'}
|
||||
>
|
||||
@@ -220,7 +242,9 @@ export function BeginnerOnboardingPage() {
|
||||
|
||||
<div
|
||||
className={`rounded-[24px] border border-white/15 bg-black/18 px-5 py-3.5 text-zinc-500 ${
|
||||
isZh ? 'text-xs lg:whitespace-nowrap' : 'text-[11px] leading-6'
|
||||
isZh
|
||||
? 'text-xs lg:whitespace-nowrap'
|
||||
: 'text-[11px] leading-6'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2 text-zinc-600">•</span>
|
||||
@@ -246,7 +270,9 @@ export function BeginnerOnboardingPage() {
|
||||
isZh ? 'text-[20px]' : 'text-[16px] sm:text-[18px]'
|
||||
}`}
|
||||
>
|
||||
<span>{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}</span>
|
||||
<span>
|
||||
{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}
|
||||
</span>
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -34,24 +34,8 @@ export function LandingPage() {
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={(page) => {
|
||||
const pathMap: Record<string, string> = {
|
||||
'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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen bg-nofx-bg text-nofx-text font-sans selection:bg-nofx-gold selection:text-black">
|
||||
|
||||
<TerminalHero />
|
||||
|
||||
<LiveFeed />
|
||||
|
||||
+157
-58
@@ -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<Tab>('account')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(
|
||||
() => 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 (
|
||||
<div className="min-h-screen pt-20 pb-12 px-4" style={{ background: '#0B0E11' }}>
|
||||
<div
|
||||
className="min-h-screen pt-20 pb-12 px-4"
|
||||
style={{ background: '#0B0E11' }}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-xl font-bold text-white mb-6">Settings</h1>
|
||||
|
||||
@@ -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 */}
|
||||
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
|
||||
|
||||
{/* Account Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div className="space-y-6">
|
||||
@@ -322,8 +367,12 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
|
||||
{userMode === 'beginner'
|
||||
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner'
|
||||
: language === 'zh' ? '当前:老手模式' : 'Current: Advanced'}
|
||||
? language === 'zh'
|
||||
? '当前:新手模式'
|
||||
: 'Current: Beginner'
|
||||
: language === 'zh'
|
||||
? '当前:老手模式'
|
||||
: 'Current: Advanced'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -369,10 +418,14 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||
<h3 className="text-sm font-semibold text-white mb-4">
|
||||
Change Password
|
||||
</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">New Password</label>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -387,7 +440,11 @@ export function SettingsPage() {
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
{showPassword ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,10 +465,14 @@ export function SettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured
|
||||
{configuredModels.length} model
|
||||
{configuredModels.length !== 1 ? 's' : ''} configured
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setEditingModel(null); setShowModelModal(true) }}
|
||||
onClick={() => {
|
||||
setEditingModel(null)
|
||||
setShowModelModal(true)
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -428,7 +489,10 @@ export function SettingsPage() {
|
||||
{configuredModels.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => { setEditingModel(model.id); setShowModelModal(true) }}
|
||||
onClick={() => {
|
||||
setEditingModel(model.id)
|
||||
setShowModelModal(true)
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -436,15 +500,24 @@ export function SettingsPage() {
|
||||
<Cpu size={14} className="text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{model.name}</p>
|
||||
<p className="text-xs text-zinc-500">{model.provider}</p>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{model.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{model.provider}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}
|
||||
>
|
||||
{model.enabled ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<Pencil size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
<Pencil
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -458,10 +531,14 @@ export function SettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected
|
||||
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '}
|
||||
connected
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setEditingExchange(null); setShowExchangeModal(true) }}
|
||||
onClick={() => {
|
||||
setEditingExchange(null)
|
||||
setShowExchangeModal(true)
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -478,7 +555,10 @@ export function SettingsPage() {
|
||||
{exchanges.map((exchange) => (
|
||||
<button
|
||||
key={exchange.id}
|
||||
onClick={() => { setEditingExchange(exchange.id); setShowExchangeModal(true) }}
|
||||
onClick={() => {
|
||||
setEditingExchange(exchange.id)
|
||||
setShowExchangeModal(true)
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -486,11 +566,18 @@ export function SettingsPage() {
|
||||
<Building2 size={14} className="text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p>
|
||||
<p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{exchange.account_name || exchange.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 capitalize">
|
||||
{exchange.exchange_type || exchange.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -502,7 +589,8 @@ export function SettingsPage() {
|
||||
{activeTab === 'telegram' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
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.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowTelegramModal(true)}
|
||||
@@ -512,9 +600,14 @@ export function SettingsPage() {
|
||||
<div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center">
|
||||
<MessageCircle size={14} className="text-[#0088cc]" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white">Configure Telegram Bot</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
Configure Telegram Bot
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<string, { color: string; border: string; glow: string; shadow: string; icon: any; bg: string }> = {
|
||||
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<string, { color: string; border: string; glow: stri
|
||||
glow: 'shadow-[0_0_20px_rgba(34,211,238,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(34,211,238,0.25)]',
|
||||
bg: 'bg-cyan-400/5',
|
||||
icon: TrendingUp
|
||||
icon: TrendingUp,
|
||||
},
|
||||
arbitrage: {
|
||||
color: 'text-purple-400',
|
||||
@@ -62,7 +73,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: 'shadow-[0_0_20px_rgba(192,132,252,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(192,132,252,0.25)]',
|
||||
bg: 'bg-purple-400/5',
|
||||
icon: Layers
|
||||
icon: Layers,
|
||||
},
|
||||
conservative: {
|
||||
color: 'text-emerald-400',
|
||||
@@ -70,7 +81,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: 'shadow-[0_0_20px_rgba(52,211,153,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(52,211,153,0.25)]',
|
||||
bg: 'bg-emerald-400/5',
|
||||
icon: Shield
|
||||
icon: Shield,
|
||||
},
|
||||
aggressive: {
|
||||
color: 'text-red-500',
|
||||
@@ -78,7 +89,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: 'shadow-[0_0_20px_rgba(239,68,68,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(239,68,68,0.25)]',
|
||||
bg: 'bg-red-500/5',
|
||||
icon: Target
|
||||
icon: Target,
|
||||
},
|
||||
default: {
|
||||
color: 'text-zinc-400',
|
||||
@@ -86,8 +97,8 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: '',
|
||||
shadow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.05)]',
|
||||
bg: 'bg-zinc-800/20',
|
||||
icon: Activity
|
||||
}
|
||||
icon: Activity,
|
||||
},
|
||||
}
|
||||
|
||||
function getStrategyStyle(name: string) {
|
||||
@@ -95,12 +106,15 @@ function getStrategyStyle(name: string) {
|
||||
if (lowerName.includes('scalp')) return strategyStyles.scalper
|
||||
if (lowerName.includes('swing')) return strategyStyles.swing
|
||||
if (lowerName.includes('arb')) return strategyStyles.arbitrage
|
||||
if (lowerName.includes('safe') || lowerName.includes('conserv')) return strategyStyles.conservative
|
||||
if (lowerName.includes('aggress') || lowerName.includes('high')) return strategyStyles.aggressive
|
||||
if (lowerName.includes('safe') || lowerName.includes('conserv'))
|
||||
return strategyStyles.conservative
|
||||
if (lowerName.includes('aggress') || lowerName.includes('high'))
|
||||
return strategyStyles.aggressive
|
||||
return strategyStyles.default
|
||||
}
|
||||
|
||||
export function StrategyMarketPage() {
|
||||
const navigate = useNavigate()
|
||||
const { language } = useLanguage()
|
||||
const { token, user } = useAuth()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -120,23 +134,28 @@ export function StrategyMarketPage() {
|
||||
},
|
||||
{
|
||||
refreshInterval: 60000,
|
||||
revalidateOnFocus: false
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
)
|
||||
|
||||
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 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 (
|
||||
<DeepVoidBackground className="min-h-screen text-white font-mono py-12">
|
||||
<div className="w-full px-4 md:px-8 space-y-8">
|
||||
|
||||
<div className="w-full relative z-10">
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="mb-12 border-b border-zinc-800 pb-8 relative">
|
||||
<div className="absolute top-0 right-0 p-2 border border-zinc-800 rounded bg-black/50 text-xs text-zinc-500 font-mono hidden md:block">
|
||||
SYSTEM_STATUS: <span className="text-emerald-500 animate-pulse">ONLINE</span>
|
||||
SYSTEM_STATUS:{' '}
|
||||
<span className="text-emerald-500 animate-pulse">ONLINE</span>
|
||||
<br />
|
||||
MARKET_UPLINK: <span className="text-emerald-500">ESTABLISHED</span>
|
||||
MARKET_UPLINK:{' '}
|
||||
<span className="text-emerald-500">ESTABLISHED</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
@@ -191,11 +212,15 @@ export function StrategyMarketPage() {
|
||||
<Database className="w-8 h-8 text-nofx-gold relative z-10" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text" data-text={tr('title')}>
|
||||
<h1
|
||||
className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text"
|
||||
data-text={tr('title')}
|
||||
>
|
||||
{tr('title')}
|
||||
</h1>
|
||||
<p className="text-xs text-nofx-gold tracking-[0.3em] font-bold mt-1">
|
||||
// {tr('subtitle')}
|
||||
{'// '}
|
||||
{tr('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,16 +257,21 @@ export function StrategyMarketPage() {
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${selectedCategory === cat
|
||||
? 'text-black font-bold'
|
||||
: 'text-zinc-500 hover:text-white'
|
||||
}`}
|
||||
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${
|
||||
selectedCategory === cat
|
||||
? 'text-black font-bold'
|
||||
: 'text-zinc-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{selectedCategory === cat && (
|
||||
<motion.div
|
||||
layoutId="filter-highlight"
|
||||
className="absolute inset-0 bg-nofx-gold"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{tr(cat)}</span>
|
||||
@@ -260,11 +290,22 @@ export function StrategyMarketPage() {
|
||||
<Cpu size={24} className="text-nofx-gold/50" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">{tr('loading')}</p>
|
||||
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">
|
||||
{tr('loading')}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div>
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0s' }}
|
||||
></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.4s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,7 +320,9 @@ export function StrategyMarketPage() {
|
||||
<h3 className="text-xl font-bold text-zinc-300 font-mono tracking-tight mb-2">
|
||||
[{tr('noStrategies')}]
|
||||
</h3>
|
||||
<p className="text-zinc-600 text-xs tracking-wide uppercase">{tr('noStrategiesDesc')}</p>
|
||||
<p className="text-zinc-600 text-xs tracking-wide uppercase">
|
||||
{tr('noStrategiesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<motion.div
|
||||
@@ -304,16 +348,24 @@ export function StrategyMarketPage() {
|
||||
className={`group relative bg-black border border-zinc-800 hover:border-zinc-600 transition-all duration-300 ${style.shadow}`}
|
||||
>
|
||||
{/* Holographic Border Highlight */}
|
||||
<div className={`absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
<div className={`absolute bottom-0 right-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
<div
|
||||
className={`absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
|
||||
></div>
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
|
||||
></div>
|
||||
|
||||
{/* Category Side Strip */}
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}></div>
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}
|
||||
></div>
|
||||
|
||||
<div className="p-6 relative">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-2 rounded-none border ${style.border} ${style.bg}`}>
|
||||
<div
|
||||
className={`p-2 rounded-none border ${style.border} ${style.bg}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${style.color}`} />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono">
|
||||
@@ -332,7 +384,9 @@ export function StrategyMarketPage() {
|
||||
</div>
|
||||
|
||||
{/* Name and Description */}
|
||||
<h3 className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}>
|
||||
<h3
|
||||
className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}
|
||||
>
|
||||
{strategy.name}
|
||||
<span className="absolute -bottom-1 left-0 w-8 h-[2px] bg-zinc-800 group-hover:bg-nofx-gold transition-colors"></span>
|
||||
</h3>
|
||||
@@ -343,12 +397,22 @@ export function StrategyMarketPage() {
|
||||
{/* Meta Data */}
|
||||
<div className="grid grid-cols-2 gap-y-2 mb-6 text-[10px] font-mono text-zinc-600">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-700 uppercase">{tr('author')}</span>
|
||||
<span className="text-zinc-400 group-hover:text-white transition-colors">@{strategy.author_email?.split('@')[0] || 'UNKNOWN'}</span>
|
||||
<span className="text-zinc-700 uppercase">
|
||||
{tr('author')}
|
||||
</span>
|
||||
<span className="text-zinc-400 group-hover:text-white transition-colors">
|
||||
@
|
||||
{strategy.author_email?.split('@')[0] ||
|
||||
'UNKNOWN'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-right">
|
||||
<span className="text-zinc-700 uppercase">{tr('createdAt')}</span>
|
||||
<span className="text-zinc-400">{formatDate(strategy.created_at)}</span>
|
||||
<span className="text-zinc-700 uppercase">
|
||||
{tr('createdAt')}
|
||||
</span>
|
||||
<span className="text-zinc-400">
|
||||
{formatDate(strategy.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -358,14 +422,20 @@ export function StrategyMarketPage() {
|
||||
<div className="space-y-3">
|
||||
{/* Indicators */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
{indicators.length > 0 ? indicators.map((ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
className="px-1.5 py-0.5 border border-zinc-700 bg-zinc-800 text-[9px] text-zinc-300 font-mono whitespace-nowrap"
|
||||
>
|
||||
{ind}
|
||||
{indicators.length > 0 ? (
|
||||
indicators.map((ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
className="px-1.5 py-0.5 border border-zinc-700 bg-zinc-800 text-[9px] text-zinc-300 font-mono whitespace-nowrap"
|
||||
>
|
||||
{ind}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
NO_INDICATORS
|
||||
</span>
|
||||
)) : <span className="text-[9px] text-zinc-600">NO_INDICATORS</span>}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk Control */}
|
||||
@@ -373,22 +443,38 @@ export function StrategyMarketPage() {
|
||||
<div className="flex justify-between items-center text-[10px]">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-600 scale-90 origin-left">LEV</span>
|
||||
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.btc_eth_max_leverage || '-'}x</span>
|
||||
<span className="text-zinc-600 scale-90 origin-left">
|
||||
LEV
|
||||
</span>
|
||||
<span className="text-zinc-300 font-bold">
|
||||
{strategy.config.risk_control
|
||||
.btc_eth_max_leverage || '-'}
|
||||
x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-600 scale-90 origin-left">POS</span>
|
||||
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.max_positions || '-'}</span>
|
||||
<span className="text-zinc-600 scale-90 origin-left">
|
||||
POS
|
||||
</span>
|
||||
<span className="text-zinc-300 font-bold">
|
||||
{strategy.config.risk_control
|
||||
.max_positions || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Activity size={12} className="text-zinc-700" />
|
||||
<Activity
|
||||
size={12}
|
||||
className="text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-600">
|
||||
<EyeOff size={16} className="mb-1 opacity-50" />
|
||||
<span className="text-[9px] uppercase tracking-widest">{tr('configHiddenDesc')}</span>
|
||||
<span className="text-[9px] uppercase tracking-widest">
|
||||
{tr('configHiddenDesc')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -403,7 +489,9 @@ export function StrategyMarketPage() {
|
||||
{copiedId === strategy.id ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-500">{tr('copied')}</span>
|
||||
<span className="text-emerald-500">
|
||||
{tr('copied')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -413,13 +501,15 @@ export function StrategyMarketPage() {
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button disabled className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-800 bg-black text-zinc-700 cursor-not-allowed flex items-center justify-center gap-2">
|
||||
<button
|
||||
disabled
|
||||
className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-800 bg-black text-zinc-700 cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Shield size={12} />
|
||||
{tr('hideConfig')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
@@ -436,13 +526,23 @@ export function StrategyMarketPage() {
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-16 mb-20 flex justify-center"
|
||||
>
|
||||
<div className="relative group cursor-pointer" onClick={() => window.location.href = '/strategy'}>
|
||||
<div
|
||||
className="relative group cursor-pointer"
|
||||
onClick={() => navigate('/strategy')}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-nofx-gold to-yellow-600 rounded blur opacity-25 group-hover:opacity-75 transition duration-1000 group-hover:duration-200"></div>
|
||||
<div className="relative px-8 py-4 bg-black border border-zinc-800 hover:border-nofx-gold/50 flex items-center gap-4 transition-all">
|
||||
<Hexagon className="text-nofx-gold animate-spin-slow" size={24} />
|
||||
<Hexagon
|
||||
className="text-nofx-gold animate-spin-slow"
|
||||
size={24}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">{tr('shareYours')}</div>
|
||||
<div className="text-[10px] text-zinc-500 font-mono">CONTRIBUTE TO THE GLOBAL DATABASE</div>
|
||||
<div className="text-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">
|
||||
{tr('shareYours')}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500 font-mono">
|
||||
CONTRIBUTE TO THE GLOBAL DATABASE
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[1px] h-8 bg-zinc-800 mx-2"></div>
|
||||
<div className="text-xs font-mono text-zinc-400 group-hover:translate-x-1 transition-transform">
|
||||
@@ -452,7 +552,6 @@ export function StrategyMarketPage() {
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: '#0B0E11' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 mx-auto mb-4 animate-pulse"
|
||||
/>
|
||||
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${location.pathname}${location.search}`}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage={currentPage}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
/>
|
||||
|
||||
{wrapInMain ? (
|
||||
<main className="min-h-screen pt-16">{content}</main>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
|
||||
{showFooter ? <SiteFooter language={language} /> : null}
|
||||
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
|
||||
{extraContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TradersRoute({
|
||||
showBeginnerOnboarding = false,
|
||||
}: {
|
||||
showBeginnerOnboarding?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const { user, token } = useAuth()
|
||||
const { data: traders } = useSWR<TraderInfo[]>(
|
||||
user && token ? 'traders-route' : null,
|
||||
api.getTraders,
|
||||
{
|
||||
refreshInterval: 5000,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<AppChrome
|
||||
currentPage="traders"
|
||||
animateContent
|
||||
extraContent={showBeginnerOnboarding ? <BeginnerOnboardingPage /> : null}
|
||||
>
|
||||
<AITradersPage
|
||||
onTraderSelect={(traderId) => {
|
||||
const trader = traders?.find((item) => item.trader_id === traderId)
|
||||
navigate(
|
||||
buildDashboardPath(trader ? getTraderSlug(trader) : undefined)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</AppChrome>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | undefined>()
|
||||
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||
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<TraderInfo[]>(
|
||||
user && token ? 'traders-dashboard' : null,
|
||||
() => api.getTraders(true),
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: exchanges } = useSWR<Exchange[]>(
|
||||
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<SystemStatus>(
|
||||
selectedTraderId ? `status-${selectedTraderId}` : null,
|
||||
() => api.getStatus(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: 15000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: account } = useSWR<AccountInfo>(
|
||||
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<Position[]>(
|
||||
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<DecisionRecord[]>(
|
||||
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<Statistics>(
|
||||
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 (
|
||||
<AppChrome currentPage="trader" animateContent>
|
||||
<TraderDashboardPage
|
||||
selectedTrader={selectedTrader}
|
||||
status={status}
|
||||
account={account}
|
||||
accountFailed={accountPollOff}
|
||||
positions={positions}
|
||||
positionsFailed={positionsPollOff}
|
||||
decisions={decisions}
|
||||
decisionsFailed={decisionsPollOff}
|
||||
decisionsLimit={decisionsLimit}
|
||||
onDecisionsLimitChange={setDecisionsLimit}
|
||||
stats={stats}
|
||||
lastUpdate={lastUpdate}
|
||||
language={language}
|
||||
traders={traders}
|
||||
tradersError={tradersError}
|
||||
selectedTraderId={selectedTraderId}
|
||||
onTraderSelect={(traderId) => {
|
||||
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}
|
||||
/>
|
||||
</AppChrome>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppRoutes() {
|
||||
const { user, token, isLoading } = useAuth()
|
||||
const { config: systemConfig, loading: configLoading } = useSystemConfig()
|
||||
const isAuthenticated = !!user && !!token
|
||||
|
||||
if (isLoading || configLoading) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
if (systemConfig && !systemConfig.initialized && !user) {
|
||||
return <SetupPage />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LegacyHashRedirect />
|
||||
<Routes>
|
||||
<Route path={ROUTES.home} element={<LandingPage />} />
|
||||
<Route path={ROUTES.login} element={<LoginPage />} />
|
||||
<Route path={ROUTES.register} element={<RegisterPage />} />
|
||||
<Route path={ROUTES.resetPassword} element={<ResetPasswordPage />} />
|
||||
<Route
|
||||
path={ROUTES.setup}
|
||||
element={
|
||||
systemConfig?.initialized ? (
|
||||
<Navigate to={ROUTES.login} replace />
|
||||
) : (
|
||||
<SetupPage />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.faq}
|
||||
element={
|
||||
<AppChrome currentPage="faq" showFooter={false} wrapInMain={false}>
|
||||
<FAQPage />
|
||||
</AppChrome>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.data}
|
||||
element={
|
||||
<AppChrome currentPage="data" showFooter={false}>
|
||||
<DataPage />
|
||||
</AppChrome>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.settings}
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<AppChrome showFooter={false}>
|
||||
<SettingsPage />
|
||||
</AppChrome>
|
||||
) : (
|
||||
<Navigate to={ROUTES.login} replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.welcome}
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
getUserMode() === 'beginner' ? (
|
||||
<TradersRoute showBeginnerOnboarding />
|
||||
) : (
|
||||
<Navigate to={ROUTES.traders} replace />
|
||||
)
|
||||
) : (
|
||||
<Navigate to={ROUTES.login} replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.competition}
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<AppChrome currentPage="competition" animateContent>
|
||||
<CompetitionPage />
|
||||
</AppChrome>
|
||||
) : (
|
||||
<LandingPage />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.strategyMarket}
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<AppChrome currentPage="strategy-market" animateContent>
|
||||
<StrategyMarketPage />
|
||||
</AppChrome>
|
||||
) : (
|
||||
<LandingPage />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.traders}
|
||||
element={isAuthenticated ? <TradersRoute /> : <LandingPage />}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.dashboard}
|
||||
element={isAuthenticated ? <DashboardRoute /> : <LandingPage />}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.strategy}
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<AppChrome currentPage="strategy" animateContent>
|
||||
<StrategyStudioPage />
|
||||
</AppChrome>
|
||||
) : (
|
||||
<LandingPage />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to={ROUTES.home} replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Page, string> = {
|
||||
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<string, string> = {
|
||||
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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user