mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-18 09:54:35 +08:00
fix: use completeRegistration for incomplete OTP setup in login flow
- LoginPage: call completeRegistration instead of verifyOTP when qrCodeURL exists - This ensures otp_verified is set to true for users completing OTP setup - Backend: reorder maxUsers check to allow existing incomplete users to continue - Backend: return OTP info when login with incomplete OTP setup
This commit is contained in:
@@ -2989,7 +2989,44 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check max users limit
|
var req struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists (must check before maxUsers to allow incomplete OTP users)
|
||||||
|
existingUser, err := s.store.User().GetByEmail(req.Email)
|
||||||
|
if err == nil {
|
||||||
|
// User exists, check OTP verification status
|
||||||
|
if !existingUser.OTPVerified {
|
||||||
|
// OTP not verified, verify password first for security
|
||||||
|
if !auth.CheckPassword(req.Password, existingUser.PasswordHash) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Password correct, allow user to continue OTP setup
|
||||||
|
// Return existing OTP information
|
||||||
|
qrCodeURL := auth.GetOTPQRCodeURL(existingUser.OTPSecret, req.Email)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user_id": existingUser.ID,
|
||||||
|
"email": existingUser.Email,
|
||||||
|
"otp_secret": existingUser.OTPSecret,
|
||||||
|
"qr_code_url": qrCodeURL,
|
||||||
|
"message": "Incomplete registration detected, please continue OTP setup",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// OTP already verified, reject duplicate registration
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max users limit (only for new users)
|
||||||
maxUsers := config.Get().MaxUsers
|
maxUsers := config.Get().MaxUsers
|
||||||
if maxUsers > 0 {
|
if maxUsers > 0 {
|
||||||
userCount, err := s.store.User().Count()
|
userCount, err := s.store.User().Count()
|
||||||
@@ -3003,23 +3040,6 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid request parameters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email already exists
|
|
||||||
_, err := s.store.User().GetByEmail(req.Email)
|
|
||||||
if err == nil {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate password hash
|
// Generate password hash
|
||||||
passwordHash, err := auth.HashPassword(req.Password)
|
passwordHash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3141,10 +3161,15 @@ func (s *Server) handleLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// Check if OTP is verified
|
// Check if OTP is verified
|
||||||
if !user.OTPVerified {
|
if !user.OTPVerified {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
// Return OTP info so user can complete setup
|
||||||
"error": "Account has not completed OTP setup",
|
qrCodeURL := auth.GetOTPQRCodeURL(user.OTPSecret, user.Email)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user_id": user.ID,
|
"user_id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"otp_secret": user.OTPSecret,
|
||||||
|
"qr_code_url": qrCodeURL,
|
||||||
"requires_otp_setup": true,
|
"requires_otp_setup": true,
|
||||||
|
"message": "Please complete OTP setup first",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export function AdvancedChart({
|
|||||||
symbol = 'BTCUSDT',
|
symbol = 'BTCUSDT',
|
||||||
interval = '5m',
|
interval = '5m',
|
||||||
traderID,
|
traderID,
|
||||||
height = 550,
|
|
||||||
exchange = 'binance', // 默认使用 binance
|
exchange = 'binance', // 默认使用 binance
|
||||||
onSymbolChange: _onSymbolChange, // Available for future use
|
onSymbolChange: _onSymbolChange, // Available for future use
|
||||||
}: AdvancedChartProps) {
|
}: AdvancedChartProps) {
|
||||||
@@ -347,7 +346,7 @@ export function AdvancedChart({
|
|||||||
|
|
||||||
const chart = createChart(chartContainerRef.current, {
|
const chart = createChart(chartContainerRef.current, {
|
||||||
width: chartContainerRef.current.clientWidth,
|
width: chartContainerRef.current.clientWidth,
|
||||||
height: height,
|
height: chartContainerRef.current.clientHeight, // Use container height
|
||||||
layout: {
|
layout: {
|
||||||
background: { color: '#0B0E11' },
|
background: { color: '#0B0E11' },
|
||||||
textColor: '#B7BDC6',
|
textColor: '#B7BDC6',
|
||||||
@@ -447,16 +446,16 @@ export function AdvancedChart({
|
|||||||
})
|
})
|
||||||
volumeSeriesRef.current = volumeSeries as any
|
volumeSeriesRef.current = volumeSeries as any
|
||||||
|
|
||||||
// 响应式调整
|
// 响应式调整 (ResizeObserver)
|
||||||
const handleResize = () => {
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
if (chartContainerRef.current && chartRef.current) {
|
if (entries.length === 0 || !entries[0].contentRect) return
|
||||||
chartRef.current.applyOptions({
|
const { width, height } = entries[0].contentRect
|
||||||
width: chartContainerRef.current.clientWidth,
|
chart.applyOptions({ width, height })
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize)
|
if (chartContainerRef.current) {
|
||||||
|
resizeObserver.observe(chartContainerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
// 监听鼠标移动,显示 OHLC 信息
|
// 监听鼠标移动,显示 OHLC 信息
|
||||||
chart.subscribeCrosshairMove((param) => {
|
chart.subscribeCrosshairMove((param) => {
|
||||||
@@ -490,10 +489,11 @@ export function AdvancedChart({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize)
|
resizeObserver.disconnect()
|
||||||
chart.remove()
|
chart.remove()
|
||||||
}
|
}
|
||||||
}, [height])
|
}, []) // Removed [height] dependency as we now autosize
|
||||||
|
|
||||||
|
|
||||||
// 加载数据和指标
|
// 加载数据和指标
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1486,7 +1486,7 @@ export function BacktestPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||||
{tr('form.feeLabel')}
|
{tr('form.feeLabel')}
|
||||||
|
|||||||
@@ -145,14 +145,19 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
|||||||
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="nofx-glass rounded-lg border border-white/5 relative z-10 w-full h-[600px] flex flex-col">
|
<div className={`nofx-glass rounded-lg border border-white/5 relative z-10 w-full flex flex-col transition-all duration-300 ${typeof window !== 'undefined' && window.innerWidth < 768 ? 'h-[500px]' : 'h-[600px]'
|
||||||
{/* Clean Professional Toolbar */}
|
}`}>
|
||||||
|
{/*
|
||||||
|
Premium Professional Toolbar
|
||||||
|
Mobile: Single row, horizontal scroll with gradient mask
|
||||||
|
Desktop: Standard flex-wrap/nowrap
|
||||||
|
*/}
|
||||||
<div
|
<div
|
||||||
className="relative z-20 flex flex-wrap md:flex-nowrap items-center justify-between gap-y-2 px-3 py-2 shrink-0 backdrop-blur-md bg-[#0B0E11]/80 rounded-t-lg"
|
className="relative z-20 flex flex-wrap md:flex-nowrap items-center justify-between gap-y-2 px-3 py-2 shrink-0 backdrop-blur-md bg-[#0B0E11]/80 rounded-t-lg"
|
||||||
style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}
|
style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}
|
||||||
>
|
>
|
||||||
{/* Left: Tab Switcher */}
|
{/* Left: Tab Switcher */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('equity')}
|
onClick={() => setActiveTab('equity')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'equity'
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'equity'
|
||||||
@@ -161,7 +166,8 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<BarChart3 className="w-3.5 h-3.5" />
|
<BarChart3 className="w-3.5 h-3.5" />
|
||||||
<span>{t('accountEquityCurve', language)}</span>
|
<span className="hidden md:inline">{t('accountEquityCurve', language)}</span>
|
||||||
|
<span className="md:hidden">Eq</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -172,33 +178,31 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CandlestickChart className="w-3.5 h-3.5" />
|
<CandlestickChart className="w-3.5 h-3.5" />
|
||||||
<span>{t('marketChart', language)}</span>
|
<span className="hidden md:inline">{t('marketChart', language)}</span>
|
||||||
|
<span className="md:hidden">Kline</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Market Type Pills - Only when kline active */}
|
{/* Market Type Pills - Only when kline active, HIDDEN on mobile to save space */}
|
||||||
{activeTab === 'kline' && (
|
{activeTab === 'kline' && (
|
||||||
<>
|
<div className="hidden md:flex items-center gap-1 ml-2 border-l border-white/10 pl-2">
|
||||||
<div className="w-px h-4 bg-white/10 mx-2" />
|
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
|
||||||
<div className="flex items-center gap-1">
|
const config = MARKET_CONFIG[type]
|
||||||
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
|
const isActive = marketType === type
|
||||||
const config = MARKET_CONFIG[type]
|
return (
|
||||||
const isActive = marketType === type
|
<button
|
||||||
return (
|
key={type}
|
||||||
<button
|
onClick={() => handleMarketTypeChange(type)}
|
||||||
key={type}
|
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
|
||||||
onClick={() => handleMarketTypeChange(type)}
|
? 'bg-white/10 text-white border-white/20'
|
||||||
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
|
: 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
|
||||||
? 'bg-white/10 text-white border-white/20'
|
}`}
|
||||||
: 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
|
>
|
||||||
}`}
|
<span className="mr-1 opacity-70">{config.icon}</span>
|
||||||
>
|
{language === 'zh' ? config.label.zh : config.label.en}
|
||||||
<span className="mr-1 opacity-70">{config.icon}</span>
|
</button>
|
||||||
{language === 'zh' ? config.label.zh : config.label.en}
|
)
|
||||||
</button>
|
})}
|
||||||
)
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,8 +298,8 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content - Chart autosizes to this container */}
|
||||||
<div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden">
|
<div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden h-full min-h-0">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{activeTab === 'equity' ? (
|
{activeTab === 'equity' ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -321,8 +325,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
|||||||
symbol={chartSymbol}
|
symbol={chartSymbol}
|
||||||
interval={interval}
|
interval={interval}
|
||||||
traderID={traderId}
|
traderID={traderId}
|
||||||
// Dynamic height to fill container
|
// Dynamic auto-sizing via ResizeObserver
|
||||||
height={550}
|
|
||||||
exchange={currentExchange}
|
exchange={currentExchange}
|
||||||
onSymbolChange={setChartSymbol}
|
onSymbolChange={setChartSymbol}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Menu, X, ChevronDown } from 'lucide-react'
|
import { Menu, X, ChevronDown } from 'lucide-react'
|
||||||
import { t, type Language } from '../i18n/translations'
|
import { t, type Language } from '../i18n/translations'
|
||||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||||
@@ -306,209 +306,170 @@ export default function HeaderBar({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu Overlay */}
|
||||||
<motion.div
|
<AnimatePresence>
|
||||||
initial={false}
|
{mobileMenuOpen && (
|
||||||
animate={
|
<motion.div
|
||||||
mobileMenuOpen
|
initial={{ opacity: 0 }}
|
||||||
? { height: 'auto', opacity: 1 }
|
animate={{ opacity: 1 }}
|
||||||
: { height: 0, opacity: 0 }
|
exit={{ opacity: 0 }}
|
||||||
}
|
transition={{ duration: 0.2 }}
|
||||||
transition={{ duration: 0.3 }}
|
className="fixed inset-0 z-40 md:hidden bg-black/90 backdrop-blur-xl"
|
||||||
className="md:hidden overflow-hidden bg-nofx-bg-lighter border-t border-nofx-gold/10"
|
style={{ top: '64px' }} // Below header
|
||||||
>
|
>
|
||||||
<div className="px-4 py-4 space-y-2">
|
<motion.div
|
||||||
{/* Mobile Navigation Tabs - Show all tabs */}
|
initial={{ y: -20, opacity: 0 }}
|
||||||
{(() => {
|
animate={{ y: 0, opacity: 1 }}
|
||||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
transition={{ delay: 0.1, duration: 0.3 }}
|
||||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
|
className="flex flex-col h-[calc(100vh-64px)] overflow-y-auto px-6 py-8"
|
||||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
|
||||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
|
||||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
|
||||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
|
||||||
{ page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
|
|
||||||
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
|
|
||||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleMobileNavClick = (tab: typeof navTabs[0]) => {
|
|
||||||
if (tab.requiresAuth && !isLoggedIn) {
|
|
||||||
onLoginRequired?.(tab.label)
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (onPageChange) {
|
|
||||||
onPageChange(tab.page)
|
|
||||||
}
|
|
||||||
navigate(tab.path)
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return navTabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.page}
|
|
||||||
onClick={() => handleMobileNavClick(tab)}
|
|
||||||
className={`block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 w-full text-left px-4 py-3 rounded-lg
|
|
||||||
${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-white hover:bg-white/5'}`}
|
|
||||||
>
|
|
||||||
{currentPage === tab.page && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{tab.label}
|
|
||||||
{tab.requiresAuth && !isLoggedIn && (
|
|
||||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-nofx-gold/20 text-nofx-gold">
|
|
||||||
{language === 'zh' ? '需登录' : 'Login'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Original Navigation Items - Only on home page */}
|
|
||||||
{isHomePage &&
|
|
||||||
[
|
|
||||||
{ key: 'features', label: t('features', language) },
|
|
||||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
|
||||||
].map((item) => (
|
|
||||||
<a
|
|
||||||
key={item.key}
|
|
||||||
href={`#${item.key === 'features' ? 'features' : 'how-it-works'}`}
|
|
||||||
className="block text-sm py-2 text-nofx-text-muted hover:text-white"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Social Links - Mobile */}
|
|
||||||
<div className="py-3 flex items-center gap-3 border-t border-nofx-gold/20">
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_LINKS.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-white"
|
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
{/* Navigation Links */}
|
||||||
<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" />
|
<div className="flex flex-col gap-6 mb-12">
|
||||||
</svg>
|
{(() => {
|
||||||
</a>
|
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||||
<a
|
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
|
||||||
href={OFFICIAL_LINKS.twitter}
|
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||||
target="_blank"
|
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||||
rel="noopener noreferrer"
|
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#1DA1F2]"
|
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||||
>
|
{ page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
|
||||||
<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" />
|
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||||
</svg>
|
]
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_LINKS.telegram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#0088cc]"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Language Toggle */}
|
const handleMobileNavClick = (tab: typeof navTabs[0]) => {
|
||||||
<div className="py-2">
|
if (tab.requiresAuth && !isLoggedIn) {
|
||||||
<div className="flex items-center gap-2 mb-2">
|
onLoginRequired?.(tab.label)
|
||||||
<span className="text-xs text-nofx-text-muted">
|
setMobileMenuOpen(false)
|
||||||
{t('language', language)}:
|
return
|
||||||
</span>
|
}
|
||||||
</div>
|
if (onPageChange) {
|
||||||
<div className="space-y-1">
|
onPageChange(tab.page)
|
||||||
<button
|
}
|
||||||
onClick={() => {
|
navigate(tab.path)
|
||||||
onLanguageChange?.('zh')
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${language === 'zh'
|
|
||||||
? 'bg-yellow-500 text-black'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">🇨🇳</span>
|
|
||||||
<span className="text-sm">中文</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.('en')
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${language === 'en'
|
|
||||||
? 'bg-yellow-500 text-black'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">🇺🇸</span>
|
|
||||||
<span className="text-sm">English</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User info and logout for mobile when logged in */}
|
|
||||||
{isLoggedIn && user && (
|
|
||||||
<div
|
|
||||||
className="mt-4 pt-4 border-t border-nofx-gold/20"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 mb-2 rounded bg-nofx-bg-lighter">
|
|
||||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-nofx-gold text-black">
|
|
||||||
{user.email[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-nofx-text-muted">
|
|
||||||
{t('loggedInAs', language)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-nofx-text-muted">
|
|
||||||
{user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onLogout && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLogout()
|
|
||||||
setMobileMenuOpen(false)
|
setMobileMenuOpen(false)
|
||||||
}}
|
}
|
||||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center bg-nofx-danger/20 text-nofx-danger"
|
|
||||||
>
|
|
||||||
{t('exitLogin', language)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show login/register buttons when not logged in and not on login/register pages */}
|
return navTabs.map((tab, i) => (
|
||||||
{!isLoggedIn &&
|
<motion.button
|
||||||
currentPage !== 'login' &&
|
key={tab.page}
|
||||||
currentPage !== 'register' && (
|
initial={{ x: -20, opacity: 0 }}
|
||||||
<div className="space-y-2 mt-2">
|
animate={{ x: 0, opacity: 1 }}
|
||||||
<a
|
transition={{ delay: 0.1 + i * 0.05 }}
|
||||||
href="/login"
|
onClick={() => handleMobileNavClick(tab)}
|
||||||
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors text-nofx-text-muted border border-nofx-text-muted hover:text-white hover:border-white"
|
className={`text-2xl font-black tracking-tight text-left flex items-center gap-3
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
${currentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
|
||||||
>
|
>
|
||||||
{t('signIn', language)}
|
{currentPage === tab.page && (
|
||||||
</a>
|
<motion.div
|
||||||
{registrationEnabled && (
|
layoutId="active-indicator"
|
||||||
<a
|
className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
|
||||||
href="/register"
|
/>
|
||||||
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors bg-nofx-gold text-black hover:opacity-90"
|
)}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
{tab.label}
|
||||||
>
|
{tab.requiresAuth && !isLoggedIn && (
|
||||||
{t('signUp', language)}
|
<span className="text-[10px] px-1.5 py-0.5 rounded border border-zinc-800 text-zinc-500 font-normal tracking-wide uppercase align-middle relative -top-1">
|
||||||
</a>
|
LOGIN_REQ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Original Page Links */}
|
||||||
|
{isHomePage && (
|
||||||
|
<div className="pt-6 border-t border-white/5 space-y-4">
|
||||||
|
{[
|
||||||
|
{ key: 'features', label: t('features', language) },
|
||||||
|
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||||
|
].map((item, i) => (
|
||||||
|
<motion.a
|
||||||
|
key={item.key}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.5 + i * 0.1 }}
|
||||||
|
href={`#${item.key === 'features' ? 'features' : 'how-it-works'}`}
|
||||||
|
className="block text-lg font-mono text-zinc-600 hover:text-white"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{'>'} {item.label}
|
||||||
|
</motion.a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Bottom Actions */}
|
||||||
</motion.div>
|
<div className="mt-auto space-y-8">
|
||||||
|
{/* Social Links */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{[
|
||||||
|
{ href: OFFICIAL_LINKS.github, icon: <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" /> },
|
||||||
|
{ href: OFFICIAL_LINKS.twitter, icon: <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> },
|
||||||
|
{ href: OFFICIAL_LINKS.telegram, icon: <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /> }
|
||||||
|
].map((link, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="w-12 h-12 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 hover:text-nofx-gold hover:border-nofx-gold transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
{link.icon}
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account / Lang */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Lang Switcher */}
|
||||||
|
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
|
||||||
|
{['zh', 'en'].map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang}
|
||||||
|
onClick={() => {
|
||||||
|
onLanguageChange?.(lang as Language)
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${language === lang
|
||||||
|
? 'bg-zinc-800 text-white shadow-sm'
|
||||||
|
: 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lang === 'zh' ? 'CN' : 'EN'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Actions */}
|
||||||
|
{isLoggedIn && user ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLogout?.()
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className="bg-red-500/10 border border-red-500/20 text-red-500 rounded-lg font-bold text-sm hover:bg-red-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
{t('exitLogin', language)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
currentPage !== 'login' && currentPage !== 'register' && (
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="flex items-center justify-center bg-nofx-gold text-black rounded-lg font-bold text-sm hover:bg-yellow-400 transition-colors"
|
||||||
|
>
|
||||||
|
{t('signIn', language)}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import { useSystemConfig } from '../hooks/useSystemConfig'
|
|||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const { login, loginAdmin, verifyOTP } = useAuth()
|
const { login, loginAdmin, verifyOTP, completeRegistration } = useAuth()
|
||||||
const [step, setStep] = useState<'login' | 'otp'>('login')
|
const [step, setStep] = useState<'login' | 'otp' | 'setup-otp'>('login')
|
||||||
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 [otpCode, setOtpCode] = useState('')
|
const [otpCode, setOtpCode] = useState('')
|
||||||
const [userID, setUserID] = useState('')
|
const [userID, setUserID] = useState('')
|
||||||
|
const [qrCodeURL, setQrCodeURL] = useState('') // New state for recovery
|
||||||
|
const [otpSecret, setOtpSecret] = useState('') // New state for recovery
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [adminPassword, setAdminPassword] = useState('')
|
const [adminPassword, setAdminPassword] = useState('')
|
||||||
@@ -62,9 +64,25 @@ export function LoginPage() {
|
|||||||
const result = await login(email, password)
|
const result = await login(email, password)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.requiresOTP && result.userID) {
|
// Check for incomplete OTP setup (user registered but didn't complete 2FA)
|
||||||
|
if (result.requiresOTPSetup && result.userID) {
|
||||||
setUserID(result.userID)
|
setUserID(result.userID)
|
||||||
setStep('otp')
|
setQrCodeURL(result.qrCodeURL || '')
|
||||||
|
setOtpSecret(result.otpSecret || '')
|
||||||
|
setStep('setup-otp')
|
||||||
|
toast.info("Pending 2FA setup detected. Please complete configuration.")
|
||||||
|
} else if (result.requiresOTP && result.userID) {
|
||||||
|
setUserID(result.userID)
|
||||||
|
|
||||||
|
// Check if backend provided recovery data (meaning 2FA is pending setup)
|
||||||
|
if (result.qrCodeURL) {
|
||||||
|
setQrCodeURL(result.qrCodeURL)
|
||||||
|
setOtpSecret(result.otpSecret || '')
|
||||||
|
setStep('setup-otp')
|
||||||
|
toast.info("Pending 2FA setup detected. Please complete configuration.")
|
||||||
|
} else {
|
||||||
|
setStep('otp')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Dismiss the "login expired" toast on successful login (no OTP required)
|
// Dismiss the "login expired" toast on successful login (no OTP required)
|
||||||
if (expiredToastId) {
|
if (expiredToastId) {
|
||||||
@@ -72,9 +90,18 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = result.message || t('loginFailed', language)
|
// Check if we have recovery data despite the error (e.g. "Account has not completed OTP setup")
|
||||||
setError(msg)
|
if (result.qrCodeURL) {
|
||||||
toast.error(msg)
|
setUserID(result.userID || '') // We might need to ensure userID is returned in error case too, or derived
|
||||||
|
setQrCodeURL(result.qrCodeURL)
|
||||||
|
setOtpSecret(result.otpSecret || '')
|
||||||
|
setStep('setup-otp')
|
||||||
|
toast.warning(t('completeGapSetup', language) || "Incomplete setup detected. Please configure 2FA.")
|
||||||
|
} else {
|
||||||
|
const msg = result.message || t('loginFailed', language)
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -85,7 +112,11 @@ export function LoginPage() {
|
|||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const result = await verifyOTP(userID, otpCode)
|
// If we have qrCodeURL, it means user needs to complete registration (first time OTP setup)
|
||||||
|
// Otherwise, it's a normal login OTP verification
|
||||||
|
const result = qrCodeURL
|
||||||
|
? await completeRegistration(userID, otpCode)
|
||||||
|
: await verifyOTP(userID, otpCode)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const msg = result.message || t('verificationFailed', language)
|
const msg = result.message || t('verificationFailed', language)
|
||||||
@@ -96,12 +127,20 @@ export function LoginPage() {
|
|||||||
if (expiredToastId) {
|
if (expiredToastId) {
|
||||||
toast.dismiss(expiredToastId)
|
toast.dismiss(expiredToastId)
|
||||||
}
|
}
|
||||||
|
// Clear qrCodeURL after successful completion
|
||||||
|
setQrCodeURL('')
|
||||||
|
setOtpSecret('')
|
||||||
}
|
}
|
||||||
// 成功的话AuthContext会自动处理登录状态
|
// 成功的话AuthContext会自动处理登录状态
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
toast.success('Copied to clipboard')
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
@@ -202,6 +241,66 @@ export function LoginPage() {
|
|||||||
{loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
|
{loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
) : step === 'setup-otp' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center bg-zinc-900/50 p-4 rounded border border-zinc-800">
|
||||||
|
<div className="text-xs font-mono text-zinc-400 mb-2">COMPLETE 2FA CONFIGURATION</div>
|
||||||
|
{qrCodeURL ? (
|
||||||
|
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
|
||||||
|
<img
|
||||||
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(`otpauth://totp/NoFX:${email}?secret=${otpSecret}&issuer=NoFX`)}`}
|
||||||
|
alt="QR Code"
|
||||||
|
className="w-32 h-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-32 h-32 bg-zinc-800 animate-pulse rounded inline-block"></div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Backup Secret Key</p>
|
||||||
|
<div className="flex items-center gap-2 justify-center bg-black/50 p-2 rounded border border-zinc-700/50 max-w-[200px] mx-auto">
|
||||||
|
<code className="text-xs font-mono text-nofx-gold">{otpSecret}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(otpSecret)}
|
||||||
|
className="text-zinc-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] uppercase border border-zinc-700 px-1 rounded">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 font-mono text-xs text-zinc-400 bg-black/20 p-4 rounded border border-zinc-800/50">
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<span className="text-nofx-gold font-bold mt-0.5">01</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white mb-1">Install Authenticator App</p>
|
||||||
|
<p className="mb-2">Recommended: <span className="text-nofx-gold">Google Authenticator</span>.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">iOS</span>
|
||||||
|
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">Android</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-zinc-800/50"></div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<span className="text-nofx-gold font-bold mt-0.5">02</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white mb-1">Scan & Verify</p>
|
||||||
|
<p>Scan code above, then enter the 6-digit token below to activate your account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setStep('otp')}
|
||||||
|
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-colors font-mono shadow-lg"
|
||||||
|
>
|
||||||
|
I HAVE SCANNED THE CODE →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : step === 'login' ? (
|
) : step === 'login' ? (
|
||||||
<form onSubmit={handleLogin} className="space-y-5">
|
<form onSubmit={handleLogin} className="space-y-5">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ export function RegisterPage() {
|
|||||||
{qrCodeURL ? (
|
{qrCodeURL ? (
|
||||||
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
|
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
|
||||||
<img
|
<img
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(`otpauth://totp/NoFX:${email}?secret=${otpSecret}&issuer=NoFX`)}`}
|
||||||
alt="QR Code"
|
alt="QR Code"
|
||||||
className="w-32 h-32"
|
className="w-32 h-32"
|
||||||
/>
|
/>
|
||||||
@@ -370,18 +370,42 @@ export function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 font-mono text-xs text-zinc-400">
|
<div className="space-y-4 font-mono text-xs text-zinc-400 bg-black/20 p-4 rounded border border-zinc-800/50">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 items-start">
|
||||||
<span className="text-nofx-gold mt-0.5">01</span>
|
<span className="text-nofx-gold font-bold mt-0.5">01</span>
|
||||||
<p>Install Google Authenticator or Authy on your mobile device.</p>
|
<div>
|
||||||
|
<p className="font-bold text-white mb-1">Install Authenticator App</p>
|
||||||
|
<p className="mb-2">We highly recommend <span className="text-nofx-gold">Google Authenticator</span> for compatibility.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">iOS</span>
|
||||||
|
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">Android</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
|
||||||
<span className="text-nofx-gold mt-0.5">02</span>
|
<div className="w-full h-px bg-zinc-800/50"></div>
|
||||||
<p>Scan the QR code above or manually enter the secret key.</p>
|
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<span className="text-nofx-gold font-bold mt-0.5">02</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white mb-1">Scan QR Code</p>
|
||||||
|
<p>Open Google Authenticator, tap the <span className="text-white">+</span> button, and scan the code above.</p>
|
||||||
|
<p className="text-[10px] text-zinc-500 mt-1 italic">Protocol: Time-Based OTP (TOTP)</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
|
||||||
<span className="text-nofx-gold mt-0.5">03</span>
|
<div className="w-full h-px bg-zinc-800/50"></div>
|
||||||
<p>Proceed to verify the generated 6-digit token.</p>
|
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<span className="text-nofx-gold font-bold mt-0.5">03</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white mb-1">Verify Token</p>
|
||||||
|
<p>Enter the 6-digit code generated by the app.</p>
|
||||||
|
<div className="mt-2 p-2 bg-yellow-500/10 border border-yellow-500/20 rounded text-[10px] text-yellow-500/80 flex gap-2 items-start">
|
||||||
|
<span className="mt-px">⚠️</span>
|
||||||
|
<span>Stuck? Ensure your phone's time is set to "Automatic". Time drift causes codes to fail.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { useAuth } from '../../../contexts/AuthContext'
|
||||||
|
|
||||||
const agents = [
|
const agents = [
|
||||||
{
|
{
|
||||||
@@ -43,7 +44,15 @@ const agents = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function AgentGrid() {
|
export default function AgentGrid() {
|
||||||
// Simplified State to prevent crash
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const handleInitialize = () => {
|
||||||
|
if (user) {
|
||||||
|
window.location.href = '/strategy-market'
|
||||||
|
} else {
|
||||||
|
window.location.href = '/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">
|
||||||
@@ -118,7 +127,10 @@ export default function AgentGrid() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Btn */}
|
{/* Action Btn */}
|
||||||
<button 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`}>
|
<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>
|
<span className={agent.color}>[</span> INITIALIZE <span className={agent.color}>]</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export default function TerminalHero() {
|
|||||||
{/* Main Title - Massive & Impactful */}
|
{/* Main Title - Massive & Impactful */}
|
||||||
{/* Main Title - Massive & Impactful */}
|
{/* Main Title - Massive & Impactful */}
|
||||||
<div className="relative z-20 mix-blend-hard-light md:mix-blend-normal">
|
<div className="relative z-20 mix-blend-hard-light md:mix-blend-normal">
|
||||||
<h1 className="text-6xl sm:text-6xl md:text-8xl lg:text-9xl font-black tracking-tighter leading-[0.9] md:leading-[0.8] mb-6 select-none bg-clip-text text-transparent bg-gradient-to-b from-white via-white to-zinc-600 drop-shadow-2xl">
|
<h1 className="text-5xl sm:text-6xl md:text-8xl lg:text-9xl font-black tracking-tighter leading-[0.9] md:leading-[0.8] mb-6 select-none bg-clip-text text-transparent bg-gradient-to-b from-white via-white to-zinc-600 drop-shadow-2xl">
|
||||||
AGENTIC<br />
|
AGENTIC<br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold via-white to-nofx-gold animate-shimmer bg-[length:200%_auto] tracking-tight filter drop-shadow-[0_0_15px_rgba(234,179,8,0.3)]">TRADING</span>
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold via-white to-nofx-gold animate-shimmer bg-[length:200%_auto] tracking-tight filter drop-shadow-[0_0_15px_rgba(234,179,8,0.3)]">TRADING</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -344,14 +344,14 @@ function CommunityStats() {
|
|||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
label: 'GITHUB STARS',
|
label: 'GITHUB STARS',
|
||||||
value: isLoading ? '...' : (error ? '9.5k+' : stars.toLocaleString()),
|
value: isLoading ? '...' : (error ? '9,700+' : stars.toLocaleString()),
|
||||||
icon: Star,
|
icon: Star,
|
||||||
color: 'text-yellow-400',
|
color: 'text-yellow-400',
|
||||||
href: OFFICIAL_LINKS.github
|
href: OFFICIAL_LINKS.github
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'FORKS',
|
label: 'FORKS',
|
||||||
value: isLoading ? '...' : (error ? '2.5k+' : forks.toLocaleString()),
|
value: isLoading ? '...' : (error ? '2,600+' : forks.toLocaleString()),
|
||||||
icon: GitFork,
|
icon: GitFork,
|
||||||
color: 'text-blue-400',
|
color: 'text-blue-400',
|
||||||
href: `${OFFICIAL_LINKS.github}/fork`
|
href: `${OFFICIAL_LINKS.github}/fork`
|
||||||
@@ -365,7 +365,7 @@ function CommunityStats() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'DEV COMMUNITY',
|
label: 'DEV COMMUNITY',
|
||||||
value: '5,800+', // Hardcoded as per user request
|
value: '6,000+', // Updated as per user request
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
color: 'text-blue-500',
|
color: 'text-blue-500',
|
||||||
href: OFFICIAL_LINKS.telegram
|
href: OFFICIAL_LINKS.telegram
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ interface AuthContextType {
|
|||||||
message?: string
|
message?: string
|
||||||
userID?: string
|
userID?: string
|
||||||
requiresOTP?: boolean
|
requiresOTP?: boolean
|
||||||
|
requiresOTPSetup?: boolean
|
||||||
|
qrCodeURL?: string
|
||||||
|
otpSecret?: string
|
||||||
|
email?: string
|
||||||
}>
|
}>
|
||||||
loginAdmin: (password: string) => Promise<{
|
loginAdmin: (password: string) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -119,22 +123,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
// Check for OTP setup required (incomplete registration)
|
||||||
|
if (data.requires_otp_setup) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userID: data.user_id,
|
||||||
|
requiresOTPSetup: true,
|
||||||
|
message: data.message,
|
||||||
|
qrCodeURL: data.qr_code_url,
|
||||||
|
otpSecret: data.otp_secret,
|
||||||
|
email: data.email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for OTP verification required (normal login flow)
|
||||||
if (data.requires_otp) {
|
if (data.requires_otp) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
userID: data.user_id,
|
userID: data.user_id,
|
||||||
requiresOTP: true,
|
requiresOTP: true,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
|
qrCodeURL: data.qr_code_url,
|
||||||
|
otpSecret: data.otp_secret
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Unexpected success response
|
||||||
|
return { success: false, message: '登录响应异常' }
|
||||||
} else {
|
} else {
|
||||||
return { success: false, message: data.error }
|
return {
|
||||||
|
success: false,
|
||||||
|
message: data.error,
|
||||||
|
qrCodeURL: data.qr_code_url,
|
||||||
|
otpSecret: data.otp_secret,
|
||||||
|
userID: data.user_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: '登录失败,请重试' }
|
return { success: false, message: '登录失败,请重试' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, message: '未知错误' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginAdmin = async (password: string) => {
|
const loginAdmin = async (password: string) => {
|
||||||
|
|||||||
@@ -471,7 +471,13 @@ textarea::placeholder {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 16px;
|
||||||
|
|
||||||
|
/* Prevent iOS zoom */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -788,8 +794,20 @@ tr:hover {
|
|||||||
color: var(--binance-red);
|
color: var(--binance-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-neutral {
|
/* Scrollbar Hiding for sleek horizontal scrolls */
|
||||||
color: var(--text-secondary);
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Linear Fade Mask for Scrollable Areas */
|
||||||
|
.mask-linear-fade {
|
||||||
|
mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
|
||||||
|
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Divider */
|
/* Divider */
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ export function TraderDashboardPage({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="w-px h-3 bg-white/10" />
|
<span className="w-px h-3 bg-white/10 hidden md:block" />
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="opacity-60">Exchange:</span>
|
<span className="opacity-60">Exchange:</span>
|
||||||
<span className="text-nofx-text-main font-semibold">
|
<span className="text-nofx-text-main font-semibold">
|
||||||
@@ -468,7 +468,7 @@ export function TraderDashboardPage({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="w-px h-3 bg-white/10" />
|
<span className="w-px h-3 bg-white/10 hidden md:block" />
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="opacity-60">Strategy:</span>
|
<span className="opacity-60">Strategy:</span>
|
||||||
<span className="text-nofx-gold font-semibold tracking-wide">
|
<span className="text-nofx-gold font-semibold tracking-wide">
|
||||||
@@ -476,12 +476,12 @@ export function TraderDashboardPage({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{status && (
|
{status && (
|
||||||
<>
|
<div className="hidden md:contents">
|
||||||
<span className="w-px h-3 bg-white/10" />
|
<span className="w-px h-3 bg-white/10" />
|
||||||
<span>Cycles: <span className="text-nofx-text-main">{status.call_count}</span></span>
|
<span>Cycles: <span className="text-nofx-text-main">{status.call_count}</span></span>
|
||||||
<span className="w-px h-3 bg-white/10" />
|
<span className="w-px h-3 bg-white/10" />
|
||||||
<span>Runtime: <span className="text-nofx-text-main">{status.runtime_minutes} min</span></span>
|
<span>Runtime: <span className="text-nofx-text-main">{status.runtime_minutes} min</span></span>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +499,7 @@ export function TraderDashboardPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Account Overview */}
|
{/* Account Overview */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('totalEquity', language)}
|
title={t('totalEquity', language)}
|
||||||
value={`${account?.total_equity?.toFixed(2) || '0.00'}`}
|
value={`${account?.total_equity?.toFixed(2) || '0.00'}`}
|
||||||
@@ -580,13 +580,13 @@ export function TraderDashboardPage({
|
|||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-left">{t('symbol', language)}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-left">{t('symbol', language)}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{t('side', language)}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{t('side', language)}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{language === 'zh' ? '操作' : 'Action'}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{language === 'zh' ? '操作' : 'Action'}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('entryPrice', language)}>{language === 'zh' ? '入场价' : 'Entry'}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('entryPrice', language)}>{language === 'zh' ? '入场价' : 'Entry'}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('markPrice', language)}>{language === 'zh' ? '标记价' : 'Mark'}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('markPrice', language)}>{language === 'zh' ? '标记价' : 'Mark'}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('quantity', language)}>{language === 'zh' ? '数量' : 'Qty'}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('quantity', language)}>{language === 'zh' ? '数量' : 'Qty'}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('positionValue', language)}>{language === 'zh' ? '价值' : 'Value'}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('positionValue', language)}>{language === 'zh' ? '价值' : 'Value'}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center" title={t('leverage', language)}>{language === 'zh' ? '杠杆' : 'Lev.'}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center hidden md:table-cell" title={t('leverage', language)}>{language === 'zh' ? '杠杆' : 'Lev.'}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('unrealizedPnL', language)}>{language === 'zh' ? '未实现盈亏' : 'uPnL'}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('unrealizedPnL', language)}>{language === 'zh' ? '未实现盈亏' : 'uPnL'}</th>
|
||||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('liqPrice', language)}>{language === 'zh' ? '强平价' : 'Liq.'}</th>
|
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('liqPrice', language)}>{language === 'zh' ? '强平价' : 'Liq.'}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -634,11 +634,11 @@ export function TraderDashboardPage({
|
|||||||
{language === 'zh' ? '平仓' : 'Close'}
|
{language === 'zh' ? '平仓' : 'Close'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.entry_price.toFixed(4)}</td>
|
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{pos.entry_price.toFixed(4)}</td>
|
||||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.mark_price.toFixed(4)}</td>
|
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{pos.mark_price.toFixed(4)}</td>
|
||||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.quantity.toFixed(4)}</td>
|
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.quantity.toFixed(4)}</td>
|
||||||
<td className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right text-nofx-text-main">{(pos.quantity * pos.mark_price).toFixed(2)}</td>
|
<td className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{(pos.quantity * pos.mark_price).toFixed(2)}</td>
|
||||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-center text-nofx-gold">{pos.leverage}x</td>
|
<td className="px-1 py-3 font-mono whitespace-nowrap text-center text-nofx-gold hidden md:table-cell">{pos.leverage}x</td>
|
||||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
|
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
|
||||||
<span
|
<span
|
||||||
className={`font-bold ${pos.unrealized_pnl >= 0 ? 'text-nofx-green shadow-nofx-green' : 'text-nofx-red shadow-nofx-red'}`}
|
className={`font-bold ${pos.unrealized_pnl >= 0 ? 'text-nofx-green shadow-nofx-green' : 'text-nofx-red shadow-nofx-red'}`}
|
||||||
@@ -648,7 +648,7 @@ export function TraderDashboardPage({
|
|||||||
{pos.unrealized_pnl.toFixed(2)}
|
{pos.unrealized_pnl.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted">{pos.liquidation_price.toFixed(4)}</td>
|
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted hidden md:table-cell">{pos.liquidation_price.toFixed(4)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user