mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
refactor: replace window.location with useNavigate for routing in auth components (#1470)
Co-authored-by: Dean <afei.wuhao@gmail.com>
This commit is contained in:
+5
-709
@@ -1,718 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import { api } from './lib/api'
|
|
||||||
import { TraderDashboardPage } from './pages/TraderDashboardPage'
|
|
||||||
|
|
||||||
import { AITradersPage } from './components/trader/AITradersPage'
|
|
||||||
import { LoginPage } from './components/auth/LoginPage'
|
|
||||||
import { SetupPage } from './components/modals/SetupPage'
|
|
||||||
import { SettingsPage } from './pages/SettingsPage'
|
|
||||||
import { ResetPasswordPage } from './components/auth/ResetPasswordPage'
|
|
||||||
import { CompetitionPage } from './components/trader/CompetitionPage'
|
|
||||||
import { LandingPage } from './pages/LandingPage'
|
|
||||||
import { FAQPage } from './pages/FAQPage'
|
|
||||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
|
||||||
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
|
||||||
import { DataPage } from './pages/DataPage'
|
|
||||||
import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage'
|
|
||||||
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
|
|
||||||
import HeaderBar from './components/common/HeaderBar'
|
|
||||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
|
||||||
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
|
import { 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 slug(name + ID 前 4 位)
|
|
||||||
const getTraderSlug = (trader: TraderInfo) => {
|
|
||||||
const idPrefix = trader.trader_id.slice(0, 4)
|
|
||||||
return `${trader.trader_name}-${idPrefix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 slug 解析并匹配 trader
|
|
||||||
const findTraderBySlug = (slug: string, traderList: TraderInfo[]) => {
|
|
||||||
// slug 格式: name-xxxx (xxxx 是 ID 前 4 位)
|
|
||||||
const lastDashIndex = slug.lastIndexOf('-')
|
|
||||||
if (lastDashIndex === -1) {
|
|
||||||
// 没有 dash,直接按 name 匹配
|
|
||||||
return traderList.find(t => t.trader_name === slug)
|
|
||||||
}
|
|
||||||
const name = slug.slice(0, lastDashIndex)
|
|
||||||
const idPrefix = slug.slice(lastDashIndex + 1)
|
|
||||||
return traderList.find(t =>
|
|
||||||
t.trader_name === name && t.trader_id.startsWith(idPrefix)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
|
||||||
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
|
|
||||||
const hasPersistedAuth =
|
|
||||||
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
|
|
||||||
|
|
||||||
// Poll-off states: stop polling after 3 consecutive failures
|
|
||||||
const [accountPollOff, setAccountPollOff] = useState(false)
|
|
||||||
const [positionsPollOff, setPositionsPollOff] = useState(false)
|
|
||||||
const [decisionsPollOff, setDecisionsPollOff] = useState(false)
|
|
||||||
|
|
||||||
// Reset poll-off states when trader changes
|
|
||||||
useEffect(() => {
|
|
||||||
setAccountPollOff(false)
|
|
||||||
setPositionsPollOff(false)
|
|
||||||
setDecisionsPollOff(false)
|
|
||||||
}, [selectedTraderId])
|
|
||||||
|
|
||||||
// 监听URL变化,同步页面状态
|
|
||||||
useEffect(() => {
|
|
||||||
const handleRouteChange = () => {
|
|
||||||
const path = window.location.pathname
|
|
||||||
const hash = window.location.hash.slice(1)
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const traderParam = params.get('trader')
|
|
||||||
|
|
||||||
if (path === '/welcome') {
|
|
||||||
setCurrentPage('traders')
|
|
||||||
} else if (path === '/traders' || hash === 'traders') {
|
|
||||||
setCurrentPage('traders')
|
|
||||||
} else if (path === '/strategy' || hash === 'strategy') {
|
|
||||||
setCurrentPage('strategy')
|
|
||||||
} else if (path === '/strategy-market' || hash === 'strategy-market') {
|
|
||||||
setCurrentPage('strategy-market')
|
|
||||||
} else if (path === '/data' || hash === 'data') {
|
|
||||||
setCurrentPage('data')
|
|
||||||
} else if (
|
|
||||||
path === '/dashboard' ||
|
|
||||||
hash === 'trader' ||
|
|
||||||
hash === 'details'
|
|
||||||
) {
|
|
||||||
setCurrentPage('trader')
|
|
||||||
// 如果 URL 中有 trader 参数(slug 格式),更新选中的 trader
|
|
||||||
setSelectedTraderSlug(traderParam || undefined)
|
|
||||||
} else if (
|
|
||||||
path === '/competition' ||
|
|
||||||
hash === 'competition' ||
|
|
||||||
hash === ''
|
|
||||||
) {
|
|
||||||
setCurrentPage('competition')
|
|
||||||
}
|
|
||||||
setRoute(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('hashchange', handleRouteChange)
|
|
||||||
window.addEventListener('popstate', handleRouteChange)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('hashchange', handleRouteChange)
|
|
||||||
window.removeEventListener('popstate', handleRouteChange)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 切换页面时更新URL hash (当前通过按钮直接调用setCurrentPage,这个函数暂时保留用于未来扩展)
|
|
||||||
// const navigateToPage = (page: Page) => {
|
|
||||||
// setCurrentPage(page);
|
|
||||||
// window.location.hash = page === 'competition' ? '' : 'trader';
|
|
||||||
// };
|
|
||||||
|
|
||||||
// 获取trader列表(仅在用户登录时)
|
|
||||||
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
|
||||||
user && token ? 'traders' : null,
|
|
||||||
() => api.getTraders(currentPage === 'trader'),
|
|
||||||
{
|
|
||||||
refreshInterval: 10000,
|
|
||||||
shouldRetryOnError: false, // 避免在后端未运行时无限重试
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 获取exchanges列表(用于显示交易所名称)
|
|
||||||
const { data: exchanges } = useSWR<Exchange[]>(
|
|
||||||
user && token ? 'exchanges' : null,
|
|
||||||
api.getExchangeConfigs,
|
|
||||||
{
|
|
||||||
refreshInterval: 60000, // 1分钟刷新一次
|
|
||||||
shouldRetryOnError: false,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 当获取到traders后,根据 URL 中的 trader slug 设置选中的 trader,或默认选中第一个
|
|
||||||
useEffect(() => {
|
|
||||||
if (!traders || traders.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTraderSlug) {
|
|
||||||
// 通过 slug 找到对应的 trader
|
|
||||||
const trader = findTraderBySlug(selectedTraderSlug, traders)
|
|
||||||
const nextTraderId = trader?.trader_id || traders[0].trader_id
|
|
||||||
if (nextTraderId !== selectedTraderId) {
|
|
||||||
setSelectedTraderId(nextTraderId)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedTraderId) {
|
|
||||||
setSelectedTraderId(traders[0].trader_id)
|
|
||||||
}
|
|
||||||
}, [traders, selectedTraderId, selectedTraderSlug])
|
|
||||||
|
|
||||||
// 如果在trader页面,获取该trader的数据
|
|
||||||
const { data: status } = useSWR<SystemStatus>(
|
|
||||||
currentPage === 'trader' && selectedTraderId
|
|
||||||
? `status-${selectedTraderId}`
|
|
||||||
: null,
|
|
||||||
() => api.getStatus(selectedTraderId, true),
|
|
||||||
{
|
|
||||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
|
||||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
|
||||||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: account } = useSWR<AccountInfo>(
|
|
||||||
currentPage === 'trader' && selectedTraderId
|
|
||||||
? `account-${selectedTraderId}`
|
|
||||||
: null,
|
|
||||||
() => api.getAccount(selectedTraderId, true),
|
|
||||||
{
|
|
||||||
refreshInterval: accountPollOff ? 0 : 15000,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
dedupingInterval: 10000,
|
|
||||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
|
||||||
if (retryCount >= 2) { setAccountPollOff(true); return }
|
|
||||||
setTimeout(() => revalidate({ retryCount }), 500)
|
|
||||||
},
|
|
||||||
onSuccess: () => { if (accountPollOff) setAccountPollOff(false) },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: positions } = useSWR<Position[]>(
|
|
||||||
currentPage === 'trader' && selectedTraderId
|
|
||||||
? `positions-${selectedTraderId}`
|
|
||||||
: null,
|
|
||||||
() => api.getPositions(selectedTraderId, true),
|
|
||||||
{
|
|
||||||
refreshInterval: positionsPollOff ? 0 : 15000,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
dedupingInterval: 10000,
|
|
||||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
|
||||||
if (retryCount >= 2) { setPositionsPollOff(true); return }
|
|
||||||
setTimeout(() => revalidate({ retryCount }), 500)
|
|
||||||
},
|
|
||||||
onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: decisions } = useSWR<DecisionRecord[]>(
|
|
||||||
currentPage === 'trader' && selectedTraderId
|
|
||||||
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
|
|
||||||
: null,
|
|
||||||
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
|
|
||||||
{
|
|
||||||
refreshInterval: decisionsPollOff ? 0 : 30000,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
dedupingInterval: 20000,
|
|
||||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
|
||||||
if (retryCount >= 2) { setDecisionsPollOff(true); return }
|
|
||||||
setTimeout(() => revalidate({ retryCount }), 500)
|
|
||||||
},
|
|
||||||
onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: stats } = useSWR<Statistics>(
|
|
||||||
currentPage === 'trader' && selectedTraderId
|
|
||||||
? `statistics-${selectedTraderId}`
|
|
||||||
: null,
|
|
||||||
() => api.getStatistics(selectedTraderId, true),
|
|
||||||
{
|
|
||||||
refreshInterval: 30000, // 30秒刷新(统计数据更新频率较低)
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
dedupingInterval: 20000,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (account) {
|
|
||||||
const now = new Date().toLocaleTimeString()
|
|
||||||
setLastUpdate(now)
|
|
||||||
}
|
|
||||||
}, [account])
|
|
||||||
|
|
||||||
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
|
|
||||||
|
|
||||||
const effectiveAccount = account
|
|
||||||
const effectivePositions = positions
|
|
||||||
const effectiveDecisions = decisions
|
|
||||||
|
|
||||||
// Handle routing
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePopState = () => {
|
|
||||||
setRoute(window.location.pathname)
|
|
||||||
}
|
|
||||||
window.addEventListener('popstate', handlePopState)
|
|
||||||
return () => window.removeEventListener('popstate', handlePopState)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Set current page based on route for consistent navigation state
|
|
||||||
useEffect(() => {
|
|
||||||
if (route === '/welcome') {
|
|
||||||
setCurrentPage('traders')
|
|
||||||
} else if (route === '/competition') {
|
|
||||||
setCurrentPage('competition')
|
|
||||||
} else if (route === '/traders') {
|
|
||||||
setCurrentPage('traders')
|
|
||||||
} else if (route === '/dashboard') {
|
|
||||||
setCurrentPage('trader')
|
|
||||||
}
|
|
||||||
}, [route])
|
|
||||||
|
|
||||||
const showBeginnerOnboarding =
|
|
||||||
route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding()
|
|
||||||
|
|
||||||
// Show loading spinner while checking auth or config
|
|
||||||
if (isLoading || configLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen flex items-center justify-center"
|
|
||||||
style={{ background: '#0B0E11' }}
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<img
|
|
||||||
src="/icons/nofx.svg"
|
|
||||||
alt="NoFx Logo"
|
|
||||||
className="w-16 h-16 mx-auto mb-4 animate-pulse"
|
|
||||||
/>
|
|
||||||
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-time setup: redirect to /setup if system not initialized
|
|
||||||
if (systemConfig && !systemConfig.initialized && !user) {
|
|
||||||
return <SetupPage />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle specific routes regardless of authentication
|
|
||||||
if (route === '/login') {
|
|
||||||
return <LoginPage />
|
|
||||||
}
|
|
||||||
if (route === '/setup') {
|
|
||||||
// If already initialized, redirect to login
|
|
||||||
if (systemConfig?.initialized) {
|
|
||||||
window.location.href = '/login'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return <SetupPage />
|
|
||||||
}
|
|
||||||
if (route === '/welcome') {
|
|
||||||
if ((!user || !token) && !hasPersistedAuth) {
|
|
||||||
window.location.href = '/login'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (getUserMode() !== 'beginner') {
|
|
||||||
window.location.href = '/traders'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (route === '/faq') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen"
|
|
||||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
<HeaderBar
|
|
||||||
isLoggedIn={!!user}
|
|
||||||
currentPage="faq"
|
|
||||||
language={language}
|
|
||||||
onLanguageChange={setLanguage}
|
|
||||||
user={user}
|
|
||||||
onLogout={logout}
|
|
||||||
onLoginRequired={handleLoginRequired}
|
|
||||||
onPageChange={navigateToPage}
|
|
||||||
/>
|
|
||||||
<FAQPage />
|
|
||||||
<LoginRequiredOverlay
|
|
||||||
isOpen={loginOverlayOpen}
|
|
||||||
onClose={() => setLoginOverlayOpen(false)}
|
|
||||||
featureName={loginOverlayFeature}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (route === '/reset-password') {
|
|
||||||
return <ResetPasswordPage />
|
|
||||||
}
|
|
||||||
if (route === '/settings') {
|
|
||||||
if ((!user || !token) && !hasPersistedAuth) {
|
|
||||||
window.location.href = '/login'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
|
|
||||||
<HeaderBar
|
|
||||||
isLoggedIn={!!user}
|
|
||||||
language={language}
|
|
||||||
onLanguageChange={setLanguage}
|
|
||||||
user={user}
|
|
||||||
onLogout={logout}
|
|
||||||
onLoginRequired={handleLoginRequired}
|
|
||||||
onPageChange={navigateToPage}
|
|
||||||
/>
|
|
||||||
<SettingsPage />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Data page - publicly accessible with embedded dashboard
|
|
||||||
if (route === '/data') {
|
|
||||||
const dataPageNavigate = (page: Page) => {
|
|
||||||
navigateToPage(page)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen"
|
|
||||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
<HeaderBar
|
|
||||||
isLoggedIn={!!user}
|
|
||||||
currentPage="data"
|
|
||||||
language={language}
|
|
||||||
onLanguageChange={setLanguage}
|
|
||||||
user={user}
|
|
||||||
onLogout={logout}
|
|
||||||
onLoginRequired={handleLoginRequired}
|
|
||||||
onPageChange={dataPageNavigate}
|
|
||||||
/>
|
|
||||||
<main className="pt-16">
|
|
||||||
<DataPage />
|
|
||||||
</main>
|
|
||||||
<LoginRequiredOverlay
|
|
||||||
isOpen={loginOverlayOpen}
|
|
||||||
onClose={() => setLoginOverlayOpen(false)}
|
|
||||||
featureName={loginOverlayFeature}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Show landing page for root route
|
|
||||||
if (route === '/' || route === '') {
|
|
||||||
return <LandingPage />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect unauthenticated users to landing page
|
|
||||||
if (!user || !token) {
|
|
||||||
return <LandingPage />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen"
|
|
||||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
<HeaderBar
|
|
||||||
isLoggedIn={!!user}
|
|
||||||
currentPage={currentPage}
|
|
||||||
language={language}
|
|
||||||
onLanguageChange={setLanguage}
|
|
||||||
user={user}
|
|
||||||
onLogout={logout}
|
|
||||||
onLoginRequired={handleLoginRequired}
|
|
||||||
onPageChange={navigateToPage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main Content with Page Transitions */}
|
|
||||||
<main className="min-h-screen pt-16">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={currentPage}
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -8 }}
|
|
||||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
|
||||||
>
|
|
||||||
{currentPage === 'competition' ? (
|
|
||||||
<CompetitionPage />
|
|
||||||
) : currentPage === 'data' ? (
|
|
||||||
<DataPage />
|
|
||||||
) : currentPage === 'strategy-market' ? (
|
|
||||||
<StrategyMarketPage />
|
|
||||||
) : currentPage === 'traders' ? (
|
|
||||||
<AITradersPage
|
|
||||||
onTraderSelect={(traderId) => {
|
|
||||||
setSelectedTraderId(traderId)
|
|
||||||
const trader = traders?.find((item) => item.trader_id === traderId)
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.pathname = '/dashboard'
|
|
||||||
if (trader) {
|
|
||||||
const slug = getTraderSlug(trader)
|
|
||||||
url.searchParams.set('trader', slug)
|
|
||||||
setSelectedTraderSlug(slug)
|
|
||||||
} else {
|
|
||||||
url.searchParams.delete('trader')
|
|
||||||
setSelectedTraderSlug(undefined)
|
|
||||||
}
|
|
||||||
window.history.pushState({}, '', url.toString())
|
|
||||||
setRoute('/dashboard')
|
|
||||||
setCurrentPage('trader')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : currentPage === 'strategy' ? (
|
|
||||||
<StrategyStudioPage />
|
|
||||||
) : (
|
|
||||||
<TraderDashboardPage
|
|
||||||
selectedTrader={selectedTrader}
|
|
||||||
status={status}
|
|
||||||
account={effectiveAccount}
|
|
||||||
accountFailed={accountPollOff}
|
|
||||||
positions={effectivePositions}
|
|
||||||
positionsFailed={positionsPollOff}
|
|
||||||
decisions={effectiveDecisions}
|
|
||||||
decisionsFailed={decisionsPollOff}
|
|
||||||
decisionsLimit={decisionsLimit}
|
|
||||||
onDecisionsLimitChange={setDecisionsLimit}
|
|
||||||
stats={stats}
|
|
||||||
lastUpdate={lastUpdate}
|
|
||||||
language={language}
|
|
||||||
traders={traders}
|
|
||||||
tradersError={tradersError}
|
|
||||||
selectedTraderId={selectedTraderId}
|
|
||||||
onTraderSelect={(traderId) => {
|
|
||||||
setSelectedTraderId(traderId)
|
|
||||||
// 更新 URL 参数(使用 slug: name-id前4位)
|
|
||||||
const trader = traders?.find(t => t.trader_id === traderId)
|
|
||||||
if (trader) {
|
|
||||||
const slug = getTraderSlug(trader)
|
|
||||||
setSelectedTraderSlug(slug)
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set('trader', slug)
|
|
||||||
window.history.replaceState({}, '', url.toString())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onNavigateToTraders={() => {
|
|
||||||
window.history.pushState({}, '', '/traders')
|
|
||||||
setRoute('/traders')
|
|
||||||
setCurrentPage('traders')
|
|
||||||
}}
|
|
||||||
exchanges={exchanges}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer
|
|
||||||
className="mt-16"
|
|
||||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
|
|
||||||
style={{ color: '#5E6673' }}
|
|
||||||
>
|
|
||||||
<p>{t('footerTitle', language)}</p>
|
|
||||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
|
||||||
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
|
|
||||||
{/* GitHub */}
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_LINKS.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
color: '#848E9C',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#2B3139'
|
|
||||||
e.currentTarget.style.color = '#EAECEF'
|
|
||||||
e.currentTarget.style.borderColor = '#F0B90B'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1E2329'
|
|
||||||
e.currentTarget.style.color = '#848E9C'
|
|
||||||
e.currentTarget.style.borderColor = '#2B3139'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
{/* Twitter/X */}
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_LINKS.twitter}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
color: '#848E9C',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#2B3139'
|
|
||||||
e.currentTarget.style.color = '#EAECEF'
|
|
||||||
e.currentTarget.style.borderColor = '#1DA1F2'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1E2329'
|
|
||||||
e.currentTarget.style.color = '#848E9C'
|
|
||||||
e.currentTarget.style.borderColor = '#2B3139'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
||||||
</svg>
|
|
||||||
Twitter
|
|
||||||
</a>
|
|
||||||
{/* Telegram */}
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_LINKS.telegram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
color: '#848E9C',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#2B3139'
|
|
||||||
e.currentTarget.style.color = '#EAECEF'
|
|
||||||
e.currentTarget.style.borderColor = '#0088cc'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1E2329'
|
|
||||||
e.currentTarget.style.color = '#848E9C'
|
|
||||||
e.currentTarget.style.borderColor = '#2B3139'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
|
||||||
</svg>
|
|
||||||
Telegram
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
{/* Login Required Overlay */}
|
|
||||||
<LoginRequiredOverlay
|
|
||||||
isOpen={loginOverlayOpen}
|
|
||||||
onClose={() => setLoginOverlayOpen(false)}
|
|
||||||
featureName={loginOverlayFeature}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showBeginnerOnboarding && <BeginnerOnboardingPage />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Wrap App with providers
|
|
||||||
export default function AppWithProviders() {
|
|
||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ConfirmDialogProvider>
|
<ConfirmDialogProvider>
|
||||||
<App />
|
<AppRoutes />
|
||||||
</ConfirmDialogProvider>
|
</ConfirmDialogProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|||||||
@@ -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">-></span>
|
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">
|
||||||
</a>
|
->
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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">< ABORT_REGISTRATION</span>
|
<span className="text-xs font-mono uppercase tracking-widest">
|
||||||
|
< 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">-></span>
|
<span className="group-hover:translate-x-1 transition-transform">
|
||||||
|
->
|
||||||
|
</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' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import { type ReactNode, useEffect, useState } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import {
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useSearchParams,
|
||||||
|
} from 'react-router-dom'
|
||||||
|
import HeaderBar from '../components/common/HeaderBar'
|
||||||
|
import { SiteFooter } from '../components/common/SiteFooter'
|
||||||
|
import { LoginRequiredOverlay } from '../components/auth/LoginRequiredOverlay'
|
||||||
|
import { LoginPage } from '../components/auth/LoginPage'
|
||||||
|
import { RegisterPage } from '../components/auth/RegisterPage'
|
||||||
|
import { ResetPasswordPage } from '../components/auth/ResetPasswordPage'
|
||||||
|
import { SetupPage } from '../components/modals/SetupPage'
|
||||||
|
import { CompetitionPage } from '../components/trader/CompetitionPage'
|
||||||
|
import { AITradersPage } from '../components/trader/AITradersPage'
|
||||||
|
import { FAQPage } from '../pages/FAQPage'
|
||||||
|
import { LandingPage } from '../pages/LandingPage'
|
||||||
|
import { BeginnerOnboardingPage } from '../pages/BeginnerOnboardingPage'
|
||||||
|
import { DataPage } from '../pages/DataPage'
|
||||||
|
import { SettingsPage } from '../pages/SettingsPage'
|
||||||
|
import { StrategyMarketPage } from '../pages/StrategyMarketPage'
|
||||||
|
import { StrategyStudioPage } from '../pages/StrategyStudioPage'
|
||||||
|
import { TraderDashboardPage } from '../pages/TraderDashboardPage'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||||
|
import { t } from '../i18n/translations'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { getUserMode } from '../lib/onboarding'
|
||||||
|
import type {
|
||||||
|
AccountInfo,
|
||||||
|
DecisionRecord,
|
||||||
|
Exchange,
|
||||||
|
Position,
|
||||||
|
Statistics,
|
||||||
|
SystemStatus,
|
||||||
|
TraderInfo,
|
||||||
|
} from '../types'
|
||||||
|
import {
|
||||||
|
buildDashboardPath,
|
||||||
|
LEGACY_HASH_ROUTES,
|
||||||
|
ROUTES,
|
||||||
|
type Page,
|
||||||
|
} from './paths'
|
||||||
|
|
||||||
|
function getTraderSlug(trader: TraderInfo) {
|
||||||
|
const idPrefix = trader.trader_id.slice(0, 4)
|
||||||
|
return `${trader.trader_name}-${idPrefix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTraderBySlug(slug: string, traderList: TraderInfo[]) {
|
||||||
|
const lastDashIndex = slug.lastIndexOf('-')
|
||||||
|
if (lastDashIndex === -1) {
|
||||||
|
return traderList.find((trader) => trader.trader_name === slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = slug.slice(0, lastDashIndex)
|
||||||
|
const idPrefix = slug.slice(lastDashIndex + 1)
|
||||||
|
return traderList.find(
|
||||||
|
(trader) =>
|
||||||
|
trader.trader_name === name && trader.trader_id.startsWith(idPrefix)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingScreen() {
|
||||||
|
const { language } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center"
|
||||||
|
style={{ background: '#0B0E11' }}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<img
|
||||||
|
src="/icons/nofx.svg"
|
||||||
|
alt="NoFx Logo"
|
||||||
|
className="w-16 h-16 mx-auto mb-4 animate-pulse"
|
||||||
|
/>
|
||||||
|
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyHashRedirect() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hashRoute = LEGACY_HASH_ROUTES[location.hash.slice(1)]
|
||||||
|
if (!hashRoute) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashRoute === location.pathname && location.hash === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: hashRoute,
|
||||||
|
search: location.search,
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
)
|
||||||
|
}, [location.hash, location.pathname, location.search, navigate])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppChromeProps {
|
||||||
|
children: ReactNode
|
||||||
|
currentPage?: Page
|
||||||
|
showFooter?: boolean
|
||||||
|
wrapInMain?: boolean
|
||||||
|
animateContent?: boolean
|
||||||
|
extraContent?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppChrome({
|
||||||
|
children,
|
||||||
|
currentPage,
|
||||||
|
showFooter = true,
|
||||||
|
wrapInMain = true,
|
||||||
|
animateContent = false,
|
||||||
|
extraContent,
|
||||||
|
}: AppChromeProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const { language, setLanguage } = useLanguage()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [loginOverlayOpen, setLoginOverlayOpen] = useState(false)
|
||||||
|
const [loginOverlayFeature, setLoginOverlayFeature] = useState('')
|
||||||
|
|
||||||
|
const handleLoginRequired = (featureName: string) => {
|
||||||
|
setLoginOverlayFeature(featureName)
|
||||||
|
setLoginOverlayOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = animateContent ? (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${location.pathname}${location.search}`}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen"
|
||||||
|
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
<HeaderBar
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
currentPage={currentPage}
|
||||||
|
language={language}
|
||||||
|
onLanguageChange={setLanguage}
|
||||||
|
user={user}
|
||||||
|
onLogout={logout}
|
||||||
|
onLoginRequired={handleLoginRequired}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{wrapInMain ? (
|
||||||
|
<main className="min-h-screen pt-16">{content}</main>
|
||||||
|
) : (
|
||||||
|
content
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFooter ? <SiteFooter language={language} /> : null}
|
||||||
|
|
||||||
|
<LoginRequiredOverlay
|
||||||
|
isOpen={loginOverlayOpen}
|
||||||
|
onClose={() => setLoginOverlayOpen(false)}
|
||||||
|
featureName={loginOverlayFeature}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{extraContent}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradersRoute({
|
||||||
|
showBeginnerOnboarding = false,
|
||||||
|
}: {
|
||||||
|
showBeginnerOnboarding?: boolean
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, token } = useAuth()
|
||||||
|
const { data: traders } = useSWR<TraderInfo[]>(
|
||||||
|
user && token ? 'traders-route' : null,
|
||||||
|
api.getTraders,
|
||||||
|
{
|
||||||
|
refreshInterval: 5000,
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppChrome
|
||||||
|
currentPage="traders"
|
||||||
|
animateContent
|
||||||
|
extraContent={showBeginnerOnboarding ? <BeginnerOnboardingPage /> : null}
|
||||||
|
>
|
||||||
|
<AITradersPage
|
||||||
|
onTraderSelect={(traderId) => {
|
||||||
|
const trader = traders?.find((item) => item.trader_id === traderId)
|
||||||
|
navigate(
|
||||||
|
buildDashboardPath(trader ? getTraderSlug(trader) : undefined)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppChrome>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardRoute() {
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const { user, token } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const selectedTraderSlug = searchParams.get('trader') || undefined
|
||||||
|
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>()
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||||
|
const [decisionsLimit, setDecisionsLimit] = useState(5)
|
||||||
|
const [accountPollOff, setAccountPollOff] = useState(false)
|
||||||
|
const [positionsPollOff, setPositionsPollOff] = useState(false)
|
||||||
|
const [decisionsPollOff, setDecisionsPollOff] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAccountPollOff(false)
|
||||||
|
setPositionsPollOff(false)
|
||||||
|
setDecisionsPollOff(false)
|
||||||
|
}, [selectedTraderId])
|
||||||
|
|
||||||
|
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
||||||
|
user && token ? 'traders-dashboard' : null,
|
||||||
|
() => api.getTraders(true),
|
||||||
|
{
|
||||||
|
refreshInterval: 10000,
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: exchanges } = useSWR<Exchange[]>(
|
||||||
|
user && token ? 'exchanges-dashboard' : null,
|
||||||
|
api.getExchangeConfigs,
|
||||||
|
{
|
||||||
|
refreshInterval: 60000,
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!traders || traders.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTraderSlug) {
|
||||||
|
const trader = findTraderBySlug(selectedTraderSlug, traders)
|
||||||
|
const nextTraderId = trader?.trader_id || traders[0].trader_id
|
||||||
|
if (nextTraderId !== selectedTraderId) {
|
||||||
|
setSelectedTraderId(nextTraderId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedTraderId) {
|
||||||
|
setSelectedTraderId(traders[0].trader_id)
|
||||||
|
}
|
||||||
|
}, [selectedTraderId, selectedTraderSlug, traders])
|
||||||
|
|
||||||
|
const { data: status } = useSWR<SystemStatus>(
|
||||||
|
selectedTraderId ? `status-${selectedTraderId}` : null,
|
||||||
|
() => api.getStatus(selectedTraderId, true),
|
||||||
|
{
|
||||||
|
refreshInterval: 15000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 10000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: account } = useSWR<AccountInfo>(
|
||||||
|
selectedTraderId ? `account-${selectedTraderId}` : null,
|
||||||
|
() => api.getAccount(selectedTraderId, true),
|
||||||
|
{
|
||||||
|
refreshInterval: accountPollOff ? 0 : 15000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 10000,
|
||||||
|
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||||
|
if (retryCount >= 2) {
|
||||||
|
setAccountPollOff(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTimeout(() => revalidate({ retryCount }), 500)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
if (accountPollOff) {
|
||||||
|
setAccountPollOff(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: positions } = useSWR<Position[]>(
|
||||||
|
selectedTraderId ? `positions-${selectedTraderId}` : null,
|
||||||
|
() => api.getPositions(selectedTraderId, true),
|
||||||
|
{
|
||||||
|
refreshInterval: positionsPollOff ? 0 : 15000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 10000,
|
||||||
|
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||||
|
if (retryCount >= 2) {
|
||||||
|
setPositionsPollOff(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTimeout(() => revalidate({ retryCount }), 500)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
if (positionsPollOff) {
|
||||||
|
setPositionsPollOff(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: decisions } = useSWR<DecisionRecord[]>(
|
||||||
|
selectedTraderId
|
||||||
|
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
|
||||||
|
: null,
|
||||||
|
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
|
||||||
|
{
|
||||||
|
refreshInterval: decisionsPollOff ? 0 : 30000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 20000,
|
||||||
|
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||||
|
if (retryCount >= 2) {
|
||||||
|
setDecisionsPollOff(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTimeout(() => revalidate({ retryCount }), 500)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
if (decisionsPollOff) {
|
||||||
|
setDecisionsPollOff(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: stats } = useSWR<Statistics>(
|
||||||
|
selectedTraderId ? `statistics-${selectedTraderId}` : null,
|
||||||
|
() => api.getStatistics(selectedTraderId, true),
|
||||||
|
{
|
||||||
|
refreshInterval: 30000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 20000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account) {
|
||||||
|
setLastUpdate(new Date().toLocaleTimeString())
|
||||||
|
}
|
||||||
|
}, [account])
|
||||||
|
|
||||||
|
const selectedTrader = traders?.find(
|
||||||
|
(trader) => trader.trader_id === selectedTraderId
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppChrome currentPage="trader" animateContent>
|
||||||
|
<TraderDashboardPage
|
||||||
|
selectedTrader={selectedTrader}
|
||||||
|
status={status}
|
||||||
|
account={account}
|
||||||
|
accountFailed={accountPollOff}
|
||||||
|
positions={positions}
|
||||||
|
positionsFailed={positionsPollOff}
|
||||||
|
decisions={decisions}
|
||||||
|
decisionsFailed={decisionsPollOff}
|
||||||
|
decisionsLimit={decisionsLimit}
|
||||||
|
onDecisionsLimitChange={setDecisionsLimit}
|
||||||
|
stats={stats}
|
||||||
|
lastUpdate={lastUpdate}
|
||||||
|
language={language}
|
||||||
|
traders={traders}
|
||||||
|
tradersError={tradersError}
|
||||||
|
selectedTraderId={selectedTraderId}
|
||||||
|
onTraderSelect={(traderId) => {
|
||||||
|
setSelectedTraderId(traderId)
|
||||||
|
const trader = traders?.find((item) => item.trader_id === traderId)
|
||||||
|
navigate(
|
||||||
|
buildDashboardPath(trader ? getTraderSlug(trader) : undefined),
|
||||||
|
{
|
||||||
|
replace: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onNavigateToTraders={() => navigate(ROUTES.traders)}
|
||||||
|
exchanges={exchanges}
|
||||||
|
/>
|
||||||
|
</AppChrome>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppRoutes() {
|
||||||
|
const { user, token, isLoading } = useAuth()
|
||||||
|
const { config: systemConfig, loading: configLoading } = useSystemConfig()
|
||||||
|
const isAuthenticated = !!user && !!token
|
||||||
|
|
||||||
|
if (isLoading || configLoading) {
|
||||||
|
return <LoadingScreen />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemConfig && !systemConfig.initialized && !user) {
|
||||||
|
return <SetupPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LegacyHashRedirect />
|
||||||
|
<Routes>
|
||||||
|
<Route path={ROUTES.home} element={<LandingPage />} />
|
||||||
|
<Route path={ROUTES.login} element={<LoginPage />} />
|
||||||
|
<Route path={ROUTES.register} element={<RegisterPage />} />
|
||||||
|
<Route path={ROUTES.resetPassword} element={<ResetPasswordPage />} />
|
||||||
|
<Route
|
||||||
|
path={ROUTES.setup}
|
||||||
|
element={
|
||||||
|
systemConfig?.initialized ? (
|
||||||
|
<Navigate to={ROUTES.login} replace />
|
||||||
|
) : (
|
||||||
|
<SetupPage />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.faq}
|
||||||
|
element={
|
||||||
|
<AppChrome currentPage="faq" showFooter={false} wrapInMain={false}>
|
||||||
|
<FAQPage />
|
||||||
|
</AppChrome>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.data}
|
||||||
|
element={
|
||||||
|
<AppChrome currentPage="data" showFooter={false}>
|
||||||
|
<DataPage />
|
||||||
|
</AppChrome>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.settings}
|
||||||
|
element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<AppChrome showFooter={false}>
|
||||||
|
<SettingsPage />
|
||||||
|
</AppChrome>
|
||||||
|
) : (
|
||||||
|
<Navigate to={ROUTES.login} replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.welcome}
|
||||||
|
element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
getUserMode() === 'beginner' ? (
|
||||||
|
<TradersRoute showBeginnerOnboarding />
|
||||||
|
) : (
|
||||||
|
<Navigate to={ROUTES.traders} replace />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Navigate to={ROUTES.login} replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.competition}
|
||||||
|
element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<AppChrome currentPage="competition" animateContent>
|
||||||
|
<CompetitionPage />
|
||||||
|
</AppChrome>
|
||||||
|
) : (
|
||||||
|
<LandingPage />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.strategyMarket}
|
||||||
|
element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<AppChrome currentPage="strategy-market" animateContent>
|
||||||
|
<StrategyMarketPage />
|
||||||
|
</AppChrome>
|
||||||
|
) : (
|
||||||
|
<LandingPage />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.traders}
|
||||||
|
element={isAuthenticated ? <TradersRoute /> : <LandingPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.dashboard}
|
||||||
|
element={isAuthenticated ? <DashboardRoute /> : <LandingPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.strategy}
|
||||||
|
element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<AppChrome currentPage="strategy" animateContent>
|
||||||
|
<StrategyStudioPage />
|
||||||
|
</AppChrome>
|
||||||
|
) : (
|
||||||
|
<LandingPage />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to={ROUTES.home} replace />} />
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildDashboardPath,
|
||||||
|
getCurrentPageForPath,
|
||||||
|
LEGACY_HASH_ROUTES,
|
||||||
|
ROUTES,
|
||||||
|
} from './paths'
|
||||||
|
|
||||||
|
describe('router paths helpers', () => {
|
||||||
|
it('maps pathname to current navigation page', () => {
|
||||||
|
expect(getCurrentPageForPath(ROUTES.home)).toBeUndefined()
|
||||||
|
expect(getCurrentPageForPath(ROUTES.welcome)).toBe('traders')
|
||||||
|
expect(getCurrentPageForPath(ROUTES.dashboard)).toBe('trader')
|
||||||
|
expect(getCurrentPageForPath(ROUTES.strategyMarket)).toBe('strategy-market')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds dashboard path with optional trader query', () => {
|
||||||
|
expect(buildDashboardPath()).toBe(ROUTES.dashboard)
|
||||||
|
expect(buildDashboardPath('alpha-1234')).toBe(
|
||||||
|
'/dashboard?trader=alpha-1234'
|
||||||
|
)
|
||||||
|
expect(buildDashboardPath('alpha beta')).toBe(
|
||||||
|
'/dashboard?trader=alpha%20beta'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps legacy hash redirects aligned with current routes', () => {
|
||||||
|
expect(LEGACY_HASH_ROUTES.trader).toBe(ROUTES.dashboard)
|
||||||
|
expect(LEGACY_HASH_ROUTES.details).toBe(ROUTES.dashboard)
|
||||||
|
expect(LEGACY_HASH_ROUTES.strategy).toBe(ROUTES.strategy)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
export type Page =
|
||||||
|
| 'competition'
|
||||||
|
| 'traders'
|
||||||
|
| 'trader'
|
||||||
|
| 'strategy'
|
||||||
|
| 'strategy-market'
|
||||||
|
| 'data'
|
||||||
|
| 'faq'
|
||||||
|
| 'login'
|
||||||
|
| 'register'
|
||||||
|
|
||||||
|
export const ROUTES = {
|
||||||
|
home: '/',
|
||||||
|
login: '/login',
|
||||||
|
register: '/register',
|
||||||
|
setup: '/setup',
|
||||||
|
welcome: '/welcome',
|
||||||
|
faq: '/faq',
|
||||||
|
resetPassword: '/reset-password',
|
||||||
|
settings: '/settings',
|
||||||
|
data: '/data',
|
||||||
|
competition: '/competition',
|
||||||
|
traders: '/traders',
|
||||||
|
dashboard: '/dashboard',
|
||||||
|
strategy: '/strategy',
|
||||||
|
strategyMarket: '/strategy-market',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PAGE_PATHS: Record<Page, string> = {
|
||||||
|
competition: ROUTES.competition,
|
||||||
|
traders: ROUTES.traders,
|
||||||
|
trader: ROUTES.dashboard,
|
||||||
|
strategy: ROUTES.strategy,
|
||||||
|
'strategy-market': ROUTES.strategyMarket,
|
||||||
|
data: ROUTES.data,
|
||||||
|
faq: ROUTES.faq,
|
||||||
|
login: ROUTES.login,
|
||||||
|
register: ROUTES.register,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEGACY_HASH_ROUTES: Record<string, string> = {
|
||||||
|
competition: ROUTES.competition,
|
||||||
|
traders: ROUTES.traders,
|
||||||
|
trader: ROUTES.dashboard,
|
||||||
|
details: ROUTES.dashboard,
|
||||||
|
strategy: ROUTES.strategy,
|
||||||
|
'strategy-market': ROUTES.strategyMarket,
|
||||||
|
data: ROUTES.data,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPageForPath(pathname: string): Page | undefined {
|
||||||
|
switch (pathname) {
|
||||||
|
case ROUTES.welcome:
|
||||||
|
case ROUTES.traders:
|
||||||
|
return 'traders'
|
||||||
|
case ROUTES.dashboard:
|
||||||
|
return 'trader'
|
||||||
|
case ROUTES.strategy:
|
||||||
|
return 'strategy'
|
||||||
|
case ROUTES.strategyMarket:
|
||||||
|
return 'strategy-market'
|
||||||
|
case ROUTES.data:
|
||||||
|
return 'data'
|
||||||
|
case ROUTES.faq:
|
||||||
|
return 'faq'
|
||||||
|
case ROUTES.login:
|
||||||
|
return 'login'
|
||||||
|
case ROUTES.register:
|
||||||
|
return 'register'
|
||||||
|
case ROUTES.competition:
|
||||||
|
return 'competition'
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDashboardPath(traderSlug?: string): string {
|
||||||
|
if (!traderSlug) {
|
||||||
|
return ROUTES.dashboard
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ROUTES.dashboard}?trader=${encodeURIComponent(traderSlug)}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user