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:
deanokk
2026-04-13 23:44:14 +08:00
committed by GitHub
parent eef78b7987
commit e1b5a5d833
22 changed files with 1930 additions and 1273 deletions
+5 -709
View File
@@ -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 { ConfirmDialogProvider } from './components/common/ConfirmDialog'
import { t } from './i18n/translations' import { AuthProvider } from './contexts/AuthContext'
import { useSystemConfig } from './hooks/useSystemConfig' import { LanguageProvider } from './contexts/LanguageContext'
import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding' import { AppRoutes } from './router/AppRoutes'
import { OFFICIAL_LINKS } from './constants/branding' export default function App() {
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 slugname + 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() {
return ( return (
<LanguageProvider> <LanguageProvider>
<AuthProvider> <AuthProvider>
<ConfirmDialogProvider> <ConfirmDialogProvider>
<App /> <AppRoutes />
</ConfirmDialogProvider> </ConfirmDialogProvider>
</AuthProvider> </AuthProvider>
</LanguageProvider> </LanguageProvider>
+23 -10
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext' import { useLanguage } from '../../contexts/LanguageContext'
@@ -13,12 +14,15 @@ import { invalidateSystemConfig } from '../../lib/config'
export function LoginPage() { export function LoginPage() {
const { language } = useLanguage() const { language } = useLanguage()
const { login } = useAuth() const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) 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') const [mode, setMode] = useState<UserMode>('beginner')
// Clean up stale auth state once on mount // 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) // Show session-expired toast (re-runs on language change to update text)
useEffect(() => { useEffect(() => {
if (sessionStorage.getItem('from401') === 'true') { 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) setExpiredToastId(id)
sessionStorage.removeItem('from401') sessionStorage.removeItem('from401')
} }
@@ -48,7 +54,9 @@ export function LoginPage() {
sessionStorage.removeItem('from401') sessionStorage.removeItem('from401')
invalidateSystemConfig() invalidateSystemConfig()
toast.success(t('forgotAccountSuccess', language)) toast.success(t('forgotAccountSuccess', language))
setTimeout(() => { window.location.href = '/setup' }, 1500) setTimeout(() => {
navigate('/setup')
}, 1500)
} else { } else {
const data = await res.json() const data = await res.json()
toast.error(data.error || 'Reset failed') 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="flex-1 flex items-center justify-center px-4 py-16">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
{/* Logo + Title */} {/* Logo + Title */}
<div className="text-center mb-10"> <div className="text-center mb-10">
<div className="flex justify-center mb-5"> <div className="flex justify-center mb-5">
<div className="relative"> <div className="relative">
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" /> <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>
</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> <p className="text-zinc-500 text-sm">Sign in to your account</p>
</div> </div>
{/* Card */} {/* Card */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-8 shadow-2xl"> <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"> <form onSubmit={handleLogin} className="space-y-5">
{/* Email */} {/* Email */}
<div> <div>
<label className="block text-xs font-medium text-zinc-400 mb-2"> <label className="block text-xs font-medium text-zinc-400 mb-2">
@@ -120,7 +132,7 @@ export function LoginPage() {
</label> </label>
<button <button
type="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" className="text-xs text-zinc-500 hover:text-nofx-gold transition-colors"
> >
{t('forgotPassword', language)} {t('forgotPassword', language)}
@@ -164,7 +176,9 @@ export function LoginPage() {
disabled={loading} 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" 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> </button>
</form> </form>
@@ -178,7 +192,6 @@ export function LoginPage() {
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</DeepVoidBackground> </DeepVoidBackground>
@@ -1,5 +1,6 @@
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react' import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react'
import { Link } from 'react-router-dom'
import { DeepVoidBackground } from '../common/DeepVoidBackground' import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { useLanguage } from '../../contexts/LanguageContext' import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations' import { t } from '../../i18n/translations'
@@ -10,7 +11,11 @@ interface LoginRequiredOverlayProps {
featureName?: string featureName?: string
} }
export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequiredOverlayProps) { export function LoginRequiredOverlay({
isOpen,
onClose,
featureName,
}: LoginRequiredOverlayProps) {
const { language } = useLanguage() const { language } = useLanguage()
const tr = (key: string, params?: Record<string, string | number>) => const tr = (key: string, params?: Record<string, string | number>) =>
@@ -20,11 +25,7 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
? tr('subtitleWithFeature', { featureName }) ? tr('subtitleWithFeature', { featureName })
: tr('subtitleDefault') : tr('subtitleDefault')
const benefits = [ const benefits = [tr('benefit1'), tr('benefit2'), tr('benefit4')]
tr('benefit1'),
tr('benefit2'),
tr('benefit4'),
]
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -40,7 +41,6 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
disableAnimation disableAnimation
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }} initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }} 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 justify-between px-3 py-2 bg-nofx-bg-lighter border-b border-nofx-gold/20">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal size={12} className="text-nofx-gold" /> <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> </div>
<button <button
onClick={onClose} 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="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)]"> <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" /> <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> </div>
</div> </div>
@@ -83,8 +87,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
{/* Terminal Text */} {/* Terminal Text */}
<div className="space-y-4 mb-8"> <div className="space-y-4 mb-8">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{tr('title')}</h2> <h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">{subtitle}</p> {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>
<div className="bg-nofx-bg-lighter border-l-2 border-nofx-gold/20 p-3 my-4"> <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"> <div className="grid grid-cols-2 gap-2">
{benefits.map((benefit, i) => ( {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} <span className="text-nofx-gold"></span> {benefit}
</div> </div>
))} ))}
@@ -105,22 +116,24 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
{/* Action Buttons */} {/* Action Buttons */}
<div className="space-y-3"> <div className="space-y-3">
<a <Link
href="/login" 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" 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} /> <LogIn size={14} />
<span>{tr('loginButton')}</span> <span>{tr('loginButton')}</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-&gt;</span> <span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">
</a> -&gt;
</span>
</Link>
<a <Link
href="/register" 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" 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} /> <UserPlus size={14} />
<span>{tr('registerButton')}</span> <span>{tr('registerButton')}</span>
</a> </Link>
</div> </div>
<div className="mt-4 text-center"> <div className="mt-4 text-center">
@@ -131,14 +144,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
[ {tr('abort')} ] [ {tr('abort')} ]
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Corner Accents */} {/* Corner Accents */}
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div> <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> <div className="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-nofx-gold"></div>
</motion.div> </motion.div>
</DeepVoidBackground> </DeepVoidBackground>
</motion.div> </motion.div>
+72 -21
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import PasswordChecklist from 'react-password-checklist' import PasswordChecklist from 'react-password-checklist'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
@@ -13,6 +14,7 @@ import { WhitelistFullPage } from '../common/WhitelistFullPage'
export function RegisterPage() { export function RegisterPage() {
const { language } = useLanguage() const { language } = useLanguage()
const { register } = useAuth() const { register } = useAuth()
const navigate = useNavigate()
const [view, setView] = useState<'register' | 'whitelist-full'>('register') const [view, setView] = useState<'register' | 'whitelist-full'>('register')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -61,7 +63,11 @@ export function RegisterPage() {
setLoading(true) setLoading(true)
try { try {
const result = await register(email, password, betaCode.trim() || undefined) const result = await register(
email,
password,
betaCode.trim() || undefined
)
const isWhitelistError = (msg: string) => { const isWhitelistError = (msg: string) => {
const lowerMsg = msg.toLowerCase() const lowerMsg = msg.toLowerCase()
@@ -86,7 +92,10 @@ export function RegisterPage() {
// success path is handled in AuthContext (auto login + navigation) // success path is handled in AuthContext (auto login + navigation)
} catch (e) { } catch (e) {
console.error('Registration error:', 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() const lowerMsg = errorMsg.toLowerCase()
if ( if (
lowerMsg.includes('whitelist') || lowerMsg.includes('whitelist') ||
@@ -106,15 +115,20 @@ export function RegisterPage() {
} }
return ( 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="w-full max-w-lg relative z-10 px-6">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<button <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" 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> <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">&lt; ABORT_REGISTRATION</span> <span className="text-xs font-mono uppercase tracking-widest">
&lt; ABORT_REGISTRATION
</span>
</button> </button>
</div> </div>
@@ -122,7 +136,11 @@ export function RegisterPage() {
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<div className="relative"> <div className="relative">
<div className="absolute -inset-2 bg-nofx-gold/20 rounded-full blur-xl animate-pulse"></div> <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>
</div> </div>
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase mb-2"> <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="flex gap-1.5">
<div <div
className="w-2.5 h-2.5 rounded-full bg-red-500/50 hover:bg-red-500 cursor-pointer transition-colors" 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" title="Close / Return Home"
></div> ></div>
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></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="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"> <div className="flex gap-2">
<span className="text-emerald-500"></span> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<span className="text-emerald-500"></span> <span className="text-emerald-500"></span>
@@ -165,7 +185,9 @@ export function RegisterPage() {
<form onSubmit={handleRegister} className="space-y-5"> <form onSubmit={handleRegister} className="space-y-5">
<div> <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 <input
type="email" type="email"
value={email} value={email}
@@ -178,7 +200,9 @@ export function RegisterPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <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"> <div className="relative">
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
@@ -199,7 +223,9 @@ export function RegisterPage() {
</div> </div>
<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"> <div className="relative">
<input <input
type={showConfirmPassword ? 'text' : 'password'} type={showConfirmPassword ? 'text' : 'password'}
@@ -211,10 +237,16 @@ export function RegisterPage() {
/> />
<button <button
type="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" 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> </button>
</div> </div>
</div> </div>
@@ -227,7 +259,14 @@ export function RegisterPage() {
</div> </div>
<div className="text-xs font-mono text-zinc-400"> <div className="text-xs font-mono text-zinc-400">
<PasswordChecklist <PasswordChecklist
rules={['minLength', 'capital', 'lowercase', 'number', 'specialChar', 'match']} rules={[
'minLength',
'capital',
'lowercase',
'number',
'specialChar',
'match',
]}
minLength={8} minLength={8}
value={password} value={password}
valueAgain={confirmPassword} valueAgain={confirmPassword}
@@ -248,17 +287,25 @@ export function RegisterPage() {
{betaMode && ( {betaMode && (
<div> <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 <input
type="text" type="text"
value={betaCode} 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" 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" placeholder="XXXXXX"
maxLength={6} maxLength={6}
required={betaMode} 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> </div>
)} )}
@@ -270,7 +317,9 @@ export function RegisterPage() {
<button <button
type="submit" 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" 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 ? ( {loading ? (
@@ -278,7 +327,9 @@ export function RegisterPage() {
) : ( ) : (
<> <>
<span>CREATE_ACCOUNT</span> <span>CREATE_ACCOUNT</span>
<span className="group-hover:translate-x-1 transition-transform">-&gt;</span> <span className="group-hover:translate-x-1 transition-transform">
-&gt;
</span>
</> </>
)} )}
</button> </button>
@@ -295,14 +346,14 @@ export function RegisterPage() {
<p className="text-xs font-mono text-zinc-500"> <p className="text-xs font-mono text-zinc-500">
EXISTING_OPERATOR?{' '} EXISTING_OPERATOR?{' '}
<button <button
onClick={() => (window.location.href = '/login')} onClick={() => navigate('/login')}
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase" className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
> >
ACCESS TERMINAL ACCESS TERMINAL
</button> </button>
</p> </p>
<button <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" 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 ] [ ABORT_REGISTRATION_RETURN_HOME ]
@@ -1,8 +1,19 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { RegistrationDisabled } from './RegistrationDisabled' import { RegistrationDisabled } from './RegistrationDisabled'
import { LanguageProvider } from '../../contexts/LanguageContext' 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 // Mock useLanguage hook
vi.mock('../../contexts/LanguageContext', async () => { vi.mock('../../contexts/LanguageContext', async () => {
const actual = await vi.importActual('../../contexts/LanguageContext') const actual = await vi.importActual('../../contexts/LanguageContext')
@@ -21,9 +32,11 @@ vi.mock('../../contexts/LanguageContext', async () => {
describe('RegistrationDisabled Component', () => { describe('RegistrationDisabled Component', () => {
const renderComponent = () => { const renderComponent = () => {
return render( return render(
<LanguageProvider> <MemoryRouter>
<RegistrationDisabled /> <LanguageProvider>
</LanguageProvider> <RegistrationDisabled />
</LanguageProvider>
</MemoryRouter>
) )
} }
@@ -48,7 +61,9 @@ describe('RegistrationDisabled Component', () => {
it('should display registration closed message', () => { it('should display registration closed message', () => {
renderComponent() renderComponent()
const message = screen.getByText(/User registration is currently disabled/i) const message = screen.getByText(
/User registration is currently disabled/i
)
expect(message).toBeTruthy() expect(message).toBeTruthy()
}) })
@@ -61,19 +76,12 @@ describe('RegistrationDisabled Component', () => {
describe('Navigation', () => { describe('Navigation', () => {
it('should navigate to login page when button is clicked', () => { it('should navigate to login page when button is clicked', () => {
const pushStateSpy = vi.spyOn(window.history, 'pushState')
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
renderComponent() renderComponent()
const button = screen.getByRole('button', { name: /back to login/i }) const button = screen.getByRole('button', { name: /back to login/i })
fireEvent.click(button) fireEvent.click(button)
expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/login') expect(mockNavigate).toHaveBeenCalledWith('/login')
expect(dispatchEventSpy).toHaveBeenCalled()
pushStateSpy.mockRestore()
dispatchEventSpy.mockRestore()
}) })
}) })
@@ -1,12 +1,13 @@
import { useNavigate } from 'react-router-dom'
import { useLanguage } from '../../contexts/LanguageContext' import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations' import { t } from '../../i18n/translations'
export function RegistrationDisabled() { export function RegistrationDisabled() {
const { language } = useLanguage() const { language } = useLanguage()
const navigate = useNavigate()
const handleBackToLogin = () => { const handleBackToLogin = () => {
window.history.pushState({}, '', '/login') navigate('/login')
window.dispatchEvent(new PopStateEvent('popstate'))
} }
return ( return (
@@ -1,4 +1,5 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext' import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations' import { t } from '../../i18n/translations'
@@ -11,6 +12,7 @@ import { toast } from 'sonner'
export function ResetPasswordPage() { export function ResetPasswordPage() {
const { language } = useLanguage() const { language } = useLanguage()
const { resetPassword } = useAuth() const { resetPassword } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
@@ -41,8 +43,7 @@ export function ResetPasswordPage() {
toast.success(t('resetPasswordSuccess', language) || '重置成功') toast.success(t('resetPasswordSuccess', language) || '重置成功')
// 3秒后跳转到登录页面 // 3秒后跳转到登录页面
setTimeout(() => { setTimeout(() => {
window.history.pushState({}, '', '/login') navigate('/login')
window.dispatchEvent(new PopStateEvent('popstate'))
}, 3000) }, 3000)
} else { } else {
const msg = result.message || t('resetPasswordFailed', language) const msg = result.message || t('resetPasswordFailed', language)
@@ -64,10 +65,7 @@ export function ResetPasswordPage() {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Back to Login */} {/* Back to Login */}
<button <button
onClick={() => { onClick={() => navigate('/login')}
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors" className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}
> >
+209 -69
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react' 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 { motion, AnimatePresence } from 'framer-motion'
import { Menu, X, ChevronDown, Settings } from 'lucide-react' import { Menu, X, ChevronDown, Settings } from 'lucide-react'
import { t, type Language } from '../../i18n/translations' import { t, type Language } from '../../i18n/translations'
@@ -10,17 +10,7 @@ import {
setUserMode, setUserMode,
type UserMode, type UserMode,
} from '../../lib/onboarding' } from '../../lib/onboarding'
import { getCurrentPageForPath, ROUTES, type Page } from '../../router/paths'
type Page =
| 'competition'
| 'traders'
| 'trader'
| 'strategy'
| 'strategy-market'
| 'data'
| 'faq'
| 'login'
| 'register'
interface HeaderBarProps { interface HeaderBarProps {
onLoginClick?: () => void onLoginClick?: () => void
@@ -47,16 +37,20 @@ export default function HeaderBar({
onLoginRequired, onLoginRequired,
}: HeaderBarProps) { }: HeaderBarProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false) const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
const [userDropdownOpen, setUserDropdownOpen] = 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 dropdownRef = useRef<HTMLDivElement>(null)
const userDropdownRef = useRef<HTMLDivElement>(null) const userDropdownRef = useRef<HTMLDivElement>(null)
const resolvedCurrentPage =
currentPage ?? getCurrentPageForPath(location.pathname)
const navigateInApp = (path: string) => { const navigateInApp = (path: string) => {
navigate(path) navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
} }
const handleSwitchMode = (nextMode: UserMode) => { const handleSwitchMode = (nextMode: UserMode) => {
@@ -94,14 +88,12 @@ export default function HeaderBar({
{/* Logo - Always go to home page */} {/* Logo - Always go to home page */}
<div <div
onClick={() => { onClick={() => {
window.location.href = '/' navigateInApp(ROUTES.home)
}} }}
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" 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" /> <img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
<span className="text-lg font-bold text-nofx-gold"> <span className="text-lg font-bold text-nofx-gold">NOFX</span>
NOFX
</span>
</div> </div>
{/* Desktop Menu */} {/* Desktop Menu */}
@@ -111,17 +103,67 @@ export default function HeaderBar({
{/* Navigation tabs configuration */} {/* Navigation tabs configuration */}
{(() => { {(() => {
// Define all navigation tabs // Define all navigation tabs
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [ const navTabs: {
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false }, page: Page
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true }, path: string
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true }, label: string
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true }, requiresAuth: boolean
{ 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 }, 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 requires auth and not logged in, show login prompt
if (tab.requiresAuth && !isLoggedIn) { if (tab.requiresAuth && !isLoggedIn) {
onLoginRequired?.(tab.label) onLoginRequired?.(tab.label)
@@ -131,7 +173,7 @@ export default function HeaderBar({
if (onPageChange) { if (onPageChange) {
onPageChange(tab.page) onPageChange(tab.page)
} }
navigate(tab.path) navigateInApp(tab.path)
} }
return navTabs.map((tab) => ( return navTabs.map((tab) => (
@@ -139,12 +181,10 @@ export default function HeaderBar({
key={tab.page} key={tab.page}
onClick={() => handleNavClick(tab)} 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 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 && ( {resolvedCurrentPage === tab.page && (
<span <span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" />
className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
/>
)} )}
{tab.label} {tab.label}
</button> </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" className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-white hover:bg-white/5"
title="GitHub" 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" /> <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> </svg>
</a> </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" className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#1DA1F2] hover:bg-[#1DA1F2]/10"
title="Twitter" 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" /> <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> </svg>
</a> </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" className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#0088cc] hover:bg-[#0088cc]/10"
title="Telegram" 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" /> <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> </svg>
</a> </a>
@@ -227,7 +282,7 @@ export default function HeaderBar({
</div> </div>
<button <button
onClick={() => { onClick={() => {
window.location.href = '/settings' navigateInApp(ROUTES.settings)
setUserDropdownOpen(false) 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" 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 Settings
</button> </button>
<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" 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" /> <Settings className="w-3.5 h-3.5" />
{userMode === 'beginner' {userMode === 'beginner'
? language === 'zh' ? '切到老手模式' : 'Switch to Advanced' ? language === 'zh'
: language === 'zh' ? '切到手模式' : 'Switch to Beginner'} ? '切到手模式'
: 'Switch to Advanced'
: language === 'zh'
? '切到新手模式'
: 'Switch to Beginner'}
</button> </button>
{onLogout && ( {onLogout && (
<button <button
@@ -261,15 +324,16 @@ export default function HeaderBar({
</div> </div>
) : ( ) : (
/* Show login/register buttons when not logged in and not on login/register pages */ /* Show login/register buttons when not logged in and not on login/register pages */
currentPage !== 'login' && resolvedCurrentPage !== 'login' &&
currentPage !== 'register' && ( resolvedCurrentPage !== 'register' && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<a <button
href="/login" 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" className="px-3 py-2 text-sm font-medium transition-colors rounded text-nofx-text-muted hover:text-white"
> >
{t('signIn', language)} {t('signIn', language)}
</a> </button>
</div> </div>
) )
)} )}
@@ -361,17 +425,67 @@ export default function HeaderBar({
{/* Navigation Links */} {/* Navigation Links */}
<div className="flex flex-col gap-6 mb-12"> <div className="flex flex-col gap-6 mb-12">
{(() => { {(() => {
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [ const navTabs: {
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false }, page: Page
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true }, path: string
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true }, label: string
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true }, requiresAuth: boolean
{ 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 }, 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) { if (tab.requiresAuth && !isLoggedIn) {
onLoginRequired?.(tab.label) onLoginRequired?.(tab.label)
setMobileMenuOpen(false) setMobileMenuOpen(false)
@@ -380,7 +494,7 @@ export default function HeaderBar({
if (onPageChange) { if (onPageChange) {
onPageChange(tab.page) onPageChange(tab.page)
} }
navigate(tab.path) navigateInApp(tab.path)
setMobileMenuOpen(false) setMobileMenuOpen(false)
} }
@@ -392,9 +506,9 @@ export default function HeaderBar({
transition={{ delay: 0.1 + i * 0.05 }} transition={{ delay: 0.1 + i * 0.05 }}
onClick={() => handleMobileNavClick(tab)} onClick={() => handleMobileNavClick(tab)}
className={`text-2xl font-black tracking-tight text-left flex items-center gap-3 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 <motion.div
layoutId="active-indicator" layoutId="active-indicator"
className="w-1.5 h-1.5 rounded-full bg-nofx-gold" className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
@@ -438,9 +552,24 @@ export default function HeaderBar({
{/* Social Links */} {/* Social Links */}
<div className="flex items-center gap-4"> <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.github,
{ 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" /> } 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) => ( ].map((link, i) => (
<a <a
key={i} key={i}
@@ -449,7 +578,12 @@ export default function HeaderBar({
rel="noopener noreferrer" 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" 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} {link.icon}
</svg> </svg>
</a> </a>
@@ -467,10 +601,11 @@ export default function HeaderBar({
onLanguageChange?.(lang as Language) onLanguageChange?.(lang as Language)
setMobileMenuOpen(false) setMobileMenuOpen(false)
}} }}
className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${language === lang className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${
? 'bg-zinc-800 text-white shadow-sm' language === lang
: 'text-zinc-500' ? 'bg-zinc-800 text-white shadow-sm'
}`} : 'text-zinc-500'
}`}
> >
{lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'} {lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'}
</button> </button>
@@ -489,13 +624,18 @@ export default function HeaderBar({
{t('exitLogin', language)} {t('exitLogin', language)}
</button> </button>
) : ( ) : (
currentPage !== 'login' && currentPage !== 'register' && ( resolvedCurrentPage !== 'login' &&
<a resolvedCurrentPage !== 'register' && (
href="/login" <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" 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)} {t('signIn', language)}
</a> </button>
) )
)} )}
</div> </div>
+103
View File
@@ -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 { motion } from 'framer-motion'
import { ShieldAlert, ArrowLeft, Twitter, Send, Lock } from 'lucide-react' import { ShieldAlert, ArrowLeft, Twitter, Send, Lock } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { OFFICIAL_LINKS } from '../../constants/branding' import { OFFICIAL_LINKS } from '../../constants/branding'
interface WhitelistFullPageProps { interface WhitelistFullPageProps {
@@ -7,11 +8,13 @@ interface WhitelistFullPageProps {
} }
export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) { export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
const navigate = useNavigate()
const handleBackToLogin = () => { const handleBackToLogin = () => {
if (onBack) { if (onBack) {
onBack() onBack()
} else { } 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" 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"> <div className="bg-zinc-900/40 backdrop-blur-md border border-red-500/30 rounded-lg overflow-hidden relative group">
{/* Top Bar */} {/* 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 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"> <div className="flex gap-1.5 opacity-50">
@@ -60,9 +62,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
{/* Description */} {/* Description */}
<p className="text-xs text-zinc-400 mb-8 leading-relaxed font-mono px-4"> <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. <span className="text-red-400">[SYSTEM_MESSAGE]:</span> YOUR
<br /><br /> IDENTIFIER IS NOT ON THE ACTIVE WHITELIST.
Platform capacity limits have been reached for the current beta phase. Prioritized access is currently reserved for authorized operators only. <br />
<br />
Platform capacity limits have been reached for the current beta
phase. Prioritized access is currently reserved for authorized
operators only.
</p> </p>
{/* Info Box */} {/* Info Box */}
@@ -70,9 +76,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Lock className="w-4 h-4 text-red-500 mt-0.5" /> <Lock className="w-4 h-4 text-red-500 mt-0.5" />
<div> <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"> <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> </p>
</div> </div>
</div> </div>
@@ -109,14 +119,12 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
</a> </a>
</div> </div>
</div> </div>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="bg-black/80 p-2 text-[9px] text-zinc-700 text-center border-t border-zinc-800 font-mono uppercase"> <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 ERR_CODE: WLIST_0x403 // SECURITY_LAYER_ACTIVE
</div> </div>
</div> </div>
</motion.div> </motion.div>
</div> </div>
+24 -11
View File
@@ -1,5 +1,6 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { ArrowRight, Play, Github, Zap } from 'lucide-react' import { ArrowRight, Play, Github, Zap } from 'lucide-react'
import { Link } from 'react-router-dom'
import { t, Language } from '../../i18n/translations' import { t, Language } from '../../i18n/translations'
import { useGitHubStats } from '../../hooks/useGitHubStats' import { useGitHubStats } from '../../hooks/useGitHubStats'
import { useCounterAnimation } from '../../hooks/useCounterAnimation' import { useCounterAnimation } from '../../hooks/useCounterAnimation'
@@ -33,7 +34,8 @@ export default function HeroSection({ language }: HeroSectionProps) {
<div <div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full"
style={{ 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 */} {/* Floating Orbs */}
@@ -138,8 +140,7 @@ export default function HeroSection({ language }: HeroSectionProps) {
transition={{ duration: 0.6, delay: 0.3 }} transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12" className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12"
> >
<motion.a <motion.div
href="/competition"
className="group flex items-center gap-3 px-8 py-4 rounded-xl font-bold text-lg transition-all" className="group flex items-center gap-3 px-8 py-4 rounded-xl font-bold text-lg transition-all"
style={{ style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)', background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
@@ -152,10 +153,12 @@ export default function HeroSection({ language }: HeroSectionProps) {
}} }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Play className="w-5 h-5" /> <Link to="/competition" className="flex items-center gap-3">
{t('liveCompetition', language) || 'Live Competition'} <Play className="w-5 h-5" />
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" /> {t('liveCompetition', language) || 'Live Competition'}
</motion.a> <ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Link>
</motion.div>
<motion.a <motion.a
href={OFFICIAL_LINKS.github} href={OFFICIAL_LINKS.github}
@@ -188,9 +191,18 @@ export default function HeroSection({ language }: HeroSectionProps) {
> >
{[ {[
{ label: 'GitHub Stars', value: `${(stars / 1000).toFixed(1)}K+` }, { 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' ? '支持交易所' : 'Exchanges',
{ label: language === 'zh' ? '开源免费' : 'Open Source', value: '100%' }, value: '5+',
},
{
label: language === 'zh' ? 'AI 模型' : 'AI Models',
value: '10+',
},
{
label: language === 'zh' ? '开源免费' : 'Open Source',
value: '100%',
},
].map((stat, index) => ( ].map((stat, index) => (
<motion.div <motion.div
key={stat.label} key={stat.label}
@@ -202,7 +214,8 @@ export default function HeroSection({ language }: HeroSectionProps) {
<div <div
className="text-3xl sm:text-4xl font-bold mb-1" className="text-3xl sm:text-4xl font-bold mb-1"
style={{ style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)', background:
'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
WebkitBackgroundClip: 'text', WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent', WebkitTextFillColor: 'transparent',
}} }}
+3 -2
View File
@@ -1,5 +1,6 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t, Language } from '../../i18n/translations' import { t, Language } from '../../i18n/translations'
interface LoginModalProps { interface LoginModalProps {
onClose: () => void onClose: () => void
@@ -7,6 +8,7 @@ interface LoginModalProps {
} }
export default function LoginModal({ onClose, language }: LoginModalProps) { export default function LoginModal({ onClose, language }: LoginModalProps) {
const navigate = useNavigate()
return ( return (
<motion.div <motion.div
@@ -49,8 +51,7 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
<div className="space-y-3"> <div className="space-y-3">
<motion.button <motion.button
onClick={() => { onClick={() => {
window.history.pushState({}, '', '/login') navigate('/login')
window.dispatchEvent(new PopStateEvent('popstate'))
onClose() onClose()
}} }}
className="block w-full px-6 py-3 rounded-lg font-semibold text-center" className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
+158 -129
View File
@@ -1,149 +1,178 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react' import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../../contexts/AuthContext' import { useAuth } from '../../../contexts/AuthContext'
const agents = [ const agents = [
{ {
name: "ALPHA-1", 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) // ... (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. // Actually, I'll use multi_replace for targeted cleanup.
class: "SCALPER", class: 'SCALPER',
desc: "High-frequency microstructure exploitation.", desc: 'High-frequency microstructure exploitation.',
apy: "142%", apy: '142%',
winRate: "68%", winRate: '68%',
risk: "HIGH", risk: 'HIGH',
color: "text-nofx-gold", color: 'text-nofx-gold',
border: "border-nofx-gold/50", border: 'border-nofx-gold/50',
bg_glow: "shadow-[0_0_30px_rgba(240,185,11,0.1)]", bg_glow: 'shadow-[0_0_30px_rgba(240,185,11,0.1)]',
icon: Zap icon: Zap,
}, },
{ {
name: "BETA-X", name: 'BETA-X',
class: "SWING_OPS", class: 'SWING_OPS',
desc: "Multi-day trend extraction engine.", desc: 'Multi-day trend extraction engine.',
apy: "89%", apy: '89%',
winRate: "55%", winRate: '55%',
risk: "MED", risk: 'MED',
color: "text-blue-400", color: 'text-blue-400',
border: "border-blue-400/30", border: 'border-blue-400/30',
bg_glow: "shadow-[0_0_30px_rgba(96,165,250,0.1)]", bg_glow: 'shadow-[0_0_30px_rgba(96,165,250,0.1)]',
icon: TrendingUp icon: TrendingUp,
}, },
{ {
name: "GAMMA-RAY", name: 'GAMMA-RAY',
class: "ARBITRAGE", class: 'ARBITRAGE',
desc: "Low-risk spatial price equalization.", desc: 'Low-risk spatial price equalization.',
apy: "24%", apy: '24%',
winRate: "99%", winRate: '99%',
risk: "LOW", risk: 'LOW',
color: "text-purple-400", color: 'text-purple-400',
border: "border-purple-400/30", border: 'border-purple-400/30',
bg_glow: "shadow-[0_0_30px_rgba(192,132,252,0.1)]", bg_glow: 'shadow-[0_0_30px_rgba(192,132,252,0.1)]',
icon: Layers icon: Layers,
}, },
] ]
export default function AgentGrid() { export default function AgentGrid() {
const { user } = useAuth() const { user } = useAuth()
const navigate = useNavigate()
const handleInitialize = () => { const handleInitialize = () => {
if (user) { if (user) {
window.location.href = '/strategy-market' navigate('/strategy-market')
} else { } else {
window.location.href = '/login' navigate('/login')
}
} }
}
return ( return (
<section id="market-scanner" className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden"> <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="max-w-7xl mx-auto px-6 relative z-10">
<div className="absolute top-0 right-0 p-10 opacity-20 pointer-events-none"> <div className="flex flex-col md:flex-row justify-between items-end mb-10 md:mb-16 gap-6">
<Hexagon className="w-64 h-64 text-zinc-800" strokeWidth={0.5} /> <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> </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"> return (
<div> <motion.div
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase"> key={i}
<Crosshair className="w-4 h-4" /> MARKET SELECT initial={{ opacity: 0, y: 20 }}
</div> whileInView={{ opacity: 1, y: 0 }}
<h2 className="text-4xl md:text-5xl font-black text-white uppercase tracking-tighter"> transition={{ delay: i * 0.1 }}
STRATEGY <span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">UNITS</span> 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`}
</h2> >
{/* 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>
<div className="font-mono text-right text-xs text-zinc-500 max-w-xs"> <div className="text-right">
SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE PRE-TRAINED ON HISTORICAL TICKS. <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>
</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> </div>
{/* Grid Container - Removing scroll tracking for stability test */} {/* Decorative Background Elements */}
<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"> <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>
{agents.map((agent, i) => { <div className="absolute inset-0 bg-scanlines opacity-20 pointer-events-none"></div>
const Icon = agent.icon </motion.div>
)
return ( })}
<motion.div </div>
key={i} </div>
initial={{ opacity: 0, y: 20 }} </section>
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>
)
} }
+202 -85
View File
@@ -20,12 +20,7 @@ import { ModelConfigModal } from './ModelConfigModal'
import { ConfigStatusGrid } from './ConfigStatusGrid' import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList' import { TradersList } from './TradersList'
import { BeginnerGuideCards } from './BeginnerGuideCards' import { BeginnerGuideCards } from './BeginnerGuideCards'
import { import { AlertTriangle, Bot, Plus, MessageCircle } from 'lucide-react'
AlertTriangle,
Bot,
Plus,
MessageCircle,
} from 'lucide-react'
import { confirmToast } from '../../lib/notify' import { confirmToast } from '../../lib/notify'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -55,11 +50,17 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [allModels, setAllModels] = useState<AIModel[]>([]) const [allModels, setAllModels] = useState<AIModel[]>([])
const [allExchanges, setAllExchanges] = useState<Exchange[]>([]) const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [supportedModels, setSupportedModels] = useState<AIModel[]>([]) const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set()) const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set()) Set<string>
>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<
Set<string>
>(new Set())
const [copiedId, setCopiedId] = useState<string | null>(null) const [copiedId, setCopiedId] = useState<string | null>(null)
const [quickSetupLoading, setQuickSetupLoading] = useState(false) const [quickSetupLoading, setQuickSetupLoading] = useState(false)
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => getBeginnerWalletAddress()) const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<
string | null
>(() => getBeginnerWalletAddress())
const isBeginnerMode = getUserMode() === 'beginner' const isBeginnerMode = getUserMode() === 'beginner'
const getErrorMessage = (error: unknown, fallback: string) => { const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message.trim() !== '') { 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 traderName = params.trader_name || params.traderName || 'this trader'
const modelName = params.model_name || params.modelName || 'selected model' const modelName = params.model_name || params.modelName || 'selected model'
const exchangeName = params.exchange_name || params.exchangeName || 'selected exchange account' const exchangeName =
const reason = localizeTraderReason(params.reason_key, params.reason || fallback) params.exchange_name || params.exchangeName || 'selected exchange account'
const reason = localizeTraderReason(
params.reason_key,
params.reason || fallback
)
const symbol = params.symbol || '' const symbol = params.symbol || ''
const zh = language === 'zh' const zh = language === 'zh'
switch (errorKey) { switch (errorKey) {
case 'trader.create.invalid_request': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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.setup_invalid':
case 'trader.start.load_failed': 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: default:
return fallback return fallback
} }
@@ -131,34 +176,69 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
switch (reasonKey) { switch (reasonKey) {
case 'trader.reason.strategy_config_invalid': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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': 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: 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) { if (error instanceof ApiError && error.errorKey) {
return formatActionableDescriptionByKey(error.errorKey, error.errorParams, message) return formatActionableDescriptionByKey(
error.errorKey,
error.errorParams,
message
)
} }
const prefixes = [ const prefixes = [
@@ -247,12 +327,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const navigateInApp = (path: string) => { const navigateInApp = (path: string) => {
navigate(path) navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
} }
// Toggle wallet address visibility for a trader // Toggle wallet address visibility for a trader
const toggleTraderAddressVisibility = (traderId: string) => { const toggleTraderAddressVisibility = (traderId: string) => {
setVisibleTraderAddresses(prev => { setVisibleTraderAddresses((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(traderId)) { if (next.has(traderId)) {
next.delete(traderId) next.delete(traderId)
@@ -265,7 +344,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
// Toggle wallet address visibility for an exchange // Toggle wallet address visibility for an exchange
const toggleExchangeAddressVisibility = (exchangeId: string) => { const toggleExchangeAddressVisibility = (exchangeId: string) => {
setVisibleExchangeAddresses(prev => { setVisibleExchangeAddresses((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(exchangeId)) { if (next.has(exchangeId)) {
next.delete(exchangeId) next.delete(exchangeId)
@@ -287,11 +366,13 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} }
} }
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>( const {
user && token ? 'traders' : null, data: traders,
api.getTraders, mutate: mutateTraders,
{ refreshInterval: 5000 } isLoading: isTradersLoading,
) } = useSWR<TraderInfo[]>(user && token ? 'traders' : null, api.getTraders, {
refreshInterval: 5000,
})
const { const {
data: exchangeAccountStateData, data: exchangeAccountStateData,
mutate: mutateExchangeAccountStates, mutate: mutateExchangeAccountStates,
@@ -323,18 +404,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} }
try { try {
const [ const [modelConfigs, exchangeConfigs, models] = await Promise.all([
modelConfigs,
exchangeConfigs,
models,
] = await Promise.all([
api.getModelConfigs(), api.getModelConfigs(),
api.getExchangeConfigs(), api.getExchangeConfigs(),
api.getSupportedModels(), api.getSupportedModels(),
]) ])
setAllModels(modelConfigs) setAllModels(modelConfigs)
const clawWalletAddress = const clawWalletAddress =
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null modelConfigs.find((model) => model.provider === 'claw402')
?.walletAddress || null
if (clawWalletAddress) { if (clawWalletAddress) {
setBeginnerWalletAddress(clawWalletAddress) setBeginnerWalletAddress(clawWalletAddress)
persistBeginnerWalletAddress(clawWalletAddress) persistBeginnerWalletAddress(clawWalletAddress)
@@ -365,10 +443,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}) || [] }) || []
const enabledModels = allModels?.filter((m) => m.enabled) || [] const enabledModels = allModels?.filter((m) => m.enabled) || []
const enabledClaw402Model = enabledModels.find((model) => model.provider === 'claw402') || null const enabledClaw402Model =
const enabledClaw402Balance = parseBalanceUsdc(enabledClaw402Model?.balanceUsdc) enabledModels.find((model) => model.provider === 'claw402') || null
const enabledClaw402Balance = parseBalanceUsdc(
enabledClaw402Model?.balanceUsdc
)
const claw402BalanceAlert = const claw402BalanceAlert =
enabledClaw402Model && enabledClaw402Balance !== null && enabledClaw402Balance < 1 enabledClaw402Model &&
enabledClaw402Balance !== null &&
enabledClaw402Balance < 1
? { ? {
blocking: enabledClaw402Balance <= 0, blocking: enabledClaw402Balance <= 0,
title: title:
@@ -379,7 +462,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
: enabledClaw402Balance <= 0 : enabledClaw402Balance <= 0
? 'Claw402 wallet balance is zero' ? 'Claw402 wallet balance is zero'
: 'Claw402 wallet balance is low', : 'Claw402 wallet balance is low',
description: getClaw402BalanceMessage(enabledClaw402Balance, enabledClaw402Balance <= 0), description: getClaw402BalanceMessage(
enabledClaw402Balance,
enabledClaw402Balance <= 0
),
} }
: null : null
const enabledExchanges = const enabledExchanges =
@@ -415,7 +501,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} }
const getExchangeUsageInfo = (exchangeId: string) => { 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 runningCount = usingTraders.filter((tr) => tr.is_running).length
const totalCount = usingTraders.length const totalCount = usingTraders.length
return { runningCount, totalCount, usingTraders } return { runningCount, totalCount, usingTraders }
@@ -548,17 +635,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} catch (error) { } catch (error) {
console.error('Failed to toggle trader:', error) console.error('Failed to toggle trader:', error)
showActionableError( showActionableError(
running ? t('aiTradersToast.stopFailed', language) : t('aiTradersToast.startFailed', language), running
? t('aiTradersToast.stopFailed', language)
: t('aiTradersToast.startFailed', language),
error error
) )
} }
} }
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => { const handleToggleCompetition = async (
traderId: string,
currentShowInCompetition: boolean
) => {
try { try {
const newValue = !currentShowInCompetition const newValue = !currentShowInCompetition
await api.toggleCompetition(traderId, newValue) 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() await mutateTraders()
} catch (error) { } catch (error) {
@@ -695,12 +791,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
allModels?.map((m) => allModels?.map((m) =>
m.id === modelId m.id === modelId
? { ? {
...m, ...m,
apiKey, apiKey,
customApiUrl: customApiUrl || '', customApiUrl: customApiUrl || '',
customModelName: customModelName || '', customModelName: customModelName || '',
enabled: true, enabled: true,
} }
: m : m
) || [] ) || []
} else { } else {
@@ -816,7 +912,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} }
await api.updateExchangeConfigsEncrypted(request) await api.updateExchangeConfigsEncrypted(request)
toast.success(t('aiTradersToast.exchangeConfigUpdated', language)) toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
} else { } else {
const createRequest = { const createRequest = {
exchange_type: exchangeType, exchange_type: exchangeType,
@@ -837,7 +933,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} }
await api.createExchangeEncrypted(createRequest) await api.createExchangeEncrypted(createRequest)
toast.success(t('aiTradersToast.exchangeCreated', language)) toast.success(t('aiTradersToast.exchangeCreated', language))
} }
const refreshedExchanges = await api.getExchangeConfigs() 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 hasStrategies = (strategies?.length || 0) > 0
const hasCreatedTrader = (traders?.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 ( return (
<DeepVoidBackground className="py-8" disableAnimation> <DeepVoidBackground className="py-8" disableAnimation>
@@ -952,7 +1051,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<button <button
onClick={() => setShowCreateModal(true)} 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)]" 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"> <span className="relative z-10 flex items-center gap-2">
@@ -984,15 +1086,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<div <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" 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={{ style={{
borderColor: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.55)' : 'rgba(245, 158, 11, 0.45)', borderColor: claw402BalanceAlert.blocking
background: claw402BalanceAlert.blocking ? 'rgba(127, 29, 29, 0.22)' : 'rgba(120, 53, 15, 0.18)', ? '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="flex items-start gap-3">
<div <div
className="mt-0.5 rounded-full p-2" className="mt-0.5 rounded-full p-2"
style={{ 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', color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
}} }}
> >
@@ -1001,11 +1109,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<div> <div>
<div <div
className="text-sm font-semibold" className="text-sm font-semibold"
style={{ color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A' }} style={{
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
}}
> >
{claw402BalanceAlert.title} {claw402BalanceAlert.title}
</div> </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} {claw402BalanceAlert.description}
</div> </div>
</div> </div>
@@ -1013,10 +1126,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<button <button
type="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" className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
style={{ 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', color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
background: 'rgba(0, 0, 0, 0.18)', background: 'rgba(0, 0, 0, 0.18)',
}} }}
+18 -12
View File
@@ -1,8 +1,10 @@
import React, { createContext, useContext, useState, useEffect } from 'react' import React, { createContext, useContext, useState, useEffect } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { getSystemConfig, invalidateSystemConfig } from '../lib/config' import { getSystemConfig, invalidateSystemConfig } from '../lib/config'
import { reset401Flag, httpClient } from '../lib/httpClient' import { reset401Flag, httpClient } from '../lib/httpClient'
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding' import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
import { ROUTES } from '../router/paths'
import { useLanguage } from './LanguageContext' import { useLanguage } from './LanguageContext'
interface User { interface User {
@@ -43,6 +45,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const { language } = useLanguage() const { language } = useLanguage()
const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null) const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -120,8 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
sessionStorage.removeItem('returnUrl') sessionStorage.removeItem('returnUrl')
} }
window.history.pushState({}, '', nextPath) navigate(nextPath)
window.dispatchEvent(new PopStateEvent('popstate'))
} }
const login = async (email: string, password: string, mode?: UserMode) => { const login = async (email: string, password: string, mode?: UserMode) => {
@@ -145,7 +147,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
// Unexpected success response // Unexpected success response
return { success: false, message: data.message || 'Unexpected login response' } return {
success: false,
message: data.message || 'Unexpected login response',
}
} else { } else {
return { return {
success: false, success: false,
@@ -184,12 +189,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const returnUrl = sessionStorage.getItem('returnUrl') const returnUrl = sessionStorage.getItem('returnUrl')
if (returnUrl) { if (returnUrl) {
sessionStorage.removeItem('returnUrl') sessionStorage.removeItem('returnUrl')
window.history.pushState({}, '', returnUrl) navigate(returnUrl)
window.dispatchEvent(new PopStateEvent('popstate'))
} else { } else {
// Redirect to dashboard // Redirect to dashboard
window.history.pushState({}, '', '/dashboard') navigate(ROUTES.dashboard)
window.dispatchEvent(new PopStateEvent('popstate'))
} }
return { success: true } return { success: true }
} else { } else {
@@ -244,13 +247,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
message: result.message || 'Registration failed', message: result.message || 'Registration failed',
} }
} catch (error) { } 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 // Re-throw if it's a critical error, or return structured error
// Since httpClient throws on 500, we should return a structured error response // Since httpClient throws on 500, we should return a structured error response
// to let the UI display it gracefully without crashing. // to let the UI display it gracefully without crashing.
return { return {
success: false, 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 } return { success: false, message: data.error }
} }
} catch (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_token')
localStorage.removeItem('auth_user') localStorage.removeItem('auth_user')
invalidateSystemConfig() invalidateSystemConfig()
window.history.pushState({}, '', '/') navigate(ROUTES.home)
window.dispatchEvent(new PopStateEvent('popstate'))
} }
return ( return (
+49 -23
View File
@@ -1,21 +1,19 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import { useNavigate } from 'react-router-dom'
ArrowRight, import { ArrowRight, Copy, RefreshCw, Shield, Wallet, X } from 'lucide-react'
Copy,
RefreshCw,
Shield,
Wallet,
X,
} from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api' import { api } from '../lib/api'
import type { BeginnerOnboardingResponse } from '../types' import type { BeginnerOnboardingResponse } from '../types'
import { setBeginnerWalletAddress, markBeginnerOnboardingCompleted } from '../lib/onboarding' import {
setBeginnerWalletAddress,
markBeginnerOnboardingCompleted,
} from '../lib/onboarding'
export function BeginnerOnboardingPage() { export function BeginnerOnboardingPage() {
const { language } = useLanguage() const { language } = useLanguage()
const navigate = useNavigate()
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null) const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -79,8 +77,7 @@ export function BeginnerOnboardingPage() {
const handleContinue = () => { const handleContinue = () => {
markBeginnerOnboardingCompleted() markBeginnerOnboardingCompleted()
window.history.pushState({}, '', '/traders') navigate('/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
} }
return ( return (
@@ -104,7 +101,9 @@ export function BeginnerOnboardingPage() {
<div> <div>
<div <div
className={`font-semibold uppercase text-nofx-gold/80 ${ 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'} {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"> <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 ? ( {loading ? (
<div className="flex min-h-[390px] items-center justify-center px-6 text-sm text-zinc-400"> <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> </div>
) : data ? ( ) : data ? (
<div className="grid lg:grid-cols-[0.82fr_1.18fr]"> <div className="grid lg:grid-cols-[0.82fr_1.18fr]">
@@ -147,13 +148,17 @@ export function BeginnerOnboardingPage() {
</div> </div>
<div className="mt-4 text-[15px] font-medium text-zinc-300"> <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>
<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="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="text-left">
<div className="flex items-baseline gap-3 font-mono font-bold tracking-tight text-emerald-300"> <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> <span className="text-[20px]">USDC</span>
</div> </div>
</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" 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'} aria-label={isZh ? '刷新余额' : 'Refresh balance'}
> >
<RefreshCw className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`} /> <RefreshCw
className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`}
/>
</button> </button>
</div> </div>
<div className="mt-4 text-sm text-zinc-500"> <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>
</div> </div>
</section> </section>
@@ -187,7 +196,9 @@ export function BeginnerOnboardingPage() {
</div> </div>
<button <button
type="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" 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'} aria-label={isZh ? '复制地址' : 'Copy address'}
> >
@@ -199,16 +210,27 @@ export function BeginnerOnboardingPage() {
<div className="pt-1"> <div className="pt-1">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-nofx-gold"> <div className="mb-3 flex items-center gap-2 text-sm font-medium text-nofx-gold">
<Shield className="h-4 w-4" /> <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>
<div className="flex items-stretch gap-3"> <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="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>
<div className="flex shrink-0 flex-col justify-end"> <div className="flex shrink-0 flex-col justify-end">
<button <button
type="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" 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'} aria-label={isZh ? '复制私钥' : 'Copy private key'}
> >
@@ -220,7 +242,9 @@ export function BeginnerOnboardingPage() {
<div <div
className={`rounded-[24px] border border-white/15 bg-black/18 px-5 py-3.5 text-zinc-500 ${ 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> <span className="mr-2 text-zinc-600"></span>
@@ -246,7 +270,9 @@ export function BeginnerOnboardingPage() {
isZh ? 'text-[20px]' : 'text-[16px] sm:text-[18px]' 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" /> <ArrowRight className="h-5 w-5" />
</button> </button>
-16
View File
@@ -34,24 +34,8 @@ export function LandingPage() {
user={user} user={user}
onLogout={logout} onLogout={logout}
onLoginRequired={handleLoginRequired} 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"> <div className="min-h-screen bg-nofx-bg text-nofx-text font-sans selection:bg-nofx-gold selection:text-black">
<TerminalHero /> <TerminalHero />
<LiveFeed /> <LiveFeed />
+157 -58
View File
@@ -1,6 +1,17 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' 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 { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api' import { api } from '../lib/api'
@@ -20,8 +31,11 @@ type Tab = 'account' | 'models' | 'exchanges' | 'telegram'
export function SettingsPage() { export function SettingsPage() {
const { user } = useAuth() const { user } = useAuth()
const { language } = useLanguage() const { language } = useLanguage()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('account') const [activeTab, setActiveTab] = useState<Tab>('account')
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced') const [userMode, setUserModeState] = useState<UserMode>(
() => getUserMode() ?? 'advanced'
)
// Account state // Account state
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@@ -53,7 +67,8 @@ export function SettingsPage() {
.catch(() => toast.error('Failed to load AI models')) .catch(() => toast.error('Failed to load AI models'))
} }
if (activeTab === 'exchanges') { if (activeTab === 'exchanges') {
api.getExchangeConfigs() api
.getExchangeConfigs()
.then(setExchanges) .then(setExchanges)
.catch(() => toast.error('Failed to load exchanges')) .catch(() => toast.error('Failed to load exchanges'))
} }
@@ -82,7 +97,9 @@ export function SettingsPage() {
toast.success('Password updated successfully') toast.success('Password updated successfully')
setNewPassword('') setNewPassword('')
} catch (err) { } 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 { } finally {
setChangingPassword(false) setChangingPassword(false)
} }
@@ -104,8 +121,7 @@ export function SettingsPage() {
) )
const nextPath = getPostAuthPath(nextMode) const nextPath = getPostAuthPath(nextMode)
window.history.pushState({}, '', nextPath) navigate(nextPath)
window.dispatchEvent(new PopStateEvent('popstate'))
} }
const handleSaveModel = async ( const handleSaveModel = async (
@@ -118,33 +134,48 @@ export function SettingsPage() {
const existingModel = configuredModels.find((m) => m.id === modelId) const existingModel = configuredModels.find((m) => m.id === modelId)
const modelTemplate = supportedModels.find((m) => m.id === modelId) const modelTemplate = supportedModels.find((m) => m.id === modelId)
const modelToUpdate = existingModel || modelTemplate const modelToUpdate = existingModel || modelTemplate
if (!modelToUpdate) { toast.error('Model not found'); return } if (!modelToUpdate) {
toast.error('Model not found')
return
}
let updatedModels: AIModel[] let updatedModels: AIModel[]
if (existingModel) { if (existingModel) {
updatedModels = configuredModels.map((m) => updatedModels = configuredModels.map((m) =>
m.id === modelId m.id === modelId
? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true } ? {
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
: m : m
) )
} else { } else {
updatedModels = [...configuredModels, { updatedModels = [
...modelToUpdate, ...configuredModels,
apiKey, {
customApiUrl: customApiUrl || '', ...modelToUpdate,
customModelName: customModelName || '', apiKey,
enabled: true, customApiUrl: customApiUrl || '',
}] customModelName: customModelName || '',
enabled: true,
},
]
} }
const request = { const request = {
models: Object.fromEntries( models: Object.fromEntries(
updatedModels.map((m) => [m.provider, { updatedModels.map((m) => [
enabled: m.enabled, m.provider,
api_key: m.apiKey || '', {
custom_api_url: m.customApiUrl || '', enabled: m.enabled,
custom_model_name: m.customModelName || '', api_key: m.apiKey || '',
}]) custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '',
},
])
), ),
} }
await api.updateModelConfigs(request) await api.updateModelConfigs(request)
@@ -161,16 +192,27 @@ export function SettingsPage() {
const handleDeleteModel = async (modelId: string) => { const handleDeleteModel = async (modelId: string) => {
try { try {
const updatedModels = configuredModels.map((m) => 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 = { const request = {
models: Object.fromEntries( models: Object.fromEntries(
updatedModels.map((m) => [m.provider, { updatedModels.map((m) => [
enabled: m.enabled, m.provider,
api_key: m.apiKey || '', {
custom_api_url: m.customApiUrl || '', enabled: m.enabled,
custom_model_name: m.customModelName || '', api_key: m.apiKey || '',
}]) custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '',
},
])
), ),
} }
await api.updateModelConfigs(request) await api.updateModelConfigs(request)
@@ -223,7 +265,7 @@ export function SettingsPage() {
}, },
} }
await api.updateExchangeConfigsEncrypted(request) await api.updateExchangeConfigsEncrypted(request)
toast.success('Exchange config updated') toast.success('Exchange config updated')
} else { } else {
const createRequest = { const createRequest = {
exchange_type: exchangeType, exchange_type: exchangeType,
@@ -243,7 +285,7 @@ export function SettingsPage() {
lighter_api_key_index: lighterApiKeyIndex || 0, lighter_api_key_index: lighterApiKeyIndex || 0,
} }
await api.createExchangeEncrypted(createRequest) await api.createExchangeEncrypted(createRequest)
toast.success('Exchange account created') toast.success('Exchange account created')
} }
const refreshed = await api.getExchangeConfigs() const refreshed = await api.getExchangeConfigs()
setExchanges(refreshed) setExchanges(refreshed)
@@ -275,7 +317,10 @@ export function SettingsPage() {
] ]
return ( 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"> <div className="max-w-2xl mx-auto">
<h1 className="text-xl font-bold text-white mb-6">Settings</h1> <h1 className="text-xl font-bold text-white mb-6">Settings</h1>
@@ -286,9 +331,10 @@ export function SettingsPage() {
key={tab.key} key={tab.key}
onClick={() => setActiveTab(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 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' activeTab === tab.key
: 'text-zinc-400 hover:text-white' ? 'bg-nofx-gold text-black'
: 'text-zinc-400 hover:text-white'
}`} }`}
> >
{tab.icon} {tab.icon}
@@ -299,7 +345,6 @@ export function SettingsPage() {
{/* Tab Content */} {/* Tab Content */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6"> <div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
{/* Account Tab */} {/* Account Tab */}
{activeTab === 'account' && ( {activeTab === 'account' && (
<div className="space-y-6"> <div className="space-y-6">
@@ -322,8 +367,12 @@ export function SettingsPage() {
</div> </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"> <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' {userMode === 'beginner'
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner' ? language === 'zh'
: language === 'zh' ? '当前:手模式' : 'Current: Advanced'} ? '当前:手模式'
: 'Current: Beginner'
: language === 'zh'
? '当前:老手模式'
: 'Current: Advanced'}
</span> </span>
</div> </div>
@@ -369,10 +418,14 @@ export function SettingsPage() {
</div> </div>
<div className="border-t border-zinc-800 pt-6"> <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"> <form onSubmit={handleChangePassword} className="space-y-4">
<div> <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"> <div className="relative">
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
@@ -387,7 +440,11 @@ export function SettingsPage() {
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors" 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> </button>
</div> </div>
</div> </div>
@@ -408,10 +465,14 @@ export function SettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-zinc-400"> <p className="text-sm text-zinc-400">
{configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured {configuredModels.length} model
{configuredModels.length !== 1 ? 's' : ''} configured
</p> </p>
<button <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" 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} /> <Plus size={14} />
@@ -428,7 +489,10 @@ export function SettingsPage() {
{configuredModels.map((model) => ( {configuredModels.map((model) => (
<button <button
key={model.id} 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" 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"> <div className="flex items-center gap-3">
@@ -436,15 +500,24 @@ export function SettingsPage() {
<Cpu size={14} className="text-zinc-300" /> <Cpu size={14} className="text-zinc-300" />
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="text-sm font-medium text-white">{model.name}</p> <p className="text-sm font-medium text-white">
<p className="text-xs text-zinc-500">{model.provider}</p> {model.name}
</p>
<p className="text-xs text-zinc-500">
{model.provider}
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <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'} {model.enabled ? 'Active' : 'Inactive'}
</span> </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> </div>
</button> </button>
))} ))}
@@ -458,10 +531,14 @@ export function SettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-zinc-400"> <p className="text-sm text-zinc-400">
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected {exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '}
connected
</p> </p>
<button <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" 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} /> <Plus size={14} />
@@ -478,7 +555,10 @@ export function SettingsPage() {
{exchanges.map((exchange) => ( {exchanges.map((exchange) => (
<button <button
key={exchange.id} 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" 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"> <div className="flex items-center gap-3">
@@ -486,11 +566,18 @@ export function SettingsPage() {
<Building2 size={14} className="text-zinc-300" /> <Building2 size={14} className="text-zinc-300" />
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p> <p className="text-sm font-medium text-white">
<p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p> {exchange.account_name || exchange.name}
</p>
<p className="text-xs text-zinc-500 capitalize">
{exchange.exchange_type || exchange.type}
</p>
</div> </div>
</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> </button>
))} ))}
</div> </div>
@@ -502,7 +589,8 @@ export function SettingsPage() {
{activeTab === 'telegram' && ( {activeTab === 'telegram' && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-zinc-400"> <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> </p>
<button <button
onClick={() => setShowTelegramModal(true)} 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"> <div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center">
<MessageCircle size={14} className="text-[#0088cc]" /> <MessageCircle size={14} className="text-[#0088cc]" />
</div> </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> </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> </button>
</div> </div>
)} )}
@@ -530,7 +623,10 @@ export function SettingsPage() {
editingModelId={editingModel} editingModelId={editingModel}
onSave={handleSaveModel} onSave={handleSaveModel}
onDelete={handleDeleteModel} onDelete={handleDeleteModel}
onClose={() => { setShowModelModal(false); setEditingModel(null) }} onClose={() => {
setShowModelModal(false)
setEditingModel(null)
}}
language={language} language={language}
/> />
</div> </div>
@@ -544,7 +640,10 @@ export function SettingsPage() {
editingExchangeId={editingExchange} editingExchangeId={editingExchange}
onSave={handleSaveExchange} onSave={handleSaveExchange}
onDelete={handleDeleteExchange} onDelete={handleDeleteExchange}
onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }} onClose={() => {
setShowExchangeModal(false)
setEditingExchange(null)
}}
language={language} language={language}
/> />
</div> </div>
+177 -78
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import useSWR from 'swr' import useSWR from 'swr'
import { import {
TrendingUp, TrendingUp,
@@ -15,7 +16,7 @@ import {
Activity, Activity,
Terminal, Terminal,
Cpu, Cpu,
Database Database,
} from 'lucide-react' } from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@@ -39,14 +40,24 @@ interface PublicStrategy {
updated_at: string 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: { scalper: {
color: 'text-[#F0B90B]', color: 'text-[#F0B90B]',
border: 'border-[#F0B90B]/30', border: 'border-[#F0B90B]/30',
glow: 'shadow-[0_0_20px_rgba(240,185,11,0.15)]', glow: 'shadow-[0_0_20px_rgba(240,185,11,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(240,185,11,0.25)]', shadow: 'hover:shadow-[0_0_30px_rgba(240,185,11,0.25)]',
bg: 'bg-[#F0B90B]/5', bg: 'bg-[#F0B90B]/5',
icon: Zap icon: Zap,
}, },
swing: { swing: {
color: 'text-cyan-400', 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)]', glow: 'shadow-[0_0_20px_rgba(34,211,238,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(34,211,238,0.25)]', shadow: 'hover:shadow-[0_0_30px_rgba(34,211,238,0.25)]',
bg: 'bg-cyan-400/5', bg: 'bg-cyan-400/5',
icon: TrendingUp icon: TrendingUp,
}, },
arbitrage: { arbitrage: {
color: 'text-purple-400', 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)]', glow: 'shadow-[0_0_20px_rgba(192,132,252,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(192,132,252,0.25)]', shadow: 'hover:shadow-[0_0_30px_rgba(192,132,252,0.25)]',
bg: 'bg-purple-400/5', bg: 'bg-purple-400/5',
icon: Layers icon: Layers,
}, },
conservative: { conservative: {
color: 'text-emerald-400', 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)]', glow: 'shadow-[0_0_20px_rgba(52,211,153,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(52,211,153,0.25)]', shadow: 'hover:shadow-[0_0_30px_rgba(52,211,153,0.25)]',
bg: 'bg-emerald-400/5', bg: 'bg-emerald-400/5',
icon: Shield icon: Shield,
}, },
aggressive: { aggressive: {
color: 'text-red-500', 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)]', glow: 'shadow-[0_0_20px_rgba(239,68,68,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(239,68,68,0.25)]', shadow: 'hover:shadow-[0_0_30px_rgba(239,68,68,0.25)]',
bg: 'bg-red-500/5', bg: 'bg-red-500/5',
icon: Target icon: Target,
}, },
default: { default: {
color: 'text-zinc-400', color: 'text-zinc-400',
@@ -86,8 +97,8 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
glow: '', glow: '',
shadow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.05)]', shadow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.05)]',
bg: 'bg-zinc-800/20', bg: 'bg-zinc-800/20',
icon: Activity icon: Activity,
} },
} }
function getStrategyStyle(name: string) { function getStrategyStyle(name: string) {
@@ -95,12 +106,15 @@ function getStrategyStyle(name: string) {
if (lowerName.includes('scalp')) return strategyStyles.scalper if (lowerName.includes('scalp')) return strategyStyles.scalper
if (lowerName.includes('swing')) return strategyStyles.swing if (lowerName.includes('swing')) return strategyStyles.swing
if (lowerName.includes('arb')) return strategyStyles.arbitrage if (lowerName.includes('arb')) return strategyStyles.arbitrage
if (lowerName.includes('safe') || lowerName.includes('conserv')) return strategyStyles.conservative if (lowerName.includes('safe') || lowerName.includes('conserv'))
if (lowerName.includes('aggress') || lowerName.includes('high')) return strategyStyles.aggressive return strategyStyles.conservative
if (lowerName.includes('aggress') || lowerName.includes('high'))
return strategyStyles.aggressive
return strategyStyles.default return strategyStyles.default
} }
export function StrategyMarketPage() { export function StrategyMarketPage() {
const navigate = useNavigate()
const { language } = useLanguage() const { language } = useLanguage()
const { token, user } = useAuth() const { token, user } = useAuth()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -120,23 +134,28 @@ export function StrategyMarketPage() {
}, },
{ {
refreshInterval: 60000, refreshInterval: 60000,
revalidateOnFocus: false revalidateOnFocus: false,
} }
) )
const filteredStrategies = strategies?.filter(s => { const filteredStrategies =
if (searchQuery) { strategies?.filter((s) => {
const query = searchQuery.toLowerCase() if (searchQuery) {
return s.name.toLowerCase().includes(query) || const query = searchQuery.toLowerCase()
s.description?.toLowerCase().includes(query) return (
} s.name.toLowerCase().includes(query) ||
return true s.description?.toLowerCase().includes(query)
}) || [] )
}
return true
}) || []
const handleCopyConfig = async (strategy: PublicStrategy) => { const handleCopyConfig = async (strategy: PublicStrategy) => {
if (!strategy.config) return if (!strategy.config) return
try { try {
await navigator.clipboard.writeText(JSON.stringify(strategy.config, null, 2)) await navigator.clipboard.writeText(
JSON.stringify(strategy.config, null, 2)
)
setCopiedId(strategy.id) setCopiedId(strategy.id)
toast.success(tr('copied')) toast.success(tr('copied'))
setTimeout(() => setCopiedId(null), 2000) setTimeout(() => setCopiedId(null), 2000)
@@ -147,14 +166,16 @@ export function StrategyMarketPage() {
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
const date = new Date(dateStr) const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { return date
year: 'numeric', .toLocaleDateString('en-US', {
month: '2-digit', year: 'numeric',
day: '2-digit', month: '2-digit',
hour: '2-digit', day: '2-digit',
minute: '2-digit', hour: '2-digit',
hour12: false minute: '2-digit',
}).replace(',', '') hour12: false,
})
.replace(',', '')
} }
const getIndicatorList = (config: any) => { const getIndicatorList = (config: any) => {
@@ -174,15 +195,15 @@ export function StrategyMarketPage() {
return ( return (
<DeepVoidBackground className="min-h-screen text-white font-mono py-12"> <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 px-4 md:px-8 space-y-8">
<div className="w-full relative z-10"> <div className="w-full relative z-10">
{/* Header Section */} {/* Header Section */}
<div className="mb-12 border-b border-zinc-800 pb-8 relative"> <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"> <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 /> <br />
MARKET_UPLINK: <span className="text-emerald-500">ESTABLISHED</span> MARKET_UPLINK:{' '}
<span className="text-emerald-500">ESTABLISHED</span>
</div> </div>
<div className="flex items-center gap-4 mb-4"> <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" /> <Database className="w-8 h-8 text-nofx-gold relative z-10" />
</div> </div>
<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')} {tr('title')}
</h1> </h1>
<p className="text-xs text-nofx-gold tracking-[0.3em] font-bold mt-1"> <p className="text-xs text-nofx-gold tracking-[0.3em] font-bold mt-1">
// {tr('subtitle')} {'// '}
{tr('subtitle')}
</p> </p>
</div> </div>
</div> </div>
@@ -232,16 +257,21 @@ export function StrategyMarketPage() {
<button <button
key={cat} key={cat}
onClick={() => setSelectedCategory(cat)} onClick={() => setSelectedCategory(cat)}
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${selectedCategory === cat className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${
? 'text-black font-bold' selectedCategory === cat
: 'text-zinc-500 hover:text-white' ? 'text-black font-bold'
}`} : 'text-zinc-500 hover:text-white'
}`}
> >
{selectedCategory === cat && ( {selectedCategory === cat && (
<motion.div <motion.div
layoutId="filter-highlight" layoutId="filter-highlight"
className="absolute inset-0 bg-nofx-gold" 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> <span className="relative z-10">{tr(cat)}</span>
@@ -260,11 +290,22 @@ export function StrategyMarketPage() {
<Cpu size={24} className="text-nofx-gold/50" /> <Cpu size={24} className="text-nofx-gold/50" />
</div> </div>
</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="flex gap-1">
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div> <div
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div> className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div> 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>
</div> </div>
)} )}
@@ -279,7 +320,9 @@ export function StrategyMarketPage() {
<h3 className="text-xl font-bold text-zinc-300 font-mono tracking-tight mb-2"> <h3 className="text-xl font-bold text-zinc-300 font-mono tracking-tight mb-2">
[{tr('noStrategies')}] [{tr('noStrategies')}]
</h3> </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> </div>
)} )}
@@ -290,9 +333,10 @@ export function StrategyMarketPage() {
{filteredStrategies.map((strategy, i) => { {filteredStrategies.map((strategy, i) => {
const style = getStrategyStyle(strategy.name) const style = getStrategyStyle(strategy.name)
const Icon = style.icon const Icon = style.icon
const indicators = strategy.config_visible && strategy.config const indicators =
? getIndicatorList(strategy.config) strategy.config_visible && strategy.config
: [] ? getIndicatorList(strategy.config)
: []
return ( return (
<motion.div <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}`} className={`group relative bg-black border border-zinc-800 hover:border-zinc-600 transition-all duration-300 ${style.shadow}`}
> >
{/* Holographic Border Highlight */} {/* 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
<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> 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 */} {/* 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"> <div className="p-6 relative">
{/* Header */} {/* Header */}
<div className="flex justify-between items-start mb-6"> <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}`} /> <Icon className={`w-5 h-5 ${style.color}`} />
</div> </div>
<div className="text-[10px] font-mono"> <div className="text-[10px] font-mono">
@@ -332,7 +384,9 @@ export function StrategyMarketPage() {
</div> </div>
{/* Name and Description */} {/* 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} {strategy.name}
<span className="absolute -bottom-1 left-0 w-8 h-[2px] bg-zinc-800 group-hover:bg-nofx-gold transition-colors"></span> <span className="absolute -bottom-1 left-0 w-8 h-[2px] bg-zinc-800 group-hover:bg-nofx-gold transition-colors"></span>
</h3> </h3>
@@ -343,12 +397,22 @@ export function StrategyMarketPage() {
{/* Meta Data */} {/* Meta Data */}
<div className="grid grid-cols-2 gap-y-2 mb-6 text-[10px] font-mono text-zinc-600"> <div className="grid grid-cols-2 gap-y-2 mb-6 text-[10px] font-mono text-zinc-600">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-zinc-700 uppercase">{tr('author')}</span> <span className="text-zinc-700 uppercase">
<span className="text-zinc-400 group-hover:text-white transition-colors">@{strategy.author_email?.split('@')[0] || 'UNKNOWN'}</span> {tr('author')}
</span>
<span className="text-zinc-400 group-hover:text-white transition-colors">
@
{strategy.author_email?.split('@')[0] ||
'UNKNOWN'}
</span>
</div> </div>
<div className="flex flex-col text-right"> <div className="flex flex-col text-right">
<span className="text-zinc-700 uppercase">{tr('createdAt')}</span> <span className="text-zinc-700 uppercase">
<span className="text-zinc-400">{formatDate(strategy.created_at)}</span> {tr('createdAt')}
</span>
<span className="text-zinc-400">
{formatDate(strategy.created_at)}
</span>
</div> </div>
</div> </div>
@@ -358,14 +422,20 @@ export function StrategyMarketPage() {
<div className="space-y-3"> <div className="space-y-3">
{/* Indicators */} {/* Indicators */}
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pb-1"> <div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pb-1">
{indicators.length > 0 ? indicators.map((ind) => ( {indicators.length > 0 ? (
<span indicators.map((ind) => (
key={ind} <span
className="px-1.5 py-0.5 border border-zinc-700 bg-zinc-800 text-[9px] text-zinc-300 font-mono whitespace-nowrap" 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} >
{ind}
</span>
))
) : (
<span className="text-[9px] text-zinc-600">
NO_INDICATORS
</span> </span>
)) : <span className="text-[9px] text-zinc-600">NO_INDICATORS</span>} )}
</div> </div>
{/* Risk Control */} {/* Risk Control */}
@@ -373,22 +443,38 @@ export function StrategyMarketPage() {
<div className="flex justify-between items-center text-[10px]"> <div className="flex justify-between items-center text-[10px]">
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-zinc-600 scale-90 origin-left">LEV</span> <span className="text-zinc-600 scale-90 origin-left">
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.btc_eth_max_leverage || '-'}x</span> LEV
</span>
<span className="text-zinc-300 font-bold">
{strategy.config.risk_control
.btc_eth_max_leverage || '-'}
x
</span>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-zinc-600 scale-90 origin-left">POS</span> <span className="text-zinc-600 scale-90 origin-left">
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.max_positions || '-'}</span> POS
</span>
<span className="text-zinc-300 font-bold">
{strategy.config.risk_control
.max_positions || '-'}
</span>
</div> </div>
</div> </div>
<Activity size={12} className="text-zinc-700" /> <Activity
size={12}
className="text-zinc-700"
/>
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full text-zinc-600"> <div className="flex flex-col items-center justify-center h-full text-zinc-600">
<EyeOff size={16} className="mb-1 opacity-50" /> <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>
)} )}
</div> </div>
@@ -403,7 +489,9 @@ export function StrategyMarketPage() {
{copiedId === strategy.id ? ( {copiedId === strategy.id ? (
<> <>
<Check className="w-3 h-3 text-emerald-500" /> <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>
) : ( ) : (
<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} /> <Shield size={12} />
{tr('hideConfig')} {tr('hideConfig')}
</button> </button>
)} )}
</div> </div>
</div> </div>
</motion.div> </motion.div>
) )
@@ -436,13 +526,23 @@ export function StrategyMarketPage() {
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
className="mt-16 mb-20 flex justify-center" 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="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"> <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-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-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">
<div className="text-[10px] text-zinc-500 font-mono">CONTRIBUTE TO THE GLOBAL DATABASE</div> {tr('shareYours')}
</div>
<div className="text-[10px] text-zinc-500 font-mono">
CONTRIBUTE TO THE GLOBAL DATABASE
</div>
</div> </div>
<div className="w-[1px] h-8 bg-zinc-800 mx-2"></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"> <div className="text-xs font-mono text-zinc-400 group-hover:translate-x-1 transition-transform">
@@ -452,7 +552,6 @@ export function StrategyMarketPage() {
</div> </div>
</motion.div> </motion.div>
)} )}
</div> </div>
</div> </div>
</DeepVoidBackground> </DeepVoidBackground>
+539
View File
@@ -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>
</>
)
}
+32
View File
@@ -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)
})
})
+83
View File
@@ -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)}`
}