mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Remove the entire AI Debate Arena module (~5,300 lines) to simplify the codebase. This removes the multi-AI debate trading decision system including backend engine, API handlers, database store, frontend page, navigation, translations, and documentation references.
675 lines
23 KiB
TypeScript
675 lines
23 KiB
TypeScript
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/AITradersPage'
|
||
import { LoginPage } from './components/LoginPage'
|
||
import { SetupPage } from './components/SetupPage'
|
||
import { SettingsPage } from './pages/SettingsPage'
|
||
import { ResetPasswordPage } from './components/ResetPasswordPage'
|
||
import { CompetitionPage } from './components/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 { LoginRequiredOverlay } from './components/LoginRequiredOverlay'
|
||
import HeaderBar from './components/HeaderBar'
|
||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||
import { ConfirmDialogProvider } from './components/ConfirmDialog'
|
||
import { t } from './i18n/translations'
|
||
import { useSystemConfig } from './hooks/useSystemConfig'
|
||
|
||
import { OFFICIAL_LINKS } from './constants/branding'
|
||
import { BacktestPage } from './components/BacktestPage'
|
||
import type {
|
||
SystemStatus,
|
||
AccountInfo,
|
||
Position,
|
||
DecisionRecord,
|
||
Statistics,
|
||
TraderInfo,
|
||
Exchange,
|
||
} from './types'
|
||
|
||
type Page =
|
||
| 'competition'
|
||
| 'traders'
|
||
| 'trader'
|
||
| 'backtest'
|
||
| '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)
|
||
|
||
// Debug log
|
||
useEffect(() => {
|
||
console.log('[App] Mounted. Route:', window.location.pathname);
|
||
}, []);
|
||
|
||
// 从URL路径读取初始页面状态(支持刷新保持页面)
|
||
const getInitialPage = (): Page => {
|
||
const path = window.location.pathname
|
||
const hash = window.location.hash.slice(1) // 去掉 #
|
||
|
||
if (path === '/traders' || hash === 'traders') return 'traders'
|
||
if (path === '/backtest' || hash === 'backtest') return 'backtest'
|
||
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',
|
||
'backtest': '/backtest',
|
||
'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)
|
||
|
||
// 监听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 === '/traders' || hash === 'traders') {
|
||
setCurrentPage('traders')
|
||
} else if (path === '/backtest' || hash === 'backtest') {
|
||
setCurrentPage('backtest')
|
||
} 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
|
||
if (traderParam) {
|
||
setSelectedTraderSlug(traderParam)
|
||
}
|
||
} 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,
|
||
{
|
||
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 && !selectedTraderId) {
|
||
if (selectedTraderSlug) {
|
||
// 通过 slug 找到对应的 trader
|
||
const trader = findTraderBySlug(selectedTraderSlug, traders)
|
||
if (trader) {
|
||
setSelectedTraderId(trader.trader_id)
|
||
} else {
|
||
// 如果找不到,选中第一个
|
||
setSelectedTraderId(traders[0].trader_id)
|
||
}
|
||
} else {
|
||
setSelectedTraderId(traders[0].trader_id)
|
||
}
|
||
}
|
||
}, [traders, selectedTraderId, selectedTraderSlug])
|
||
|
||
// 如果在trader页面,获取该trader的数据
|
||
const { data: status } = useSWR<SystemStatus>(
|
||
currentPage === 'trader' && selectedTraderId
|
||
? `status-${selectedTraderId}`
|
||
: null,
|
||
() => api.getStatus(selectedTraderId),
|
||
{
|
||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
||
}
|
||
)
|
||
|
||
const { data: account } = useSWR<AccountInfo>(
|
||
currentPage === 'trader' && selectedTraderId
|
||
? `account-${selectedTraderId}`
|
||
: null,
|
||
() => api.getAccount(selectedTraderId),
|
||
{
|
||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
||
}
|
||
)
|
||
|
||
const { data: positions } = useSWR<Position[]>(
|
||
currentPage === 'trader' && selectedTraderId
|
||
? `positions-${selectedTraderId}`
|
||
: null,
|
||
() => api.getPositions(selectedTraderId),
|
||
{
|
||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
||
}
|
||
)
|
||
|
||
const { data: decisions } = useSWR<DecisionRecord[]>(
|
||
currentPage === 'trader' && selectedTraderId
|
||
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
|
||
: null,
|
||
() => api.getLatestDecisions(selectedTraderId, decisionsLimit),
|
||
{
|
||
refreshInterval: 30000, // 30秒刷新(决策更新频率较低)
|
||
revalidateOnFocus: false,
|
||
dedupingInterval: 20000,
|
||
}
|
||
)
|
||
|
||
const { data: stats } = useSWR<Statistics>(
|
||
currentPage === 'trader' && selectedTraderId
|
||
? `statistics-${selectedTraderId}`
|
||
: null,
|
||
() => api.getStatistics(selectedTraderId),
|
||
{
|
||
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)
|
||
|
||
// 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 === '/competition') {
|
||
setCurrentPage('competition')
|
||
} else if (route === '/traders') {
|
||
setCurrentPage('traders')
|
||
} else if (route === '/dashboard') {
|
||
setCurrentPage('trader')
|
||
}
|
||
}, [route])
|
||
|
||
// 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 === '/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) {
|
||
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) => {
|
||
const pathMap: Record<string, string> = {
|
||
'data': '/data',
|
||
'competition': '/competition',
|
||
'strategy-market': '/strategy-market',
|
||
'traders': '/traders',
|
||
'trader': '/dashboard',
|
||
'backtest': '/backtest',
|
||
'strategy': '/strategy',
|
||
'faq': '/faq',
|
||
}
|
||
const path = pathMap[page]
|
||
if (path) {
|
||
window.location.href = path
|
||
}
|
||
}
|
||
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)
|
||
window.history.pushState({}, '', '/dashboard')
|
||
setRoute('/dashboard')
|
||
setCurrentPage('trader')
|
||
}}
|
||
/>
|
||
) : currentPage === 'backtest' ? (
|
||
<BacktestPage />
|
||
) : currentPage === 'strategy' ? (
|
||
<StrategyStudioPage />
|
||
) : (
|
||
<TraderDashboardPage
|
||
selectedTrader={selectedTrader}
|
||
status={status}
|
||
account={account}
|
||
positions={positions}
|
||
decisions={decisions}
|
||
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 url = new URL(window.location.href)
|
||
url.searchParams.set('trader', getTraderSlug(trader))
|
||
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}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
// Wrap App with providers
|
||
export default function AppWithProviders() {
|
||
return (
|
||
<LanguageProvider>
|
||
<AuthProvider>
|
||
<ConfirmDialogProvider>
|
||
<App />
|
||
</ConfirmDialogProvider>
|
||
</AuthProvider>
|
||
</LanguageProvider>
|
||
)
|
||
}
|