mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
release: merge dev into main (2026-04-17) (#1484)
* feat(store): prevent deletion of active strategies and update translations (#1461) Co-authored-by: Dean <afei.wuhao@gmail.com> * fix: allow model switching without re-entering wallet key Users with existing wallets could not switch AI models because the "Start Trading" button required a valid private key even when one was already configured. Now the button is enabled when hasExistingWallet is true, and handleSubmit passes an empty key so the backend preserves the existing key. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: replace window.location with useNavigate for routing in auth components (#1470) Co-authored-by: Dean <afei.wuhao@gmail.com> * feat(trader): implement margin mode handling for order and leverage settings * refactor(trader): update SetMarginMode to avoid legacy endpoint and improve logging * feat(api): enhance strategy handling by integrating claw402 wallet key validation Added validation for the claw402 model's wallet key during strategy test runs. If the selected AI model is claw402, the server now checks for a valid wallet key and returns appropriate error messages if it's missing or if the model fails to load. This ensures better error handling and user feedback when working with AI models. * refactor(api): streamline claw402 wallet key retrieval and error handling Refactored the strategy handling logic to encapsulate claw402 wallet key retrieval in a new method, `resolveStrategyDataWalletKey`. This improves code readability and maintains consistent error handling for missing or invalid wallet keys during strategy test runs. The changes enhance the overall robustness of the AI model integration. * feat(trader): add claw402 wallet key resolution for trader configuration Implemented a new method, `resolveTraderDataWalletKey`, to retrieve the claw402 wallet key based on the selected AI model and user ID. This enhancement allows for better integration of the claw402 model within the trader configuration, ensuring that the correct wallet key is used for trading operations. The `AutoTraderConfig` struct has been updated to include the new `Claw402WalletKey` field, improving the overall handling of wallet keys in the trading process. * feat(claw402): preflight USDC balance before AI calls (#1479) * chore: ignore nofx-server build artifact Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(claw402): preflight USDC balance before AI calls Short-circuit claw402 Call/CallWithRequestFull when the wallet balance can't cover the estimated cost of the call, surfacing ErrInsufficientFunds instead of letting x402 fail mid-flight after the sign step. - wallet: cached balance lookup (30s TTL, per-address mutex) to avoid hammering the Base RPC; separate error-returning and display-only APIs so callers can distinguish zero balance from an unreachable RPC. - claw402: 1.5× safety multiplier on the flat per-call estimate, 4.0× for reasoner models whose chain-of-thought cost can blow past the flat rate. Fail-open on RPC errors — x402 still gates actually-empty wallets, and we prefer availability over extra strictness. - shortAddr redacts the wallet in error strings to avoid leaking the full address into telemetry bundles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(telemetry): report token usage for SSE streaming paths (#1475) * fix(telemetry): report token usage for SSE streaming paths ParseSSEStream already parsed the usage block from SSE chunks but only printed it, so claw402 streaming calls (and native streaming) never fired TokenUsageCallback. GA4 therefore undercounted AI usage on the streaming path. Return the parsed usage from ParseSSEStream and have both callers fire the callback with their own Provider/Model. * chore: drop leftover debug Printf in ParseSSEStream Telemetry is now wired via TokenUsageCallback, so the Printf is redundant noise in the stream path. * fix(gemini): update default model to gemini-3.1-pro Google discontinued gemini-3-pro-preview on 2026-03-26 and directs all callers to gemini-3.1-pro / gemini-3.1-pro-preview. Users on their own API key were getting errors from the native Gemini endpoint because the provider default pointed at the retired ID. Claw402 was unaffected because its route map already used gemini-3.1-pro. Align both the native provider default and the handler's preset list with gemini-3.1-pro so every code path sends a live model ID. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract ResolveClaw402WalletKey to store layer and expand OKX margin mode tests - Move duplicated claw402 wallet resolution logic into store.AIModelStore.ResolveClaw402WalletKey - api/strategy.go and manager/trader_manager.go now delegate to the shared method - Add detailed doc comment on OKX SetMarginMode explaining the local-state-only approach and why the legacy /api/v5/account/set-isolated-mode endpoint is not called - Add 3 new test cases: cross mode leverage, OpenShort tdMode, SetTakeProfit tdMode * fix(auth): prevent SetupPage remount from wiping freshly-set auth token (#1481) After #1470 moved routing into react-router, SetupPage is rendered at two different tree positions (top-level guard + /setup Route). When register success flushSync-sets `user`, the top-level guard stops matching and the Route-level SetupPage mounts as a new instance, re-running its cleanup useEffect and removing the auth_token that handlePostAuthSuccess just wrote. Subsequent requests 401 and bounce the user back to /login. Redirect /setup to /welcome when user is already set so SetupPage is never re-mounted during the auth transition. * fix(wallet): handle JSON-RPC null error field in balance query Some RPC implementations return explicit "error": null on success. json.RawMessage deserializes this as the 4-byte literal "null", so len() > 0 was true, causing every balance query to fail with "rpc error: null". Skip the null literal to avoid false positives. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: deanokk <wuhao@vergex.trade> Co-authored-by: Dean <afei.wuhao@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: root <root@localhost.localdomain>
This commit is contained in:
@@ -126,3 +126,6 @@ dmypy.json
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
PR_DESCRIPTION.md
|
||||
|
||||
# Go build artifacts
|
||||
/nofx-server
|
||||
|
||||
@@ -214,7 +214,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
|
||||
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
|
||||
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
|
||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3.1-pro"},
|
||||
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
||||
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
|
||||
|
||||
+14
-1
@@ -516,8 +516,17 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
req.PromptVariant = "balanced"
|
||||
}
|
||||
|
||||
claw402WalletKey, err := s.resolveStrategyDataWalletKey(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
"ai_response": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create strategy engine to build prompt
|
||||
engine := kernel.NewStrategyEngine(&req.Config)
|
||||
engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey)
|
||||
|
||||
// Get candidate coins
|
||||
candidates, err := engine.GetCandidateCoins()
|
||||
@@ -697,3 +706,7 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) {
|
||||
return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID)
|
||||
}
|
||||
|
||||
@@ -703,6 +703,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.CustomAPIKey = string(aiModelCfg.APIKey)
|
||||
}
|
||||
|
||||
traderConfig.Claw402WalletKey = resolveTraderDataWalletKey(st, traderCfg.UserID, aiModelCfg)
|
||||
|
||||
// Create trader instance
|
||||
at, err := trader.NewAutoTrader(traderConfig, st, traderCfg.UserID)
|
||||
if err != nil {
|
||||
@@ -741,3 +743,26 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveTraderDataWalletKey(st *store.Store, userID string, selectedModel *store.AIModel) string {
|
||||
// Fast path: selected model is itself a claw402 model.
|
||||
if selectedModel != nil && selectedModel.Provider == "claw402" {
|
||||
if walletKey := string(selectedModel.APIKey); walletKey != "" {
|
||||
return walletKey
|
||||
}
|
||||
}
|
||||
|
||||
if st == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Fallback: find any configured claw402 model for this user so that paid
|
||||
// NofxAI data sources work even when a non-claw402 model (e.g. deepseek) is
|
||||
// selected as the AI brain.
|
||||
preferredID := ""
|
||||
walletKey, err := st.AIModel().ResolveClaw402WalletKey(userID, preferredID)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to load claw402 wallet for trader data routing: %v", err)
|
||||
return ""
|
||||
}
|
||||
return walletKey
|
||||
}
|
||||
|
||||
+28
-7
@@ -725,21 +725,24 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return ParseSSEStream(resp.Body, onChunk, func() {
|
||||
text, usage, err := ParseSSEStream(resp.Body, onChunk, func() {
|
||||
select {
|
||||
case resetCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
ReportStreamUsage(usage, client.Provider, client.Model)
|
||||
return text, err
|
||||
}
|
||||
|
||||
// ParseSSEStream reads an SSE response body, accumulates text deltas,
|
||||
// and calls onChunk with the full accumulated text after each chunk.
|
||||
// If onLine is non-nil, it is called after each raw SSE line is scanned
|
||||
// (useful for resetting idle-timeout watchdogs).
|
||||
// Returns the complete accumulated text.
|
||||
func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string, error) {
|
||||
// Returns the complete accumulated text and any parsed token usage (nil if absent).
|
||||
func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string, *TokenUsage, error) {
|
||||
var accumulated strings.Builder
|
||||
var usage *TokenUsage
|
||||
scanner := bufio.NewScanner(body)
|
||||
|
||||
for scanner.Scan() {
|
||||
@@ -774,8 +777,11 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
|
||||
}
|
||||
|
||||
if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 {
|
||||
fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n",
|
||||
chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens)
|
||||
usage = &TokenUsage{
|
||||
PromptTokens: chunk.Usage.PromptTokens,
|
||||
CompletionTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
|
||||
if len(chunk.Choices) == 0 {
|
||||
@@ -794,8 +800,23 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return accumulated.String(), fmt.Errorf("stream interrupted: %w", err)
|
||||
return accumulated.String(), usage, fmt.Errorf("stream interrupted: %w", err)
|
||||
}
|
||||
|
||||
return accumulated.String(), nil
|
||||
return accumulated.String(), usage, nil
|
||||
}
|
||||
|
||||
// ReportStreamUsage fires TokenUsageCallback with the given usage, provider, and model.
|
||||
// No-op if usage is nil or callback is unset.
|
||||
func ReportStreamUsage(usage *TokenUsage, provider, model string) {
|
||||
if usage == nil || TokenUsageCallback == nil || usage.TotalTokens <= 0 {
|
||||
return
|
||||
}
|
||||
TokenUsageCallback(TokenUsage{
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
TotalTokens: usage.TotalTokens,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package payment
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -9,8 +10,44 @@ import (
|
||||
|
||||
"nofx/mcp"
|
||||
"nofx/mcp/provider"
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
)
|
||||
|
||||
// Per-call cost buffers for preflight. Reasoner models emit long chain-of-thought
|
||||
// tokens whose cost can far exceed the flat per-call estimate in store.GetModelPrice,
|
||||
// so they use a larger multiplier.
|
||||
const (
|
||||
preflightSafetyMultiplier = 1.5
|
||||
preflightReasonerSafetyMultiplier = 4.0
|
||||
)
|
||||
|
||||
// ErrInsufficientFunds is returned when the claw402 wallet does not hold
|
||||
// enough USDC to cover the estimated cost of a call. Callers can type-assert
|
||||
// to surface balance/needed/address to the UI.
|
||||
type ErrInsufficientFunds struct {
|
||||
Address string
|
||||
Balance float64
|
||||
Needed float64
|
||||
Model string
|
||||
}
|
||||
|
||||
func (e *ErrInsufficientFunds) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"claw402 insufficient USDC: wallet=%s balance=$%.4f needed=$%.4f model=%s",
|
||||
shortAddr(e.Address), e.Balance, e.Needed, e.Model,
|
||||
)
|
||||
}
|
||||
|
||||
// shortAddr renders 0x1234…abcd for log/error strings that may leak into
|
||||
// telemetry bundles. The full address stays on the struct for programmatic use.
|
||||
func shortAddr(addr string) string {
|
||||
if len(addr) < 10 {
|
||||
return addr
|
||||
}
|
||||
return addr[:6] + "…" + addr[len(addr)-4:]
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultClaw402URL = "https://claw402.ai"
|
||||
DefaultClaw402Model = "glm-5"
|
||||
@@ -128,13 +165,57 @@ func (c *Claw402Client) resolveEndpoint() string {
|
||||
func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
|
||||
|
||||
func (c *Claw402Client) Call(systemPrompt, userPrompt string) (string, error) {
|
||||
if err := c.preflightBalance(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return X402CallStream(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt, nil)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
|
||||
if err := c.preflightBalance(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return X402CallFull(c.Client, c.signPayment, "Claw402", req)
|
||||
}
|
||||
|
||||
// walletAddress derives the EVM address from the configured private key.
|
||||
// Returns "" when no key has been set (client unconfigured).
|
||||
func (c *Claw402Client) walletAddress() string {
|
||||
if c.privateKey == nil {
|
||||
return ""
|
||||
}
|
||||
return crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
|
||||
}
|
||||
|
||||
// preflightBalance short-circuits a call when the wallet cannot cover the
|
||||
// estimated cost. RPC failures fall through — x402 will still reject an
|
||||
// actually-empty wallet, so we prefer availability over extra strictness.
|
||||
func (c *Claw402Client) preflightBalance() error {
|
||||
addr := c.walletAddress()
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
balance, err := wallet.QueryUSDCBalanceCached(addr)
|
||||
if err != nil {
|
||||
c.Log.Warnf("⚠️ [MCP] Claw402 balance preflight skipped (RPC error): %v", err)
|
||||
return nil
|
||||
}
|
||||
multiplier := preflightSafetyMultiplier
|
||||
if strings.Contains(strings.ToLower(c.Model), "reasoner") {
|
||||
multiplier = preflightReasonerSafetyMultiplier
|
||||
}
|
||||
needed := store.GetModelPrice(c.Model) * multiplier
|
||||
if balance < needed {
|
||||
return &ErrInsufficientFunds{
|
||||
Address: addr,
|
||||
Balance: balance,
|
||||
Needed: needed,
|
||||
Model: c.Model,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// signPayment signs x402 v2 EIP-712 payment on Base chain + USDC.
|
||||
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
|
||||
|
||||
+2
-1
@@ -452,7 +452,8 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
|
||||
var bodyBuf bytes.Buffer
|
||||
tee := io.TeeReader(resp.Body, &bodyBuf)
|
||||
|
||||
text, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
|
||||
text, usage, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
|
||||
mcp.ReportStreamUsage(usage, c.Provider, c.Model)
|
||||
|
||||
if text != "" {
|
||||
c.Log.Infof("📡 [%s] SSE stream complete, got %d chars", tag, len(text))
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
const (
|
||||
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
DefaultGeminiModel = "gemini-3-pro-preview"
|
||||
DefaultGeminiModel = "gemini-3.1-pro"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -253,6 +253,43 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
}
|
||||
|
||||
// Create creates an AI model
|
||||
// ResolveClaw402WalletKey returns the claw402 wallet private key for a user.
|
||||
// If preferredModelID is non-empty and points to a claw402 model, its key is returned first.
|
||||
// Otherwise the first enabled claw402 model in the user's model list is used.
|
||||
// Returns ("", nil) when no claw402 model is configured — callers should treat this as
|
||||
// "no paid data routing" rather than an error.
|
||||
func (s *AIModelStore) ResolveClaw402WalletKey(userID, preferredModelID string) (string, error) {
|
||||
if preferredModelID != "" {
|
||||
model, err := s.Get(userID, preferredModelID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load selected AI model")
|
||||
}
|
||||
if model.Provider == "claw402" {
|
||||
walletKey := string(model.APIKey)
|
||||
if walletKey == "" {
|
||||
return "", fmt.Errorf("selected claw402 model is missing wallet private key")
|
||||
}
|
||||
return walletKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
models, err := s.List(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load AI models")
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model == nil || model.Provider != "claw402" {
|
||||
continue
|
||||
}
|
||||
if walletKey := string(model.APIKey); walletKey != "" {
|
||||
return walletKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error {
|
||||
model := &AIModel{
|
||||
ID: id,
|
||||
|
||||
+11
-10
@@ -2,14 +2,13 @@ package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
_ "nofx/mcp/payment"
|
||||
_ "nofx/mcp/provider"
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"nofx/trader/aster"
|
||||
"nofx/trader/binance"
|
||||
"nofx/trader/bitget"
|
||||
@@ -20,6 +19,7 @@ import (
|
||||
"nofx/trader/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
"nofx/wallet"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -90,9 +90,10 @@ type AutoTraderConfig struct {
|
||||
QwenKey string
|
||||
|
||||
// Custom AI API configuration
|
||||
CustomAPIURL string
|
||||
CustomAPIKey string
|
||||
CustomModelName string
|
||||
CustomAPIURL string
|
||||
CustomAPIKey string
|
||||
CustomModelName string
|
||||
Claw402WalletKey string
|
||||
|
||||
// Scan configuration
|
||||
ScanInterval time.Duration // Scan interval (recommended 3 minutes)
|
||||
@@ -148,9 +149,9 @@ type AutoTrader struct {
|
||||
userID string // User ID
|
||||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||||
claw402WalletAddr string // Claw402 wallet address (derived from private key at start)
|
||||
consecutiveAIFailures int // Consecutive AI call failures
|
||||
safeMode bool // Safe mode: no new positions, protect existing ones
|
||||
safeModeReason string // Why safe mode was activated
|
||||
consecutiveAIFailures int // Consecutive AI call failures
|
||||
safeMode bool // Safe mode: no new positions, protect existing ones
|
||||
safeModeReason string // Why safe mode was activated
|
||||
}
|
||||
|
||||
// NewAutoTrader creates an automatic trader
|
||||
@@ -335,8 +336,8 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
}
|
||||
// Pass claw402 wallet key to strategy engine so nofxos data requests
|
||||
// are routed through claw402 (reuses the same wallet as AI calls)
|
||||
var claw402Key string
|
||||
if config.AIModel == "claw402" && config.CustomAPIKey != "" {
|
||||
claw402Key := config.Claw402WalletKey
|
||||
if claw402Key == "" && config.AIModel == "claw402" && config.CustomAPIKey != "" {
|
||||
claw402Key = config.CustomAPIKey
|
||||
}
|
||||
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key)
|
||||
|
||||
@@ -324,6 +324,17 @@ func (at *AutoTrader) InitializeGrid() error {
|
||||
|
||||
at.gridState.IsInitialized = true
|
||||
|
||||
// Keep grid orders aligned with the trader's configured cross/isolated mode.
|
||||
if err := at.trader.SetMarginMode(gridConfig.Symbol, at.config.IsCrossMargin); err != nil {
|
||||
logger.Warnf("[Grid] Failed to set margin mode for %s: %v", gridConfig.Symbol, err)
|
||||
} else {
|
||||
marginMode := "cross"
|
||||
if !at.config.IsCrossMargin {
|
||||
marginMode = "isolated"
|
||||
}
|
||||
logger.Infof("[Grid] Margin mode set to %s for %s", marginMode, gridConfig.Symbol)
|
||||
}
|
||||
|
||||
// CRITICAL: Set leverage on exchange before trading
|
||||
if err := at.trader.SetLeverage(gridConfig.Symbol, gridConfig.Leverage); err != nil {
|
||||
logger.Warnf("[Grid] Failed to set leverage %dx on exchange: %v", gridConfig.Leverage, err)
|
||||
|
||||
+11
-2
@@ -41,7 +41,7 @@ type OKXTrader struct {
|
||||
secretKey string
|
||||
passphrase string
|
||||
|
||||
// Margin mode setting
|
||||
// Margin mode setting used for new orders and leverage changes.
|
||||
isCrossMargin bool
|
||||
|
||||
// Position mode: "long_short_mode" (hedge) or "net_mode" (one-way)
|
||||
@@ -121,6 +121,7 @@ func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
|
||||
apiKey: apiKey,
|
||||
secretKey: secretKey,
|
||||
passphrase: passphrase,
|
||||
isCrossMargin: true,
|
||||
httpClient: httpClient,
|
||||
cacheDuration: 15 * time.Second,
|
||||
instrumentsCache: make(map[string]*OKXInstrument),
|
||||
@@ -139,10 +140,18 @@ func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ OKX trader initialized with position mode: %s", trader.positionMode)
|
||||
logger.Infof("✓ OKX trader initialized with position mode: %s, default margin mode: %s",
|
||||
trader.positionMode, trader.marginMode())
|
||||
return trader
|
||||
}
|
||||
|
||||
func (t *OKXTrader) marginMode() string {
|
||||
if t.isCrossMargin {
|
||||
return "cross"
|
||||
}
|
||||
return "isolated"
|
||||
}
|
||||
|
||||
// detectPositionMode gets current position mode from account config
|
||||
func (t *OKXTrader) detectPositionMode() error {
|
||||
data, err := t.doRequest("GET", okxAccountConfigPath, nil)
|
||||
|
||||
@@ -80,49 +80,42 @@ func (t *OKXTrader) GetBalance() (map[string]interface{}, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetMarginMode sets margin mode
|
||||
// SetMarginMode configures the margin mode (cross/isolated) that will be applied
|
||||
// to all subsequent leverage and order requests for this trader instance.
|
||||
//
|
||||
// OKX V5 unified accounts do not expose a per-symbol mode-switch endpoint that
|
||||
// works reliably — the legacy /api/v5/account/set-isolated-mode endpoint returns
|
||||
// error 51000 ("Parameter isoMode error") when called on a unified account.
|
||||
// Instead, OKX applies the mode per-request via the mgnMode field on
|
||||
// /api/v5/account/set-leverage and via the tdMode field on order placement.
|
||||
//
|
||||
// This implementation therefore stores the configured mode locally and injects it
|
||||
// into each subsequent API request, rather than making an API call here.
|
||||
// NOTE: unlike Binance/Bybit implementations of this interface, no network call
|
||||
// is made — the method only updates local state.
|
||||
func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
instId := t.convertSymbol(symbol)
|
||||
t.isCrossMargin = isCrossMargin
|
||||
mgnMode := t.marginMode()
|
||||
|
||||
mgnMode := "isolated"
|
||||
if isCrossMargin {
|
||||
mgnMode = "cross"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"mgnMode": mgnMode,
|
||||
}
|
||||
|
||||
_, err := t.doRequest("POST", "/api/v5/account/set-isolated-mode", body)
|
||||
if err != nil {
|
||||
// Ignore error if already in target mode
|
||||
if strings.Contains(err.Error(), "already") {
|
||||
logger.Infof(" ✓ %s margin mode is already %s", symbol, mgnMode)
|
||||
return nil
|
||||
}
|
||||
// Cannot change when there are positions
|
||||
if strings.Contains(err.Error(), "position") {
|
||||
logger.Infof(" ⚠️ %s has positions, cannot change margin mode", symbol)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof(" ✓ %s margin mode set to %s", symbol, mgnMode)
|
||||
// OKX V5 unified account applies cross/isolated per order via tdMode,
|
||||
// while leverage uses mgnMode on /account/set-leverage.
|
||||
// Persist the configured mode locally so subsequent leverage/order calls use it,
|
||||
// instead of calling the legacy isolated-mode endpoint that returns 51000 errors.
|
||||
logger.Infof(" ✓ %s margin mode configured as %s (applied via tdMode/mgnMode on subsequent requests)", symbol, mgnMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLeverage sets leverage
|
||||
func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
|
||||
instId := t.convertSymbol(symbol)
|
||||
marginMode := t.marginMode()
|
||||
|
||||
// Set leverage for both long and short
|
||||
for _, posSide := range []string{"long", "short"} {
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"lever": strconv.Itoa(leverage),
|
||||
"mgnMode": "cross",
|
||||
"mgnMode": marginMode,
|
||||
"posSide": posSide,
|
||||
}
|
||||
|
||||
@@ -136,7 +129,7 @@ func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(" ✓ %s leverage set to %dx", symbol, leverage)
|
||||
logger.Infof(" ✓ %s leverage set to %dx (%s)", symbol, leverage, marginMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package okx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Body map[string]interface{}
|
||||
}
|
||||
|
||||
type recordingTransport struct {
|
||||
requests []capturedRequest
|
||||
}
|
||||
|
||||
func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var body map[string]interface{}
|
||||
if req.Body != nil {
|
||||
data, _ := io.ReadAll(req.Body)
|
||||
if len(data) > 0 && strings.HasPrefix(strings.TrimSpace(string(data)), "{") {
|
||||
_ = json.Unmarshal(data, &body)
|
||||
}
|
||||
}
|
||||
|
||||
rt.requests = append(rt.requests, capturedRequest{
|
||||
Method: req.Method,
|
||||
Path: req.URL.Path,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
response := `{"code":"0","msg":"","data":[]}`
|
||||
switch req.URL.Path {
|
||||
case okxInstrumentsPath:
|
||||
response = `{"code":"0","msg":"","data":[{"instId":"BTC-USDT-SWAP","ctVal":"0.01","ctMult":"1","lotSz":"1","minSz":"1","maxMktSz":"100000","tickSz":"0.1","ctType":"linear"}]}`
|
||||
case okxOrderPath:
|
||||
response = `{"code":"0","msg":"","data":[{"ordId":"123","clOrdId":"abc","sCode":"0","sMsg":""}]}`
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewBufferString(response)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rt *recordingTransport) requestsForPath(path string) []capturedRequest {
|
||||
var matches []capturedRequest
|
||||
for _, req := range rt.requests {
|
||||
if req.Path == path {
|
||||
matches = append(matches, req)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func newTestOKXTrader(rt *recordingTransport, isCrossMargin bool) *OKXTrader {
|
||||
return &OKXTrader{
|
||||
apiKey: "key",
|
||||
secretKey: "secret",
|
||||
passphrase: "pass",
|
||||
isCrossMargin: isCrossMargin,
|
||||
positionMode: "long_short_mode",
|
||||
httpClient: &http.Client{
|
||||
Transport: rt,
|
||||
},
|
||||
cacheDuration: 15 * time.Second,
|
||||
instrumentsCache: make(map[string]*OKXInstrument),
|
||||
instrumentsCacheTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKXSetLeverageUsesConfiguredMarginMode(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
trader := newTestOKXTrader(rt, false)
|
||||
|
||||
if err := trader.SetLeverage("BTCUSDT", 5); err != nil {
|
||||
t.Fatalf("SetLeverage failed: %v", err)
|
||||
}
|
||||
|
||||
leverageRequests := rt.requestsForPath(okxLeveragePath)
|
||||
if len(leverageRequests) != 2 {
|
||||
t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests))
|
||||
}
|
||||
|
||||
for _, req := range leverageRequests {
|
||||
if req.Body["mgnMode"] != "isolated" {
|
||||
t.Fatalf("expected isolated leverage mode, got %#v", req.Body["mgnMode"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKXSetMarginModeUpdatesFutureRequestsWithoutAPIError(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
trader := newTestOKXTrader(rt, true)
|
||||
|
||||
if err := trader.SetMarginMode("BTCUSDT", false); err != nil {
|
||||
t.Fatalf("SetMarginMode failed: %v", err)
|
||||
}
|
||||
|
||||
if len(rt.requestsForPath("/api/v5/account/set-isolated-mode")) != 0 {
|
||||
t.Fatal("expected SetMarginMode not to call legacy isolated-mode endpoint")
|
||||
}
|
||||
|
||||
if err := trader.SetLeverage("BTCUSDT", 5); err != nil {
|
||||
t.Fatalf("SetLeverage failed: %v", err)
|
||||
}
|
||||
|
||||
leverageRequests := rt.requestsForPath(okxLeveragePath)
|
||||
if len(leverageRequests) != 2 {
|
||||
t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests))
|
||||
}
|
||||
|
||||
for _, req := range leverageRequests {
|
||||
if req.Body["mgnMode"] != "isolated" {
|
||||
t.Fatalf("expected isolated leverage mode after SetMarginMode(false), got %#v", req.Body["mgnMode"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKXOpenLongUsesConfiguredMarginMode(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
trader := newTestOKXTrader(rt, false)
|
||||
|
||||
if _, err := trader.OpenLong("BTCUSDT", 0.1, 5); err != nil {
|
||||
t.Fatalf("OpenLong failed: %v", err)
|
||||
}
|
||||
|
||||
orderRequests := rt.requestsForPath(okxOrderPath)
|
||||
if len(orderRequests) == 0 {
|
||||
t.Fatal("expected at least one order request")
|
||||
}
|
||||
|
||||
lastOrder := orderRequests[len(orderRequests)-1]
|
||||
if lastOrder.Body["tdMode"] != "isolated" {
|
||||
t.Fatalf("expected isolated tdMode, got %#v", lastOrder.Body["tdMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKXSetStopLossUsesConfiguredMarginMode(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
trader := newTestOKXTrader(rt, false)
|
||||
|
||||
if err := trader.SetStopLoss("BTCUSDT", "LONG", 0.1, 90000); err != nil {
|
||||
t.Fatalf("SetStopLoss failed: %v", err)
|
||||
}
|
||||
|
||||
algoRequests := rt.requestsForPath(okxAlgoOrderPath)
|
||||
if len(algoRequests) != 1 {
|
||||
t.Fatalf("expected 1 algo order request, got %d", len(algoRequests))
|
||||
}
|
||||
|
||||
if algoRequests[0].Body["tdMode"] != "isolated" {
|
||||
t.Fatalf("expected isolated tdMode, got %#v", algoRequests[0].Body["tdMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKXPlaceLimitOrderUsesConfiguredMarginMode(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
trader := newTestOKXTrader(rt, false)
|
||||
|
||||
_, err := trader.PlaceLimitOrder(&types.LimitOrderRequest{
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "BUY",
|
||||
PositionSide: "LONG",
|
||||
Price: 95000,
|
||||
Quantity: 0.1,
|
||||
Leverage: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlaceLimitOrder failed: %v", err)
|
||||
}
|
||||
|
||||
orderRequests := rt.requestsForPath(okxOrderPath)
|
||||
if len(orderRequests) != 1 {
|
||||
t.Fatalf("expected 1 limit order request, got %d", len(orderRequests))
|
||||
}
|
||||
|
||||
if orderRequests[0].Body["tdMode"] != "isolated" {
|
||||
t.Fatalf("expected isolated tdMode, got %#v", orderRequests[0].Body["tdMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKXCrossMarginModeUsedInLeverage(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
trader := newTestOKXTrader(rt, true) // cross margin
|
||||
|
||||
if err := trader.SetLeverage("BTCUSDT", 10); err != nil {
|
||||
t.Fatalf("SetLeverage failed: %v", err)
|
||||
}
|
||||
|
||||
leverageRequests := rt.requestsForPath(okxLeveragePath)
|
||||
if len(leverageRequests) != 2 {
|
||||
t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests))
|
||||
}
|
||||
|
||||
for _, req := range leverageRequests {
|
||||
if req.Body["mgnMode"] != "cross" {
|
||||
t.Fatalf("expected cross leverage mode, got %#v", req.Body["mgnMode"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKXOpenShortUsesConfiguredMarginMode(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
trader := newTestOKXTrader(rt, false) // isolated
|
||||
|
||||
if _, err := trader.OpenShort("BTCUSDT", 0.1, 5); err != nil {
|
||||
t.Fatalf("OpenShort failed: %v", err)
|
||||
}
|
||||
|
||||
orderRequests := rt.requestsForPath(okxOrderPath)
|
||||
if len(orderRequests) == 0 {
|
||||
t.Fatal("expected at least one order request")
|
||||
}
|
||||
|
||||
lastOrder := orderRequests[len(orderRequests)-1]
|
||||
if lastOrder.Body["tdMode"] != "isolated" {
|
||||
t.Fatalf("expected isolated tdMode for OpenShort, got %#v", lastOrder.Body["tdMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKXSetTakeProfitUsesConfiguredMarginMode(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
trader := newTestOKXTrader(rt, false) // isolated
|
||||
|
||||
if err := trader.SetTakeProfit("BTCUSDT", "LONG", 0.1, 100000); err != nil {
|
||||
t.Fatalf("SetTakeProfit failed: %v", err)
|
||||
}
|
||||
|
||||
algoRequests := rt.requestsForPath(okxAlgoOrderPath)
|
||||
if len(algoRequests) != 1 {
|
||||
t.Fatalf("expected 1 algo order request, got %d", len(algoRequests))
|
||||
}
|
||||
|
||||
if algoRequests[0].Body["tdMode"] != "isolated" {
|
||||
t.Fatalf("expected isolated tdMode for SetTakeProfit, got %#v", algoRequests[0].Body["tdMode"])
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,11 @@ func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map
|
||||
szStr = t.formatSize(sz, inst)
|
||||
}
|
||||
|
||||
marginMode := t.marginMode()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"tdMode": "cross",
|
||||
"tdMode": marginMode,
|
||||
"side": "buy",
|
||||
"posSide": "long",
|
||||
"ordType": "market",
|
||||
@@ -118,9 +120,11 @@ func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (ma
|
||||
szStr = t.formatSize(sz, inst)
|
||||
}
|
||||
|
||||
marginMode := t.marginMode()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"tdMode": "cross",
|
||||
"tdMode": marginMode,
|
||||
"side": "sell",
|
||||
"posSide": "short",
|
||||
"ordType": "market",
|
||||
@@ -410,9 +414,11 @@ func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, st
|
||||
posSide = "short"
|
||||
}
|
||||
|
||||
marginMode := t.marginMode()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"tdMode": "cross",
|
||||
"tdMode": marginMode,
|
||||
"side": side,
|
||||
"posSide": posSide,
|
||||
"ordType": "conditional",
|
||||
@@ -453,9 +459,11 @@ func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity,
|
||||
posSide = "short"
|
||||
}
|
||||
|
||||
marginMode := t.marginMode()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"tdMode": "cross",
|
||||
"tdMode": marginMode,
|
||||
"side": side,
|
||||
"posSide": posSide,
|
||||
"ordType": "conditional",
|
||||
@@ -815,9 +823,11 @@ func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitO
|
||||
posSide = "short"
|
||||
}
|
||||
|
||||
marginMode := t.marginMode()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"tdMode": "cross",
|
||||
"tdMode": marginMode,
|
||||
"side": side,
|
||||
"posSide": posSide,
|
||||
"ordType": "limit",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// balanceCacheTTL is how long a balance reading is trusted before re-querying.
|
||||
const balanceCacheTTL = 30 * time.Second
|
||||
|
||||
type balanceEntry struct {
|
||||
value float64
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
balanceCache sync.Map
|
||||
balanceFetchMu sync.Map
|
||||
)
|
||||
|
||||
// QueryUSDCBalanceCached returns the USDC balance for an address, using a
|
||||
// short-lived cache to avoid hammering the Base RPC. Addresses are
|
||||
// case-insensitive.
|
||||
func QueryUSDCBalanceCached(address string) (float64, error) {
|
||||
key := strings.ToLower(strings.TrimSpace(address))
|
||||
if key == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if v, ok := balanceCache.Load(key); ok {
|
||||
e := v.(balanceEntry)
|
||||
if time.Since(e.fetchedAt) < balanceCacheTTL {
|
||||
return e.value, nil
|
||||
}
|
||||
}
|
||||
|
||||
muAny, _ := balanceFetchMu.LoadOrStore(key, &sync.Mutex{})
|
||||
mu := muAny.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if v, ok := balanceCache.Load(key); ok {
|
||||
e := v.(balanceEntry)
|
||||
if time.Since(e.fetchedAt) < balanceCacheTTL {
|
||||
return e.value, nil
|
||||
}
|
||||
}
|
||||
|
||||
balance, err := QueryUSDCBalance(address)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
balanceCache.Store(key, balanceEntry{value: balance, fetchedAt: time.Now()})
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// InvalidateBalanceCache drops the cached balance for an address, forcing the
|
||||
// next query to hit the chain. Use after a known-spending action or when the
|
||||
// caller suspects the cache is stale.
|
||||
func InvalidateBalanceCache(address string) {
|
||||
key := strings.ToLower(strings.TrimSpace(address))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
balanceCache.Delete(key)
|
||||
}
|
||||
+40
-26
@@ -18,21 +18,26 @@ const (
|
||||
USDCDecimals = 6
|
||||
)
|
||||
|
||||
// QueryUSDCBalance queries USDC balance on Base chain and returns as float64
|
||||
// QueryUSDCBalance queries USDC balance on Base chain. RPC / decode failures
|
||||
// are surfaced as errors so callers can distinguish a real zero balance from
|
||||
// an unreachable RPC.
|
||||
func QueryUSDCBalance(address string) (float64, error) {
|
||||
balanceStr := QueryUSDCBalanceStr(address)
|
||||
var balance float64
|
||||
_, err := fmt.Sscanf(balanceStr, "%f", &balance)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse balance: %w", err)
|
||||
}
|
||||
return balance, nil
|
||||
return queryUSDCBalanceRPC(address)
|
||||
}
|
||||
|
||||
// QueryUSDCBalanceStr queries USDC balance on Base chain and returns as formatted string
|
||||
// QueryUSDCBalanceStr is the display-oriented counterpart to QueryUSDCBalance:
|
||||
// it swallows errors and returns "0.00" so UI handlers always have a string to
|
||||
// render. Use QueryUSDCBalance when you need to react to failure.
|
||||
func QueryUSDCBalanceStr(address string) string {
|
||||
// Build balanceOf(address) call data
|
||||
// Function selector: 0x70a08231
|
||||
balance, err := queryUSDCBalanceRPC(address)
|
||||
if err != nil {
|
||||
return "0.00"
|
||||
}
|
||||
return fmt.Sprintf("%.6f", balance)
|
||||
}
|
||||
|
||||
func queryUSDCBalanceRPC(address string) (float64, error) {
|
||||
// Build balanceOf(address) call data — function selector 0x70a08231.
|
||||
addrNoPre := strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||
data := "0x70a08231" + fmt.Sprintf("%064s", addrNoPre)
|
||||
|
||||
@@ -51,41 +56,50 @@ func QueryUSDCBalanceStr(address string) string {
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "0.00"
|
||||
return 0, fmt.Errorf("marshal rpc payload: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Post(BaseRPCURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "0.00"
|
||||
return 0, fmt.Errorf("rpc post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "0.00"
|
||||
return 0, fmt.Errorf("read rpc response: %w", err)
|
||||
}
|
||||
|
||||
var rpcResp struct {
|
||||
Result string `json:"result"`
|
||||
Result string `json:"result"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
|
||||
return "0.00"
|
||||
return 0, fmt.Errorf("decode rpc response: %w", err)
|
||||
}
|
||||
if len(rpcResp.Error) > 0 && string(rpcResp.Error) != "null" {
|
||||
return 0, fmt.Errorf("rpc error: %s", string(rpcResp.Error))
|
||||
}
|
||||
|
||||
// Parse hex result
|
||||
hexStr := strings.TrimPrefix(rpcResp.Result, "0x")
|
||||
if hexStr == "" || hexStr == "0" {
|
||||
return "0.00"
|
||||
if hexStr == "" {
|
||||
return 0, nil
|
||||
}
|
||||
balance, ok := new(big.Int).SetString(hexStr, 16)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid hex balance: %q", rpcResp.Result)
|
||||
}
|
||||
|
||||
balance := new(big.Int)
|
||||
balance.SetString(hexStr, 16)
|
||||
|
||||
// Convert to float with 6 decimals
|
||||
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(USDCDecimals), nil)
|
||||
whole := new(big.Int).Div(balance, divisor)
|
||||
whole := new(big.Int).Quo(balance, divisor)
|
||||
remainder := new(big.Int).Mod(balance, divisor)
|
||||
|
||||
return fmt.Sprintf("%d.%06d", whole, remainder)
|
||||
// Preserve 6-decimal precision without float drift.
|
||||
frac := fmt.Sprintf("%06d", remainder.Int64())
|
||||
combined := whole.String() + "." + frac
|
||||
var out float64
|
||||
if _, err := fmt.Sscanf(combined, "%f", &out); err != nil {
|
||||
return 0, fmt.Errorf("parse balance %q: %w", combined, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
+5
-709
@@ -1,718 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import useSWR from 'swr'
|
||||
import { api } from './lib/api'
|
||||
import { TraderDashboardPage } from './pages/TraderDashboardPage'
|
||||
|
||||
import { AITradersPage } from './components/trader/AITradersPage'
|
||||
import { LoginPage } from './components/auth/LoginPage'
|
||||
import { SetupPage } from './components/modals/SetupPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { ResetPasswordPage } from './components/auth/ResetPasswordPage'
|
||||
import { CompetitionPage } from './components/trader/CompetitionPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { FAQPage } from './pages/FAQPage'
|
||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
||||
import { DataPage } from './pages/DataPage'
|
||||
import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage'
|
||||
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
|
||||
import HeaderBar from './components/common/HeaderBar'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
|
||||
import { t } from './i18n/translations'
|
||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||
import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { LanguageProvider } from './contexts/LanguageContext'
|
||||
import { AppRoutes } from './router/AppRoutes'
|
||||
|
||||
import { OFFICIAL_LINKS } from './constants/branding'
|
||||
import type {
|
||||
SystemStatus,
|
||||
AccountInfo,
|
||||
Position,
|
||||
DecisionRecord,
|
||||
Statistics,
|
||||
TraderInfo,
|
||||
Exchange,
|
||||
} from './types'
|
||||
|
||||
type Page =
|
||||
| 'competition'
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
|
||||
|
||||
|
||||
function App() {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
const { user, token, logout, isLoading } = useAuth()
|
||||
const { config: systemConfig, loading: configLoading } = useSystemConfig()
|
||||
const [route, setRoute] = useState(window.location.pathname)
|
||||
|
||||
// 从URL路径读取初始页面状态(支持刷新保持页面)
|
||||
const getInitialPage = (): Page => {
|
||||
const path = window.location.pathname
|
||||
const hash = window.location.hash.slice(1) // 去掉 #
|
||||
|
||||
if (path === '/welcome') return 'traders'
|
||||
if (path === '/traders' || hash === 'traders') return 'traders'
|
||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
|
||||
if (path === '/data' || hash === 'data') return 'data'
|
||||
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
|
||||
return 'trader'
|
||||
return 'competition' // 默认为竞赛页面
|
||||
}
|
||||
|
||||
// Login required overlay state
|
||||
const [loginOverlayOpen, setLoginOverlayOpen] = useState(false)
|
||||
const [loginOverlayFeature, setLoginOverlayFeature] = useState('')
|
||||
|
||||
const handleLoginRequired = (featureName: string) => {
|
||||
setLoginOverlayFeature(featureName)
|
||||
setLoginOverlayOpen(true)
|
||||
}
|
||||
|
||||
// Unified page navigation handler
|
||||
const navigateToPage = (page: Page) => {
|
||||
const pathMap: Record<Page, string> = {
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
'data': '/data',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'strategy': '/strategy',
|
||||
'faq': '/faq',
|
||||
'login': '/login',
|
||||
'register': '/register',
|
||||
}
|
||||
const path = pathMap[page]
|
||||
if (path) {
|
||||
window.history.pushState({}, '', path)
|
||||
setRoute(path)
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<Page>(getInitialPage())
|
||||
// 从 URL 参数读取初始 trader 标识(格式: name-id前4位)
|
||||
const [selectedTraderSlug, setSelectedTraderSlug] = useState<string | undefined>(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('trader') || undefined
|
||||
})
|
||||
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>()
|
||||
|
||||
// 生成 trader URL slug(name + ID 前 4 位)
|
||||
const getTraderSlug = (trader: TraderInfo) => {
|
||||
const idPrefix = trader.trader_id.slice(0, 4)
|
||||
return `${trader.trader_name}-${idPrefix}`
|
||||
}
|
||||
|
||||
// 从 slug 解析并匹配 trader
|
||||
const findTraderBySlug = (slug: string, traderList: TraderInfo[]) => {
|
||||
// slug 格式: name-xxxx (xxxx 是 ID 前 4 位)
|
||||
const lastDashIndex = slug.lastIndexOf('-')
|
||||
if (lastDashIndex === -1) {
|
||||
// 没有 dash,直接按 name 匹配
|
||||
return traderList.find(t => t.trader_name === slug)
|
||||
}
|
||||
const name = slug.slice(0, lastDashIndex)
|
||||
const idPrefix = slug.slice(lastDashIndex + 1)
|
||||
return traderList.find(t =>
|
||||
t.trader_name === name && t.trader_id.startsWith(idPrefix)
|
||||
)
|
||||
}
|
||||
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
|
||||
const hasPersistedAuth =
|
||||
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
|
||||
|
||||
// Poll-off states: stop polling after 3 consecutive failures
|
||||
const [accountPollOff, setAccountPollOff] = useState(false)
|
||||
const [positionsPollOff, setPositionsPollOff] = useState(false)
|
||||
const [decisionsPollOff, setDecisionsPollOff] = useState(false)
|
||||
|
||||
// Reset poll-off states when trader changes
|
||||
useEffect(() => {
|
||||
setAccountPollOff(false)
|
||||
setPositionsPollOff(false)
|
||||
setDecisionsPollOff(false)
|
||||
}, [selectedTraderId])
|
||||
|
||||
// 监听URL变化,同步页面状态
|
||||
useEffect(() => {
|
||||
const handleRouteChange = () => {
|
||||
const path = window.location.pathname
|
||||
const hash = window.location.hash.slice(1)
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const traderParam = params.get('trader')
|
||||
|
||||
if (path === '/welcome') {
|
||||
setCurrentPage('traders')
|
||||
} else if (path === '/traders' || hash === 'traders') {
|
||||
setCurrentPage('traders')
|
||||
} else if (path === '/strategy' || hash === 'strategy') {
|
||||
setCurrentPage('strategy')
|
||||
} else if (path === '/strategy-market' || hash === 'strategy-market') {
|
||||
setCurrentPage('strategy-market')
|
||||
} else if (path === '/data' || hash === 'data') {
|
||||
setCurrentPage('data')
|
||||
} else if (
|
||||
path === '/dashboard' ||
|
||||
hash === 'trader' ||
|
||||
hash === 'details'
|
||||
) {
|
||||
setCurrentPage('trader')
|
||||
// 如果 URL 中有 trader 参数(slug 格式),更新选中的 trader
|
||||
setSelectedTraderSlug(traderParam || undefined)
|
||||
} else if (
|
||||
path === '/competition' ||
|
||||
hash === 'competition' ||
|
||||
hash === ''
|
||||
) {
|
||||
setCurrentPage('competition')
|
||||
}
|
||||
setRoute(path)
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', handleRouteChange)
|
||||
window.addEventListener('popstate', handleRouteChange)
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', handleRouteChange)
|
||||
window.removeEventListener('popstate', handleRouteChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 切换页面时更新URL hash (当前通过按钮直接调用setCurrentPage,这个函数暂时保留用于未来扩展)
|
||||
// const navigateToPage = (page: Page) => {
|
||||
// setCurrentPage(page);
|
||||
// window.location.hash = page === 'competition' ? '' : 'trader';
|
||||
// };
|
||||
|
||||
// 获取trader列表(仅在用户登录时)
|
||||
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
||||
user && token ? 'traders' : null,
|
||||
() => api.getTraders(currentPage === 'trader'),
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
shouldRetryOnError: false, // 避免在后端未运行时无限重试
|
||||
}
|
||||
)
|
||||
|
||||
// 获取exchanges列表(用于显示交易所名称)
|
||||
const { data: exchanges } = useSWR<Exchange[]>(
|
||||
user && token ? 'exchanges' : null,
|
||||
api.getExchangeConfigs,
|
||||
{
|
||||
refreshInterval: 60000, // 1分钟刷新一次
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
// 当获取到traders后,根据 URL 中的 trader slug 设置选中的 trader,或默认选中第一个
|
||||
useEffect(() => {
|
||||
if (!traders || traders.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedTraderSlug) {
|
||||
// 通过 slug 找到对应的 trader
|
||||
const trader = findTraderBySlug(selectedTraderSlug, traders)
|
||||
const nextTraderId = trader?.trader_id || traders[0].trader_id
|
||||
if (nextTraderId !== selectedTraderId) {
|
||||
setSelectedTraderId(nextTraderId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedTraderId) {
|
||||
setSelectedTraderId(traders[0].trader_id)
|
||||
}
|
||||
}, [traders, selectedTraderId, selectedTraderSlug])
|
||||
|
||||
// 如果在trader页面,获取该trader的数据
|
||||
const { data: status } = useSWR<SystemStatus>(
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `status-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getStatus(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
||||
}
|
||||
)
|
||||
|
||||
const { data: account } = useSWR<AccountInfo>(
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `account-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getAccount(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: accountPollOff ? 0 : 15000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||
if (retryCount >= 2) { setAccountPollOff(true); return }
|
||||
setTimeout(() => revalidate({ retryCount }), 500)
|
||||
},
|
||||
onSuccess: () => { if (accountPollOff) setAccountPollOff(false) },
|
||||
}
|
||||
)
|
||||
|
||||
const { data: positions } = useSWR<Position[]>(
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `positions-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getPositions(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: positionsPollOff ? 0 : 15000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||
if (retryCount >= 2) { setPositionsPollOff(true); return }
|
||||
setTimeout(() => revalidate({ retryCount }), 500)
|
||||
},
|
||||
onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) },
|
||||
}
|
||||
)
|
||||
|
||||
const { data: decisions } = useSWR<DecisionRecord[]>(
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
|
||||
: null,
|
||||
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
|
||||
{
|
||||
refreshInterval: decisionsPollOff ? 0 : 30000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||
if (retryCount >= 2) { setDecisionsPollOff(true); return }
|
||||
setTimeout(() => revalidate({ retryCount }), 500)
|
||||
},
|
||||
onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) },
|
||||
}
|
||||
)
|
||||
|
||||
const { data: stats } = useSWR<Statistics>(
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `statistics-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getStatistics(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: 30000, // 30秒刷新(统计数据更新频率较低)
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
const now = new Date().toLocaleTimeString()
|
||||
setLastUpdate(now)
|
||||
}
|
||||
}, [account])
|
||||
|
||||
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
|
||||
|
||||
const effectiveAccount = account
|
||||
const effectivePositions = positions
|
||||
const effectiveDecisions = decisions
|
||||
|
||||
// Handle routing
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setRoute(window.location.pathname)
|
||||
}
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
}, [])
|
||||
|
||||
// Set current page based on route for consistent navigation state
|
||||
useEffect(() => {
|
||||
if (route === '/welcome') {
|
||||
setCurrentPage('traders')
|
||||
} else if (route === '/competition') {
|
||||
setCurrentPage('competition')
|
||||
} else if (route === '/traders') {
|
||||
setCurrentPage('traders')
|
||||
} else if (route === '/dashboard') {
|
||||
setCurrentPage('trader')
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const showBeginnerOnboarding =
|
||||
route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding()
|
||||
|
||||
// Show loading spinner while checking auth or config
|
||||
if (isLoading || configLoading) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: '#0B0E11' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 mx-auto mb-4 animate-pulse"
|
||||
/>
|
||||
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// First-time setup: redirect to /setup if system not initialized
|
||||
if (systemConfig && !systemConfig.initialized && !user) {
|
||||
return <SetupPage />
|
||||
}
|
||||
|
||||
// Handle specific routes regardless of authentication
|
||||
if (route === '/login') {
|
||||
return <LoginPage />
|
||||
}
|
||||
if (route === '/setup') {
|
||||
// If already initialized, redirect to login
|
||||
if (systemConfig?.initialized) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
return <SetupPage />
|
||||
}
|
||||
if (route === '/welcome') {
|
||||
if ((!user || !token) && !hasPersistedAuth) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
if (getUserMode() !== 'beginner') {
|
||||
window.location.href = '/traders'
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (route === '/faq') {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage="faq"
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={navigateToPage}
|
||||
/>
|
||||
<FAQPage />
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (route === '/reset-password') {
|
||||
return <ResetPasswordPage />
|
||||
}
|
||||
if (route === '/settings') {
|
||||
if ((!user || !token) && !hasPersistedAuth) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={navigateToPage}
|
||||
/>
|
||||
<SettingsPage />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Data page - publicly accessible with embedded dashboard
|
||||
if (route === '/data') {
|
||||
const dataPageNavigate = (page: Page) => {
|
||||
navigateToPage(page)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage="data"
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={dataPageNavigate}
|
||||
/>
|
||||
<main className="pt-16">
|
||||
<DataPage />
|
||||
</main>
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Show landing page for root route
|
||||
if (route === '/' || route === '') {
|
||||
return <LandingPage />
|
||||
}
|
||||
|
||||
// Redirect unauthenticated users to landing page
|
||||
if (!user || !token) {
|
||||
return <LandingPage />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage={currentPage}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={navigateToPage}
|
||||
/>
|
||||
|
||||
{/* Main Content with Page Transitions */}
|
||||
<main className="min-h-screen pt-16">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentPage}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
>
|
||||
{currentPage === 'competition' ? (
|
||||
<CompetitionPage />
|
||||
) : currentPage === 'data' ? (
|
||||
<DataPage />
|
||||
) : currentPage === 'strategy-market' ? (
|
||||
<StrategyMarketPage />
|
||||
) : currentPage === 'traders' ? (
|
||||
<AITradersPage
|
||||
onTraderSelect={(traderId) => {
|
||||
setSelectedTraderId(traderId)
|
||||
const trader = traders?.find((item) => item.trader_id === traderId)
|
||||
const url = new URL(window.location.href)
|
||||
url.pathname = '/dashboard'
|
||||
if (trader) {
|
||||
const slug = getTraderSlug(trader)
|
||||
url.searchParams.set('trader', slug)
|
||||
setSelectedTraderSlug(slug)
|
||||
} else {
|
||||
url.searchParams.delete('trader')
|
||||
setSelectedTraderSlug(undefined)
|
||||
}
|
||||
window.history.pushState({}, '', url.toString())
|
||||
setRoute('/dashboard')
|
||||
setCurrentPage('trader')
|
||||
}}
|
||||
/>
|
||||
) : currentPage === 'strategy' ? (
|
||||
<StrategyStudioPage />
|
||||
) : (
|
||||
<TraderDashboardPage
|
||||
selectedTrader={selectedTrader}
|
||||
status={status}
|
||||
account={effectiveAccount}
|
||||
accountFailed={accountPollOff}
|
||||
positions={effectivePositions}
|
||||
positionsFailed={positionsPollOff}
|
||||
decisions={effectiveDecisions}
|
||||
decisionsFailed={decisionsPollOff}
|
||||
decisionsLimit={decisionsLimit}
|
||||
onDecisionsLimitChange={setDecisionsLimit}
|
||||
stats={stats}
|
||||
lastUpdate={lastUpdate}
|
||||
language={language}
|
||||
traders={traders}
|
||||
tradersError={tradersError}
|
||||
selectedTraderId={selectedTraderId}
|
||||
onTraderSelect={(traderId) => {
|
||||
setSelectedTraderId(traderId)
|
||||
// 更新 URL 参数(使用 slug: name-id前4位)
|
||||
const trader = traders?.find(t => t.trader_id === traderId)
|
||||
if (trader) {
|
||||
const slug = getTraderSlug(trader)
|
||||
setSelectedTraderSlug(slug)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('trader', slug)
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
}}
|
||||
onNavigateToTraders={() => {
|
||||
window.history.pushState({}, '', '/traders')
|
||||
setRoute('/traders')
|
||||
setCurrentPage('traders')
|
||||
}}
|
||||
exchanges={exchanges}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer
|
||||
className="mt-16"
|
||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||
>
|
||||
<div
|
||||
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
|
||||
style={{ color: '#5E6673' }}
|
||||
>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
|
||||
{/* GitHub */}
|
||||
<a
|
||||
href={OFFICIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#F0B90B'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
{/* Twitter/X */}
|
||||
<a
|
||||
href={OFFICIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#1DA1F2'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
Twitter
|
||||
</a>
|
||||
{/* Telegram */}
|
||||
<a
|
||||
href={OFFICIAL_LINKS.telegram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#0088cc'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Login Required Overlay */}
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
|
||||
{showBeginnerOnboarding && <BeginnerOnboardingPage />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Wrap App with providers
|
||||
export default function AppWithProviders() {
|
||||
export default function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<App />
|
||||
<AppRoutes />
|
||||
</ConfirmDialogProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
@@ -13,12 +14,15 @@ import { invalidateSystemConfig } from '../../lib/config'
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(
|
||||
null
|
||||
)
|
||||
const [mode, setMode] = useState<UserMode>('beginner')
|
||||
|
||||
// Clean up stale auth state once on mount
|
||||
@@ -31,7 +35,9 @@ export function LoginPage() {
|
||||
// Show session-expired toast (re-runs on language change to update text)
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem('from401') === 'true') {
|
||||
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
|
||||
const id = toast.warning(t('sessionExpired', language), {
|
||||
duration: Infinity,
|
||||
})
|
||||
setExpiredToastId(id)
|
||||
sessionStorage.removeItem('from401')
|
||||
}
|
||||
@@ -48,7 +54,9 @@ export function LoginPage() {
|
||||
sessionStorage.removeItem('from401')
|
||||
invalidateSystemConfig()
|
||||
toast.success(t('forgotAccountSuccess', language))
|
||||
setTimeout(() => { window.location.href = '/setup' }, 1500)
|
||||
setTimeout(() => {
|
||||
navigate('/setup')
|
||||
}, 1500)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
toast.error(data.error || 'Reset failed')
|
||||
@@ -79,23 +87,27 @@ export function LoginPage() {
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
{/* Logo + Title */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" />
|
||||
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10" />
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NOFX"
|
||||
className="w-14 h-14 relative z-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome back</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1.5">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-zinc-500 text-sm">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-8 shadow-2xl">
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">
|
||||
@@ -120,7 +132,7 @@ export function LoginPage() {
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = '/reset-password'}
|
||||
onClick={() => navigate('/reset-password')}
|
||||
className="text-xs text-zinc-500 hover:text-nofx-gold transition-colors"
|
||||
>
|
||||
{t('forgotPassword', language)}
|
||||
@@ -164,7 +176,9 @@ export function LoginPage() {
|
||||
disabled={loading}
|
||||
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||
>
|
||||
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
|
||||
{loading
|
||||
? t('loggingIn', language) || 'Signing in...'
|
||||
: t('signIn', language) || 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -178,7 +192,6 @@ export function LoginPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
@@ -10,7 +11,11 @@ interface LoginRequiredOverlayProps {
|
||||
featureName?: string
|
||||
}
|
||||
|
||||
export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequiredOverlayProps) {
|
||||
export function LoginRequiredOverlay({
|
||||
isOpen,
|
||||
onClose,
|
||||
featureName,
|
||||
}: LoginRequiredOverlayProps) {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const tr = (key: string, params?: Record<string, string | number>) =>
|
||||
@@ -20,11 +25,7 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
? tr('subtitleWithFeature', { featureName })
|
||||
: tr('subtitleDefault')
|
||||
|
||||
const benefits = [
|
||||
tr('benefit1'),
|
||||
tr('benefit2'),
|
||||
tr('benefit4'),
|
||||
]
|
||||
const benefits = [tr('benefit1'), tr('benefit2'), tr('benefit4')]
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -40,7 +41,6 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
disableAnimation
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
@@ -53,7 +53,9 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-nofx-bg-lighter border-b border-nofx-gold/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={12} className="text-nofx-gold" />
|
||||
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">auth_protocol.exe</span>
|
||||
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">
|
||||
auth_protocol.exe
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -75,7 +77,9 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
|
||||
<div className="bg-nofx-bg border border-red-500/50 text-red-500 px-4 py-2 flex items-center gap-3 shadow-[0_0_15px_rgba(239,68,68,0.2)]">
|
||||
<AlertTriangle size={18} className="animate-pulse" />
|
||||
<span className="font-bold tracking-widest text-sm uppercase">{tr('accessDenied')}</span>
|
||||
<span className="font-bold tracking-widest text-sm uppercase">
|
||||
{tr('accessDenied')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,8 +87,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
{/* Terminal Text */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{tr('title')}</h2>
|
||||
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">{subtitle}</p>
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">
|
||||
{tr('title')}
|
||||
</h2>
|
||||
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-nofx-bg-lighter border-l-2 border-nofx-gold/20 p-3 my-4">
|
||||
@@ -96,7 +104,10 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{benefits.map((benefit, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide"
|
||||
>
|
||||
<span className="text-nofx-gold">✓</span> {benefit}
|
||||
</div>
|
||||
))}
|
||||
@@ -105,22 +116,24 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="/login"
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-black font-bold text-xs uppercase tracking-widest hover:bg-yellow-400 transition-all shadow-neon hover:shadow-[0_0_25px_rgba(240,185,11,0.4)] group"
|
||||
>
|
||||
<LogIn size={14} />
|
||||
<span>{tr('loginButton')}</span>
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-></span>
|
||||
</a>
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">
|
||||
->
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="/register"
|
||||
<Link
|
||||
to="/register"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-nofx-gold/20 text-nofx-text-muted hover:text-white hover:border-nofx-gold font-bold text-xs uppercase tracking-widest transition-all hover:bg-nofx-gold/10"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>{tr('registerButton')}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
@@ -131,14 +144,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
[ {tr('abort')} ]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner Accents */}
|
||||
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div>
|
||||
<div className="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-nofx-gold"></div>
|
||||
|
||||
</motion.div>
|
||||
</DeepVoidBackground>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import PasswordChecklist from 'react-password-checklist'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
@@ -13,6 +14,7 @@ import { WhitelistFullPage } from '../common/WhitelistFullPage'
|
||||
export function RegisterPage() {
|
||||
const { language } = useLanguage()
|
||||
const { register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [view, setView] = useState<'register' | 'whitelist-full'>('register')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -61,7 +63,11 @@ export function RegisterPage() {
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await register(email, password, betaCode.trim() || undefined)
|
||||
const result = await register(
|
||||
email,
|
||||
password,
|
||||
betaCode.trim() || undefined
|
||||
)
|
||||
|
||||
const isWhitelistError = (msg: string) => {
|
||||
const lowerMsg = msg.toLowerCase()
|
||||
@@ -86,7 +92,10 @@ export function RegisterPage() {
|
||||
// success path is handled in AuthContext (auto login + navigation)
|
||||
} catch (e) {
|
||||
console.error('Registration error:', e)
|
||||
const errorMsg = e instanceof Error ? e.message : 'Registration failed due to server error'
|
||||
const errorMsg =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'Registration failed due to server error'
|
||||
const lowerMsg = errorMsg.toLowerCase()
|
||||
if (
|
||||
lowerMsg.includes('whitelist') ||
|
||||
@@ -106,15 +115,20 @@ export function RegisterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
|
||||
<DeepVoidBackground
|
||||
className="min-h-screen flex items-center justify-center py-12 font-mono"
|
||||
disableAnimation
|
||||
>
|
||||
<div className="w-full max-w-lg relative z-10 px-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<button
|
||||
onClick={() => (window.location.href = '/')}
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-2 text-zinc-500 hover:text-white transition-colors group px-3 py-1.5 rounded border border-transparent hover:border-zinc-700 bg-black/20 backdrop-blur-sm"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 group-hover:animate-pulse"></div>
|
||||
<span className="text-xs font-mono uppercase tracking-widest">< ABORT_REGISTRATION</span>
|
||||
<span className="text-xs font-mono uppercase tracking-widest">
|
||||
< ABORT_REGISTRATION
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +136,11 @@ export function RegisterPage() {
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-nofx-gold/20 rounded-full blur-xl animate-pulse"></div>
|
||||
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain relative z-10 opacity-90" />
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 object-contain relative z-10 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase mb-2">
|
||||
@@ -140,7 +158,7 @@ export function RegisterPage() {
|
||||
<div className="flex gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full bg-red-500/50 hover:bg-red-500 cursor-pointer transition-colors"
|
||||
onClick={() => (window.location.href = '/')}
|
||||
onClick={() => navigate('/')}
|
||||
title="Close / Return Home"
|
||||
></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></div>
|
||||
@@ -155,7 +173,9 @@ export function RegisterPage() {
|
||||
<div className="mb-6 font-mono text-xs space-y-1 text-zinc-500 border-b border-zinc-800/50 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span>System Check: <span className="text-emerald-500">READY</span></span>
|
||||
<span>
|
||||
System Check: <span className="text-emerald-500">READY</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
@@ -165,7 +185,9 @@ export function RegisterPage() {
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('email', language)}</label>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -178,7 +200,9 @@ export function RegisterPage() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('password', language)}</label>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -199,7 +223,9 @@ export function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('confirmPassword', language)}</label>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
@@ -211,10 +237,16 @@ export function RegisterPage() {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,7 +259,14 @@ export function RegisterPage() {
|
||||
</div>
|
||||
<div className="text-xs font-mono text-zinc-400">
|
||||
<PasswordChecklist
|
||||
rules={['minLength', 'capital', 'lowercase', 'number', 'specialChar', 'match']}
|
||||
rules={[
|
||||
'minLength',
|
||||
'capital',
|
||||
'lowercase',
|
||||
'number',
|
||||
'specialChar',
|
||||
'match',
|
||||
]}
|
||||
minLength={8}
|
||||
value={password}
|
||||
valueAgain={confirmPassword}
|
||||
@@ -248,17 +287,25 @@ export function RegisterPage() {
|
||||
|
||||
{betaMode && (
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">Priority Access Code</label>
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">
|
||||
Priority Access Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={betaCode}
|
||||
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())}
|
||||
onChange={(e) =>
|
||||
setBetaCode(
|
||||
e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase()
|
||||
)
|
||||
}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono tracking-widest"
|
||||
placeholder="XXXXXX"
|
||||
maxLength={6}
|
||||
required={betaMode}
|
||||
/>
|
||||
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">* CASE SENSITIVE ALPHANUMERIC</p>
|
||||
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">
|
||||
* CASE SENSITIVE ALPHANUMERIC
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -270,7 +317,9 @@ export function RegisterPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (betaMode && !betaCode.trim()) || !passwordValid}
|
||||
disabled={
|
||||
loading || (betaMode && !betaCode.trim()) || !passwordValid
|
||||
}
|
||||
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_15px_rgba(255,215,0,0.1)] hover:shadow-[0_0_25px_rgba(255,215,0,0.25)] flex items-center justify-center gap-2 group mt-4"
|
||||
>
|
||||
{loading ? (
|
||||
@@ -278,7 +327,9 @@ export function RegisterPage() {
|
||||
) : (
|
||||
<>
|
||||
<span>CREATE_ACCOUNT</span>
|
||||
<span className="group-hover:translate-x-1 transition-transform">-></span>
|
||||
<span className="group-hover:translate-x-1 transition-transform">
|
||||
->
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -295,14 +346,14 @@ export function RegisterPage() {
|
||||
<p className="text-xs font-mono text-zinc-500">
|
||||
EXISTING_OPERATOR?{' '}
|
||||
<button
|
||||
onClick={() => (window.location.href = '/login')}
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
|
||||
>
|
||||
ACCESS TERMINAL
|
||||
</button>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => (window.location.href = '/')}
|
||||
onClick={() => navigate('/')}
|
||||
className="text-[10px] text-zinc-600 hover:text-red-500 transition-colors uppercase tracking-widest hover:underline decoration-red-500/30 font-mono"
|
||||
>
|
||||
[ ABORT_REGISTRATION_RETURN_HOME ]
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { RegistrationDisabled } from './RegistrationDisabled'
|
||||
import { LanguageProvider } from '../../contexts/LanguageContext'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useLanguage hook
|
||||
vi.mock('../../contexts/LanguageContext', async () => {
|
||||
const actual = await vi.importActual('../../contexts/LanguageContext')
|
||||
@@ -21,9 +32,11 @@ vi.mock('../../contexts/LanguageContext', async () => {
|
||||
describe('RegistrationDisabled Component', () => {
|
||||
const renderComponent = () => {
|
||||
return render(
|
||||
<LanguageProvider>
|
||||
<RegistrationDisabled />
|
||||
</LanguageProvider>
|
||||
<MemoryRouter>
|
||||
<LanguageProvider>
|
||||
<RegistrationDisabled />
|
||||
</LanguageProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,7 +61,9 @@ describe('RegistrationDisabled Component', () => {
|
||||
|
||||
it('should display registration closed message', () => {
|
||||
renderComponent()
|
||||
const message = screen.getByText(/User registration is currently disabled/i)
|
||||
const message = screen.getByText(
|
||||
/User registration is currently disabled/i
|
||||
)
|
||||
expect(message).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -61,19 +76,12 @@ describe('RegistrationDisabled Component', () => {
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate to login page when button is clicked', () => {
|
||||
const pushStateSpy = vi.spyOn(window.history, 'pushState')
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
|
||||
renderComponent()
|
||||
const button = screen.getByRole('button', { name: /back to login/i })
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/login')
|
||||
expect(dispatchEventSpy).toHaveBeenCalled()
|
||||
|
||||
pushStateSpy.mockRestore()
|
||||
dispatchEventSpy.mockRestore()
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export function RegistrationDisabled() {
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
@@ -11,6 +12,7 @@ import { toast } from 'sonner'
|
||||
export function ResetPasswordPage() {
|
||||
const { language } = useLanguage()
|
||||
const { resetPassword } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
@@ -41,8 +43,7 @@ export function ResetPasswordPage() {
|
||||
toast.success(t('resetPasswordSuccess', language) || '重置成功')
|
||||
// 3秒后跳转到登录页面
|
||||
setTimeout(() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate('/login')
|
||||
}, 3000)
|
||||
} else {
|
||||
const msg = result.message || t('resetPasswordFailed', language)
|
||||
@@ -64,10 +65,7 @@ export function ResetPasswordPage() {
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back to Login */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}}
|
||||
onClick={() => navigate('/login')}
|
||||
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
@@ -10,17 +10,7 @@ import {
|
||||
setUserMode,
|
||||
type UserMode,
|
||||
} from '../../lib/onboarding'
|
||||
|
||||
type Page =
|
||||
| 'competition'
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
import { getCurrentPageForPath, ROUTES, type Page } from '../../router/paths'
|
||||
|
||||
interface HeaderBarProps {
|
||||
onLoginClick?: () => void
|
||||
@@ -47,16 +37,20 @@ export default function HeaderBar({
|
||||
onLoginRequired,
|
||||
}: HeaderBarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(
|
||||
() => getUserMode() ?? 'advanced'
|
||||
)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const resolvedCurrentPage =
|
||||
currentPage ?? getCurrentPageForPath(location.pathname)
|
||||
|
||||
const navigateInApp = (path: string) => {
|
||||
navigate(path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleSwitchMode = (nextMode: UserMode) => {
|
||||
@@ -94,14 +88,12 @@ export default function HeaderBar({
|
||||
{/* Logo - Always go to home page */}
|
||||
<div
|
||||
onClick={() => {
|
||||
window.location.href = '/'
|
||||
navigateInApp(ROUTES.home)
|
||||
}}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
|
||||
<span className="text-lg font-bold text-nofx-gold">
|
||||
NOFX
|
||||
</span>
|
||||
<span className="text-lg font-bold text-nofx-gold">NOFX</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
@@ -111,17 +103,67 @@ export default function HeaderBar({
|
||||
{/* Navigation tabs configuration */}
|
||||
{(() => {
|
||||
// Define all navigation tabs
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||
const navTabs: {
|
||||
page: Page
|
||||
path: string
|
||||
label: string
|
||||
requiresAuth: boolean
|
||||
}[] = [
|
||||
{
|
||||
page: 'data',
|
||||
path: ROUTES.data,
|
||||
label:
|
||||
language === 'zh'
|
||||
? '数据'
|
||||
: language === 'id'
|
||||
? 'Data'
|
||||
: 'Data',
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
page: 'strategy-market',
|
||||
path: ROUTES.strategyMarket,
|
||||
label:
|
||||
language === 'zh'
|
||||
? '策略市场'
|
||||
: language === 'id'
|
||||
? 'Pasar'
|
||||
: 'Market',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'traders',
|
||||
path: ROUTES.traders,
|
||||
label: t('configNav', language),
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'trader',
|
||||
path: ROUTES.dashboard,
|
||||
label: t('dashboardNav', language),
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'strategy',
|
||||
path: ROUTES.strategy,
|
||||
label: t('strategyNav', language),
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'competition',
|
||||
path: ROUTES.competition,
|
||||
label: t('realtimeNav', language),
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'faq',
|
||||
path: ROUTES.faq,
|
||||
label: t('faqNav', language),
|
||||
requiresAuth: false,
|
||||
},
|
||||
]
|
||||
|
||||
const handleNavClick = (tab: typeof navTabs[0]) => {
|
||||
const handleNavClick = (tab: (typeof navTabs)[0]) => {
|
||||
// If requires auth and not logged in, show login prompt
|
||||
if (tab.requiresAuth && !isLoggedIn) {
|
||||
onLoginRequired?.(tab.label)
|
||||
@@ -131,7 +173,7 @@ export default function HeaderBar({
|
||||
if (onPageChange) {
|
||||
onPageChange(tab.page)
|
||||
}
|
||||
navigate(tab.path)
|
||||
navigateInApp(tab.path)
|
||||
}
|
||||
|
||||
return navTabs.map((tab) => (
|
||||
@@ -139,12 +181,10 @@ export default function HeaderBar({
|
||||
key={tab.page}
|
||||
onClick={() => handleNavClick(tab)}
|
||||
className={`text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 px-3 py-2 rounded-lg
|
||||
${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
|
||||
${resolvedCurrentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
|
||||
>
|
||||
{currentPage === tab.page && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
|
||||
/>
|
||||
{resolvedCurrentPage === tab.page && (
|
||||
<span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" />
|
||||
)}
|
||||
{tab.label}
|
||||
</button>
|
||||
@@ -164,7 +204,12 @@ export default function HeaderBar({
|
||||
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-white hover:bg-white/5"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
</a>
|
||||
@@ -176,7 +221,12 @@ export default function HeaderBar({
|
||||
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#1DA1F2] hover:bg-[#1DA1F2]/10"
|
||||
title="Twitter"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
@@ -188,7 +238,12 @@ export default function HeaderBar({
|
||||
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#0088cc] hover:bg-[#0088cc]/10"
|
||||
title="Telegram"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
</a>
|
||||
@@ -227,7 +282,7 @@ export default function HeaderBar({
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = '/settings'
|
||||
navigateInApp(ROUTES.settings)
|
||||
setUserDropdownOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
|
||||
@@ -236,13 +291,21 @@ export default function HeaderBar({
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSwitchMode(userMode === 'beginner' ? 'advanced' : 'beginner')}
|
||||
onClick={() =>
|
||||
handleSwitchMode(
|
||||
userMode === 'beginner' ? 'advanced' : 'beginner'
|
||||
)
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
{userMode === 'beginner'
|
||||
? language === 'zh' ? '切到老手模式' : 'Switch to Advanced'
|
||||
: language === 'zh' ? '切到新手模式' : 'Switch to Beginner'}
|
||||
? language === 'zh'
|
||||
? '切到老手模式'
|
||||
: 'Switch to Advanced'
|
||||
: language === 'zh'
|
||||
? '切到新手模式'
|
||||
: 'Switch to Beginner'}
|
||||
</button>
|
||||
{onLogout && (
|
||||
<button
|
||||
@@ -261,15 +324,16 @@ export default function HeaderBar({
|
||||
</div>
|
||||
) : (
|
||||
/* Show login/register buttons when not logged in and not on login/register pages */
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
resolvedCurrentPage !== 'login' &&
|
||||
resolvedCurrentPage !== 'register' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/login"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateInApp(ROUTES.login)}
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -361,17 +425,67 @@ export default function HeaderBar({
|
||||
{/* Navigation Links */}
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
{(() => {
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||
const navTabs: {
|
||||
page: Page
|
||||
path: string
|
||||
label: string
|
||||
requiresAuth: boolean
|
||||
}[] = [
|
||||
{
|
||||
page: 'data',
|
||||
path: ROUTES.data,
|
||||
label:
|
||||
language === 'zh'
|
||||
? '数据'
|
||||
: language === 'id'
|
||||
? 'Data'
|
||||
: 'Data',
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
page: 'strategy-market',
|
||||
path: ROUTES.strategyMarket,
|
||||
label:
|
||||
language === 'zh'
|
||||
? '策略市场'
|
||||
: language === 'id'
|
||||
? 'Pasar'
|
||||
: 'Market',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'traders',
|
||||
path: ROUTES.traders,
|
||||
label: t('configNav', language),
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'trader',
|
||||
path: ROUTES.dashboard,
|
||||
label: t('dashboardNav', language),
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'strategy',
|
||||
path: ROUTES.strategy,
|
||||
label: t('strategyNav', language),
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'competition',
|
||||
path: ROUTES.competition,
|
||||
label: t('realtimeNav', language),
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
page: 'faq',
|
||||
path: ROUTES.faq,
|
||||
label: t('faqNav', language),
|
||||
requiresAuth: false,
|
||||
},
|
||||
]
|
||||
|
||||
const handleMobileNavClick = (tab: typeof navTabs[0]) => {
|
||||
const handleMobileNavClick = (tab: (typeof navTabs)[0]) => {
|
||||
if (tab.requiresAuth && !isLoggedIn) {
|
||||
onLoginRequired?.(tab.label)
|
||||
setMobileMenuOpen(false)
|
||||
@@ -380,7 +494,7 @@ export default function HeaderBar({
|
||||
if (onPageChange) {
|
||||
onPageChange(tab.page)
|
||||
}
|
||||
navigate(tab.path)
|
||||
navigateInApp(tab.path)
|
||||
setMobileMenuOpen(false)
|
||||
}
|
||||
|
||||
@@ -392,9 +506,9 @@ export default function HeaderBar({
|
||||
transition={{ delay: 0.1 + i * 0.05 }}
|
||||
onClick={() => handleMobileNavClick(tab)}
|
||||
className={`text-2xl font-black tracking-tight text-left flex items-center gap-3
|
||||
${currentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
|
||||
${resolvedCurrentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
|
||||
>
|
||||
{currentPage === tab.page && (
|
||||
{resolvedCurrentPage === tab.page && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
|
||||
@@ -438,9 +552,24 @@ export default function HeaderBar({
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
{[
|
||||
{ href: OFFICIAL_LINKS.github, icon: <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" /> },
|
||||
{ href: OFFICIAL_LINKS.twitter, icon: <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> },
|
||||
{ href: OFFICIAL_LINKS.telegram, icon: <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /> }
|
||||
{
|
||||
href: OFFICIAL_LINKS.github,
|
||||
icon: (
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
),
|
||||
},
|
||||
{
|
||||
href: OFFICIAL_LINKS.twitter,
|
||||
icon: (
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
),
|
||||
},
|
||||
{
|
||||
href: OFFICIAL_LINKS.telegram,
|
||||
icon: (
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
),
|
||||
},
|
||||
].map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
@@ -449,7 +578,12 @@ export default function HeaderBar({
|
||||
rel="noopener noreferrer"
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 hover:text-nofx-gold hover:border-nofx-gold transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
{link.icon}
|
||||
</svg>
|
||||
</a>
|
||||
@@ -467,10 +601,11 @@ export default function HeaderBar({
|
||||
onLanguageChange?.(lang as Language)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${language === lang
|
||||
? 'bg-zinc-800 text-white shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${
|
||||
language === lang
|
||||
? 'bg-zinc-800 text-white shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'}
|
||||
</button>
|
||||
@@ -489,13 +624,18 @@ export default function HeaderBar({
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
) : (
|
||||
currentPage !== 'login' && currentPage !== 'register' && (
|
||||
<a
|
||||
href="/login"
|
||||
resolvedCurrentPage !== 'login' &&
|
||||
resolvedCurrentPage !== 'register' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigateInApp(ROUTES.login)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="flex items-center justify-center bg-nofx-gold text-black rounded-lg font-bold text-sm hover:bg-yellow-400 transition-colors"
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { OFFICIAL_LINKS } from '../../constants/branding'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
|
||||
interface SiteFooterProps {
|
||||
language: Language
|
||||
}
|
||||
|
||||
export function SiteFooter({ language }: SiteFooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className="mt-16"
|
||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||
>
|
||||
<div
|
||||
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
|
||||
style={{ color: '#5E6673' }}
|
||||
>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={OFFICIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#F0B90B'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#1DA1F2'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
Twitter
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_LINKS.telegram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#0088cc'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { ShieldAlert, ArrowLeft, Twitter, Send, Lock } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { OFFICIAL_LINKS } from '../../constants/branding'
|
||||
|
||||
interface WhitelistFullPageProps {
|
||||
@@ -7,11 +8,13 @@ interface WhitelistFullPageProps {
|
||||
}
|
||||
|
||||
export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
if (onBack) {
|
||||
onBack()
|
||||
} else {
|
||||
window.location.href = '/login'
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +32,6 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
className="max-w-lg w-full relative z-10"
|
||||
>
|
||||
<div className="bg-zinc-900/40 backdrop-blur-md border border-red-500/30 rounded-lg overflow-hidden relative group">
|
||||
|
||||
{/* Top Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-red-900/20 border-b border-red-500/30">
|
||||
<div className="flex gap-1.5 opacity-50">
|
||||
@@ -60,9 +62,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-zinc-400 mb-8 leading-relaxed font-mono px-4">
|
||||
<span className="text-red-400">[SYSTEM_MESSAGE]:</span> YOUR IDENTIFIER IS NOT ON THE ACTIVE WHITELIST.
|
||||
<br /><br />
|
||||
Platform capacity limits have been reached for the current beta phase. Prioritized access is currently reserved for authorized operators only.
|
||||
<span className="text-red-400">[SYSTEM_MESSAGE]:</span> YOUR
|
||||
IDENTIFIER IS NOT ON THE ACTIVE WHITELIST.
|
||||
<br />
|
||||
<br />
|
||||
Platform capacity limits have been reached for the current beta
|
||||
phase. Prioritized access is currently reserved for authorized
|
||||
operators only.
|
||||
</p>
|
||||
|
||||
{/* Info Box */}
|
||||
@@ -70,9 +76,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="w-4 h-4 text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-red-400 uppercase mb-1">Authorization Protocol</h3>
|
||||
<h3 className="text-xs font-bold text-red-400 uppercase mb-1">
|
||||
Authorization Protocol
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 leading-tight">
|
||||
Access is rolled out in batches. If you believe this is an error, please verify your credentials or contact system administrators.
|
||||
Access is rolled out in batches. If you believe this is an
|
||||
error, please verify your credentials or contact system
|
||||
administrators.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,14 +119,12 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-black/80 p-2 text-[9px] text-zinc-700 text-center border-t border-zinc-800 font-mono uppercase">
|
||||
ERR_CODE: WLIST_0x403 // SECURITY_LAYER_ACTIVE
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Play, Github, Zap } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { t, Language } from '../../i18n/translations'
|
||||
import { useGitHubStats } from '../../hooks/useGitHubStats'
|
||||
import { useCounterAnimation } from '../../hooks/useCounterAnimation'
|
||||
@@ -33,7 +34,8 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(240, 185, 11, 0.08) 0%, transparent 70%)',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(240, 185, 11, 0.08) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* Floating Orbs */}
|
||||
@@ -138,8 +140,7 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12"
|
||||
>
|
||||
<motion.a
|
||||
href="/competition"
|
||||
<motion.div
|
||||
className="group flex items-center gap-3 px-8 py-4 rounded-xl font-bold text-lg transition-all"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
@@ -152,10 +153,12 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
{t('liveCompetition', language) || 'Live Competition'}
|
||||
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||
</motion.a>
|
||||
<Link to="/competition" className="flex items-center gap-3">
|
||||
<Play className="w-5 h-5" />
|
||||
{t('liveCompetition', language) || 'Live Competition'}
|
||||
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.a
|
||||
href={OFFICIAL_LINKS.github}
|
||||
@@ -188,9 +191,18 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
>
|
||||
{[
|
||||
{ label: 'GitHub Stars', value: `${(stars / 1000).toFixed(1)}K+` },
|
||||
{ label: language === 'zh' ? '支持交易所' : 'Exchanges', value: '5+' },
|
||||
{ label: language === 'zh' ? 'AI 模型' : 'AI Models', value: '10+' },
|
||||
{ label: language === 'zh' ? '开源免费' : 'Open Source', value: '100%' },
|
||||
{
|
||||
label: language === 'zh' ? '支持交易所' : 'Exchanges',
|
||||
value: '5+',
|
||||
},
|
||||
{
|
||||
label: language === 'zh' ? 'AI 模型' : 'AI Models',
|
||||
value: '10+',
|
||||
},
|
||||
{
|
||||
label: language === 'zh' ? '开源免费' : 'Open Source',
|
||||
value: '100%',
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
@@ -202,7 +214,8 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
<div
|
||||
className="text-3xl sm:text-4xl font-bold mb-1"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
background:
|
||||
'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t, Language } from '../../i18n/translations'
|
||||
interface LoginModalProps {
|
||||
onClose: () => void
|
||||
@@ -7,6 +8,7 @@ interface LoginModalProps {
|
||||
}
|
||||
|
||||
export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -49,8 +51,7 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate('/login')
|
||||
onClose()
|
||||
}}
|
||||
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
|
||||
|
||||
@@ -1,149 +1,178 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../../contexts/AuthContext'
|
||||
|
||||
const agents = [
|
||||
{
|
||||
name: "ALPHA-1",
|
||||
// ... (rest of agents array remains, but I can't skip lines in replacement content easily without context. Wait, let's just replace the top section)
|
||||
// Actually, I'll use multi_replace for targeted cleanup.
|
||||
class: "SCALPER",
|
||||
desc: "High-frequency microstructure exploitation.",
|
||||
apy: "142%",
|
||||
winRate: "68%",
|
||||
risk: "HIGH",
|
||||
color: "text-nofx-gold",
|
||||
border: "border-nofx-gold/50",
|
||||
bg_glow: "shadow-[0_0_30px_rgba(240,185,11,0.1)]",
|
||||
icon: Zap
|
||||
},
|
||||
{
|
||||
name: "BETA-X",
|
||||
class: "SWING_OPS",
|
||||
desc: "Multi-day trend extraction engine.",
|
||||
apy: "89%",
|
||||
winRate: "55%",
|
||||
risk: "MED",
|
||||
color: "text-blue-400",
|
||||
border: "border-blue-400/30",
|
||||
bg_glow: "shadow-[0_0_30px_rgba(96,165,250,0.1)]",
|
||||
icon: TrendingUp
|
||||
},
|
||||
{
|
||||
name: "GAMMA-RAY",
|
||||
class: "ARBITRAGE",
|
||||
desc: "Low-risk spatial price equalization.",
|
||||
apy: "24%",
|
||||
winRate: "99%",
|
||||
risk: "LOW",
|
||||
color: "text-purple-400",
|
||||
border: "border-purple-400/30",
|
||||
bg_glow: "shadow-[0_0_30px_rgba(192,132,252,0.1)]",
|
||||
icon: Layers
|
||||
},
|
||||
{
|
||||
name: 'ALPHA-1',
|
||||
// ... (rest of agents array remains, but I can't skip lines in replacement content easily without context. Wait, let's just replace the top section)
|
||||
// Actually, I'll use multi_replace for targeted cleanup.
|
||||
class: 'SCALPER',
|
||||
desc: 'High-frequency microstructure exploitation.',
|
||||
apy: '142%',
|
||||
winRate: '68%',
|
||||
risk: 'HIGH',
|
||||
color: 'text-nofx-gold',
|
||||
border: 'border-nofx-gold/50',
|
||||
bg_glow: 'shadow-[0_0_30px_rgba(240,185,11,0.1)]',
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
name: 'BETA-X',
|
||||
class: 'SWING_OPS',
|
||||
desc: 'Multi-day trend extraction engine.',
|
||||
apy: '89%',
|
||||
winRate: '55%',
|
||||
risk: 'MED',
|
||||
color: 'text-blue-400',
|
||||
border: 'border-blue-400/30',
|
||||
bg_glow: 'shadow-[0_0_30px_rgba(96,165,250,0.1)]',
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
name: 'GAMMA-RAY',
|
||||
class: 'ARBITRAGE',
|
||||
desc: 'Low-risk spatial price equalization.',
|
||||
apy: '24%',
|
||||
winRate: '99%',
|
||||
risk: 'LOW',
|
||||
color: 'text-purple-400',
|
||||
border: 'border-purple-400/30',
|
||||
bg_glow: 'shadow-[0_0_30px_rgba(192,132,252,0.1)]',
|
||||
icon: Layers,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AgentGrid() {
|
||||
const { user } = useAuth()
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleInitialize = () => {
|
||||
if (user) {
|
||||
window.location.href = '/strategy-market'
|
||||
} else {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
const handleInitialize = () => {
|
||||
if (user) {
|
||||
navigate('/strategy-market')
|
||||
} else {
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="market-scanner" className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden">
|
||||
return (
|
||||
<section
|
||||
id="market-scanner"
|
||||
className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden"
|
||||
>
|
||||
{/* Background Details */}
|
||||
<div className="absolute top-0 right-0 p-10 opacity-20 pointer-events-none">
|
||||
<Hexagon className="w-64 h-64 text-zinc-800" strokeWidth={0.5} />
|
||||
</div>
|
||||
|
||||
{/* Background Details */}
|
||||
<div className="absolute top-0 right-0 p-10 opacity-20 pointer-events-none">
|
||||
<Hexagon className="w-64 h-64 text-zinc-800" strokeWidth={0.5} />
|
||||
<div className="max-w-7xl mx-auto px-6 relative z-10">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-10 md:mb-16 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase">
|
||||
<Crosshair className="w-4 h-4" /> MARKET SELECT
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black text-white uppercase tracking-tighter">
|
||||
STRATEGY{' '}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">
|
||||
UNITS
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="font-mono text-right text-xs text-zinc-500 max-w-xs">
|
||||
SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE
|
||||
PRE-TRAINED ON HISTORICAL TICKS.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 relative z-10">
|
||||
{/* Grid Container - Removing scroll tracking for stability test */}
|
||||
<div className="flex flex-row md:grid md:grid-cols-3 gap-4 md:gap-8 overflow-x-auto md:overflow-visible pb-12 md:pb-0 snap-x snap-mandatory -mx-6 px-6 md:mx-0 md:px-0 scrollbar-hide">
|
||||
{agents.map((agent, i) => {
|
||||
const Icon = agent.icon
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-10 md:mb-16 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase">
|
||||
<Crosshair className="w-4 h-4" /> MARKET SELECT
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black text-white uppercase tracking-tighter">
|
||||
STRATEGY <span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">UNITS</span>
|
||||
</h2>
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className={`group relative bg-black/40 backdrop-blur-xl border ${agent.border} overflow-hidden transition-all duration-300 min-w-[85vw] md:min-w-0 snap-center shrink-0 rounded-xl md:rounded-none`}
|
||||
>
|
||||
{/* Top "Hinge" decoration */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>
|
||||
|
||||
<div className="p-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="p-3 bg-zinc-900/80 rounded border border-zinc-700">
|
||||
<Icon className={`w-8 h-8 ${agent.color}`} />
|
||||
</div>
|
||||
<div className="font-mono text-right text-xs text-zinc-500 max-w-xs">
|
||||
SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE PRE-TRAINED ON HISTORICAL TICKS.
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-mono text-zinc-500 uppercase">
|
||||
Class
|
||||
</div>
|
||||
<div
|
||||
className={`font-bold font-mono tracking-wider ${agent.color}`}
|
||||
>
|
||||
{agent.class}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name & Desc */}
|
||||
<h3 className="text-3xl font-bold text-white mb-2 tracking-tight group-hover:text-nofx-accent transition-colors">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-sm mb-8 leading-relaxed h-10">
|
||||
{agent.desc}
|
||||
</p>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-px bg-zinc-800/50 border border-zinc-800 rounded overflow-hidden mb-8">
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
|
||||
APY
|
||||
</div>
|
||||
<div className="text-green-400 font-bold">
|
||||
{agent.apy}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
|
||||
Win %
|
||||
</div>
|
||||
<div className="text-white font-bold">
|
||||
{agent.winRate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
|
||||
Risk
|
||||
</div>
|
||||
<div className={`${agent.color} font-bold`}>
|
||||
{agent.risk}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Btn */}
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${agent.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white cursor-pointer`}
|
||||
>
|
||||
<span className={agent.color}>[</span> INITIALIZE{' '}
|
||||
<span className={agent.color}>]</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid Container - Removing scroll tracking for stability test */}
|
||||
<div className="flex flex-row md:grid md:grid-cols-3 gap-4 md:gap-8 overflow-x-auto md:overflow-visible pb-12 md:pb-0 snap-x snap-mandatory -mx-6 px-6 md:mx-0 md:px-0 scrollbar-hide">
|
||||
{agents.map((agent, i) => {
|
||||
const Icon = agent.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className={`group relative bg-black/40 backdrop-blur-xl border ${agent.border} overflow-hidden transition-all duration-300 min-w-[85vw] md:min-w-0 snap-center shrink-0 rounded-xl md:rounded-none`}
|
||||
>
|
||||
{/* Top "Hinge" decoration */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>
|
||||
|
||||
<div className="p-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="p-3 bg-zinc-900/80 rounded border border-zinc-700">
|
||||
<Icon className={`w-8 h-8 ${agent.color}`} />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-mono text-zinc-500 uppercase">Class</div>
|
||||
<div className={`font-bold font-mono tracking-wider ${agent.color}`}>{agent.class}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name & Desc */}
|
||||
<h3 className="text-3xl font-bold text-white mb-2 tracking-tight group-hover:text-nofx-accent transition-colors">{agent.name}</h3>
|
||||
<p className="text-zinc-500 text-sm mb-8 leading-relaxed h-10">{agent.desc}</p>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-px bg-zinc-800/50 border border-zinc-800 rounded overflow-hidden mb-8">
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">APY</div>
|
||||
<div className="text-green-400 font-bold">{agent.apy}</div>
|
||||
</div>
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">Win %</div>
|
||||
<div className="text-white font-bold">{agent.winRate}</div>
|
||||
</div>
|
||||
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
|
||||
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">Risk</div>
|
||||
<div className={`${agent.color} font-bold`}>{agent.risk}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Btn */}
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${agent.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white cursor-pointer`}
|
||||
>
|
||||
<span className={agent.color}>[</span> INITIALIZE <span className={agent.color}>]</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full blur-2xl group-hover:opacity-50 transition-opacity opacity-20"></div>
|
||||
<div className="absolute inset-0 bg-scanlines opacity-20 pointer-events-none"></div>
|
||||
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full blur-2xl group-hover:opacity-50 transition-opacity opacity-20"></div>
|
||||
<div className="absolute inset-0 bg-scanlines opacity-20 pointer-events-none"></div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,12 +20,7 @@ import { ModelConfigModal } from './ModelConfigModal'
|
||||
import { ConfigStatusGrid } from './ConfigStatusGrid'
|
||||
import { TradersList } from './TradersList'
|
||||
import { BeginnerGuideCards } from './BeginnerGuideCards'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Plus,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import { AlertTriangle, Bot, Plus, MessageCircle } from 'lucide-react'
|
||||
import { confirmToast } from '../../lib/notify'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -55,11 +50,17 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
const [allModels, setAllModels] = useState<AIModel[]>([])
|
||||
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
|
||||
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
|
||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
|
||||
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => getBeginnerWalletAddress())
|
||||
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<
|
||||
string | null
|
||||
>(() => getBeginnerWalletAddress())
|
||||
const isBeginnerMode = getUserMode() === 'beginner'
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (error instanceof Error && error.message.trim() !== '') {
|
||||
@@ -74,54 +75,98 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
) => {
|
||||
const traderName = params.trader_name || params.traderName || 'this trader'
|
||||
const modelName = params.model_name || params.modelName || 'selected model'
|
||||
const exchangeName = params.exchange_name || params.exchangeName || 'selected exchange account'
|
||||
const reason = localizeTraderReason(params.reason_key, params.reason || fallback)
|
||||
const exchangeName =
|
||||
params.exchange_name || params.exchangeName || 'selected exchange account'
|
||||
const reason = localizeTraderReason(
|
||||
params.reason_key,
|
||||
params.reason || fallback
|
||||
)
|
||||
const symbol = params.symbol || ''
|
||||
|
||||
const zh = language === 'zh'
|
||||
|
||||
switch (errorKey) {
|
||||
case 'trader.create.invalid_request':
|
||||
return zh ? '提交的信息不完整,或者格式不正确。请检查后重新提交。' : 'The submitted information is incomplete or invalid. Please review it and try again.'
|
||||
return zh
|
||||
? '提交的信息不完整,或者格式不正确。请检查后重新提交。'
|
||||
: 'The submitted information is incomplete or invalid. Please review it and try again.'
|
||||
case 'trader.create.invalid_btc_eth_leverage':
|
||||
return zh ? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。' : 'BTC/ETH leverage must be between 1x and 50x.'
|
||||
return zh
|
||||
? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。'
|
||||
: 'BTC/ETH leverage must be between 1x and 50x.'
|
||||
case 'trader.create.invalid_altcoin_leverage':
|
||||
return zh ? '山寨币杠杆倍数需要在 1 到 20 倍之间。' : 'Altcoin leverage must be between 1x and 20x.'
|
||||
return zh
|
||||
? '山寨币杠杆倍数需要在 1 到 20 倍之间。'
|
||||
: 'Altcoin leverage must be between 1x and 20x.'
|
||||
case 'trader.create.invalid_symbol':
|
||||
return zh ? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。` : `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
|
||||
return zh
|
||||
? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。`
|
||||
: `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
|
||||
case 'trader.create.model_not_found':
|
||||
return zh ? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。' : 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
|
||||
return zh
|
||||
? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。'
|
||||
: 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
|
||||
case 'trader.create.model_disabled':
|
||||
return zh ? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。` : `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
|
||||
return zh
|
||||
? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。`
|
||||
: `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
|
||||
case 'trader.create.model_missing_credentials':
|
||||
return zh ? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。` : `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
|
||||
return zh
|
||||
? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。`
|
||||
: `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
|
||||
case 'trader.create.strategy_required':
|
||||
return zh ? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。' : 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
|
||||
return zh
|
||||
? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。'
|
||||
: 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
|
||||
case 'trader.create.strategy_not_found':
|
||||
return zh ? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。' : 'The selected strategy no longer exists. Please choose another available strategy.'
|
||||
return zh
|
||||
? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。'
|
||||
: 'The selected strategy no longer exists. Please choose another available strategy.'
|
||||
case 'trader.create.exchange_not_found':
|
||||
return zh ? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。' : 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
|
||||
return zh
|
||||
? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。'
|
||||
: 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
|
||||
case 'trader.create.exchange_disabled':
|
||||
return zh ? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。` : `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
|
||||
return zh
|
||||
? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。`
|
||||
: `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
|
||||
case 'trader.create.exchange_missing_fields':
|
||||
return zh ? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。` : `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
|
||||
return zh
|
||||
? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。`
|
||||
: `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
|
||||
case 'trader.create.exchange_unsupported':
|
||||
return zh ? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。` : `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
|
||||
return zh
|
||||
? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。`
|
||||
: `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
|
||||
case 'trader.create.exchange_probe_failed':
|
||||
return zh ? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}` : `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
|
||||
return zh
|
||||
? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}`
|
||||
: `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
|
||||
case 'trader.start.strategy_missing':
|
||||
return zh ? `机器人「${traderName}」缺少有效的交易策略配置。` : `Trader "${traderName}" does not have a valid strategy configuration.`
|
||||
return zh
|
||||
? `机器人「${traderName}」缺少有效的交易策略配置。`
|
||||
: `Trader "${traderName}" does not have a valid strategy configuration.`
|
||||
case 'trader.start.model_not_found':
|
||||
return zh ? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。` : `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
|
||||
return zh
|
||||
? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。`
|
||||
: `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
|
||||
case 'trader.start.model_disabled':
|
||||
return zh ? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。` : `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
|
||||
return zh
|
||||
? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。`
|
||||
: `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
|
||||
case 'trader.start.exchange_not_found':
|
||||
return zh ? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。` : `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
|
||||
return zh
|
||||
? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。`
|
||||
: `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
|
||||
case 'trader.start.exchange_disabled':
|
||||
return zh ? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。` : `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
|
||||
return zh
|
||||
? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。`
|
||||
: `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
|
||||
case 'trader.start.setup_invalid':
|
||||
case 'trader.start.load_failed':
|
||||
return zh ? `机器人「${traderName}」暂时还不能启动,原因是:${reason}` : `Trader "${traderName}" cannot be started yet because ${reason}`
|
||||
return zh
|
||||
? `机器人「${traderName}」暂时还不能启动,原因是:${reason}`
|
||||
: `Trader "${traderName}" cannot be started yet because ${reason}`
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
@@ -131,34 +176,69 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
switch (reasonKey) {
|
||||
case 'trader.reason.strategy_config_invalid':
|
||||
return zh ? '当前策略配置内容已损坏,系统暂时无法解析' : 'the current strategy configuration is corrupted and cannot be parsed'
|
||||
return zh
|
||||
? '当前策略配置内容已损坏,系统暂时无法解析'
|
||||
: 'the current strategy configuration is corrupted and cannot be parsed'
|
||||
case 'trader.reason.strategy_missing':
|
||||
return zh ? '当前机器人缺少有效的交易策略配置' : 'the trader is missing a valid strategy configuration'
|
||||
return zh
|
||||
? '当前机器人缺少有效的交易策略配置'
|
||||
: 'the trader is missing a valid strategy configuration'
|
||||
case 'trader.reason.private_key_invalid':
|
||||
return zh ? '私钥格式不正确,系统无法识别' : 'the private key format is invalid and cannot be recognized'
|
||||
return zh
|
||||
? '私钥格式不正确,系统无法识别'
|
||||
: 'the private key format is invalid and cannot be recognized'
|
||||
case 'trader.reason.hyperliquid_init_failed':
|
||||
return zh ? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确' : 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
|
||||
return zh
|
||||
? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确'
|
||||
: 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
|
||||
case 'trader.reason.aster_init_failed':
|
||||
return zh ? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确' : 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
|
||||
return zh
|
||||
? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确'
|
||||
: 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
|
||||
case 'trader.reason.exchange_meta_unavailable':
|
||||
return zh ? '系统暂时无法从交易所读取账户元信息' : 'the system could not read account metadata from the exchange'
|
||||
return zh
|
||||
? '系统暂时无法从交易所读取账户元信息'
|
||||
: 'the system could not read account metadata from the exchange'
|
||||
case 'trader.reason.hyperliquid_agent_balance_too_high':
|
||||
return zh ? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求' : 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
|
||||
return zh
|
||||
? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求'
|
||||
: 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
|
||||
case 'trader.reason.exchange_account_init_failed':
|
||||
return zh ? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配' : 'exchange account initialization failed. Please verify that the wallet address and API key match'
|
||||
return zh
|
||||
? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配'
|
||||
: 'exchange account initialization failed. Please verify that the wallet address and API key match'
|
||||
case 'trader.reason.exchange_unsupported':
|
||||
return zh ? '当前交易所类型暂不支持机器人初始化' : 'the selected exchange type is not currently supported for trader initialization'
|
||||
return zh
|
||||
? '当前交易所类型暂不支持机器人初始化'
|
||||
: 'the selected exchange type is not currently supported for trader initialization'
|
||||
case 'trader.reason.exchange_balance_unavailable':
|
||||
return zh ? '系统暂时无法从交易所读取账户余额' : 'the system could not read the account balance from the exchange'
|
||||
return zh
|
||||
? '系统暂时无法从交易所读取账户余额'
|
||||
: 'the system could not read the account balance from the exchange'
|
||||
case 'trader.reason.exchange_service_unreachable':
|
||||
return zh ? '系统暂时无法连接交易所服务' : 'the system could not reach the exchange service right now'
|
||||
return zh
|
||||
? '系统暂时无法连接交易所服务'
|
||||
: 'the system could not reach the exchange service right now'
|
||||
default:
|
||||
return fallback || (zh ? '系统返回了一个未知错误' : 'an unknown error was returned by the system')
|
||||
return (
|
||||
fallback ||
|
||||
(zh
|
||||
? '系统返回了一个未知错误'
|
||||
: 'an unknown error was returned by the system')
|
||||
)
|
||||
}
|
||||
}
|
||||
const normalizeActionableDescription = (error: unknown, message: string, title: string) => {
|
||||
const normalizeActionableDescription = (
|
||||
error: unknown,
|
||||
message: string,
|
||||
title: string
|
||||
) => {
|
||||
if (error instanceof ApiError && error.errorKey) {
|
||||
return formatActionableDescriptionByKey(error.errorKey, error.errorParams, message)
|
||||
return formatActionableDescriptionByKey(
|
||||
error.errorKey,
|
||||
error.errorParams,
|
||||
message
|
||||
)
|
||||
}
|
||||
|
||||
const prefixes = [
|
||||
@@ -247,12 +327,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
const navigateInApp = (path: string) => {
|
||||
navigate(path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
// Toggle wallet address visibility for a trader
|
||||
const toggleTraderAddressVisibility = (traderId: string) => {
|
||||
setVisibleTraderAddresses(prev => {
|
||||
setVisibleTraderAddresses((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(traderId)) {
|
||||
next.delete(traderId)
|
||||
@@ -265,7 +344,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
// Toggle wallet address visibility for an exchange
|
||||
const toggleExchangeAddressVisibility = (exchangeId: string) => {
|
||||
setVisibleExchangeAddresses(prev => {
|
||||
setVisibleExchangeAddresses((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(exchangeId)) {
|
||||
next.delete(exchangeId)
|
||||
@@ -287,11 +366,13 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
|
||||
user && token ? 'traders' : null,
|
||||
api.getTraders,
|
||||
{ refreshInterval: 5000 }
|
||||
)
|
||||
const {
|
||||
data: traders,
|
||||
mutate: mutateTraders,
|
||||
isLoading: isTradersLoading,
|
||||
} = useSWR<TraderInfo[]>(user && token ? 'traders' : null, api.getTraders, {
|
||||
refreshInterval: 5000,
|
||||
})
|
||||
const {
|
||||
data: exchangeAccountStateData,
|
||||
mutate: mutateExchangeAccountStates,
|
||||
@@ -323,18 +404,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
modelConfigs,
|
||||
exchangeConfigs,
|
||||
models,
|
||||
] = await Promise.all([
|
||||
const [modelConfigs, exchangeConfigs, models] = await Promise.all([
|
||||
api.getModelConfigs(),
|
||||
api.getExchangeConfigs(),
|
||||
api.getSupportedModels(),
|
||||
])
|
||||
setAllModels(modelConfigs)
|
||||
const clawWalletAddress =
|
||||
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
|
||||
modelConfigs.find((model) => model.provider === 'claw402')
|
||||
?.walletAddress || null
|
||||
if (clawWalletAddress) {
|
||||
setBeginnerWalletAddress(clawWalletAddress)
|
||||
persistBeginnerWalletAddress(clawWalletAddress)
|
||||
@@ -365,10 +443,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}) || []
|
||||
|
||||
const enabledModels = allModels?.filter((m) => m.enabled) || []
|
||||
const enabledClaw402Model = enabledModels.find((model) => model.provider === 'claw402') || null
|
||||
const enabledClaw402Balance = parseBalanceUsdc(enabledClaw402Model?.balanceUsdc)
|
||||
const enabledClaw402Model =
|
||||
enabledModels.find((model) => model.provider === 'claw402') || null
|
||||
const enabledClaw402Balance = parseBalanceUsdc(
|
||||
enabledClaw402Model?.balanceUsdc
|
||||
)
|
||||
const claw402BalanceAlert =
|
||||
enabledClaw402Model && enabledClaw402Balance !== null && enabledClaw402Balance < 1
|
||||
enabledClaw402Model &&
|
||||
enabledClaw402Balance !== null &&
|
||||
enabledClaw402Balance < 1
|
||||
? {
|
||||
blocking: enabledClaw402Balance <= 0,
|
||||
title:
|
||||
@@ -379,7 +462,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
: enabledClaw402Balance <= 0
|
||||
? 'Claw402 wallet balance is zero'
|
||||
: 'Claw402 wallet balance is low',
|
||||
description: getClaw402BalanceMessage(enabledClaw402Balance, enabledClaw402Balance <= 0),
|
||||
description: getClaw402BalanceMessage(
|
||||
enabledClaw402Balance,
|
||||
enabledClaw402Balance <= 0
|
||||
),
|
||||
}
|
||||
: null
|
||||
const enabledExchanges =
|
||||
@@ -415,7 +501,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
const getExchangeUsageInfo = (exchangeId: string) => {
|
||||
const usingTraders = traders?.filter((tr) => tr.exchange_id === exchangeId) || []
|
||||
const usingTraders =
|
||||
traders?.filter((tr) => tr.exchange_id === exchangeId) || []
|
||||
const runningCount = usingTraders.filter((tr) => tr.is_running).length
|
||||
const totalCount = usingTraders.length
|
||||
return { runningCount, totalCount, usingTraders }
|
||||
@@ -548,17 +635,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle trader:', error)
|
||||
showActionableError(
|
||||
running ? t('aiTradersToast.stopFailed', language) : t('aiTradersToast.startFailed', language),
|
||||
running
|
||||
? t('aiTradersToast.stopFailed', language)
|
||||
: t('aiTradersToast.startFailed', language),
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
|
||||
const handleToggleCompetition = async (
|
||||
traderId: string,
|
||||
currentShowInCompetition: boolean
|
||||
) => {
|
||||
try {
|
||||
const newValue = !currentShowInCompetition
|
||||
await api.toggleCompetition(traderId, newValue)
|
||||
toast.success(newValue ? t('aiTradersToast.showInCompetition', language) : t('aiTradersToast.hideInCompetition', language))
|
||||
toast.success(
|
||||
newValue
|
||||
? t('aiTradersToast.showInCompetition', language)
|
||||
: t('aiTradersToast.hideInCompetition', language)
|
||||
)
|
||||
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
@@ -695,12 +791,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
allModels?.map((m) =>
|
||||
m.id === modelId
|
||||
? {
|
||||
...m,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}
|
||||
...m,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}
|
||||
: m
|
||||
) || []
|
||||
} else {
|
||||
@@ -816,7 +912,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
await api.updateExchangeConfigsEncrypted(request)
|
||||
toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
|
||||
toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
|
||||
} else {
|
||||
const createRequest = {
|
||||
exchange_type: exchangeType,
|
||||
@@ -837,7 +933,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
await api.createExchangeEncrypted(createRequest)
|
||||
toast.success(t('aiTradersToast.exchangeCreated', language))
|
||||
toast.success(t('aiTradersToast.exchangeCreated', language))
|
||||
}
|
||||
|
||||
const refreshedExchanges = await api.getExchangeConfigs()
|
||||
@@ -888,10 +984,13 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
|
||||
const claw402Configured = configuredModels.some(
|
||||
(model) => model.provider === 'claw402'
|
||||
)
|
||||
const hasStrategies = (strategies?.length || 0) > 0
|
||||
const hasCreatedTrader = (traders?.length || 0) > 0
|
||||
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
|
||||
const canCreateTrader =
|
||||
configuredModels.length > 0 && configuredExchanges.length > 0
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
@@ -952,7 +1051,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
|
||||
disabled={
|
||||
configuredModels.length === 0 ||
|
||||
configuredExchanges.length === 0
|
||||
}
|
||||
className="group relative px-6 py-2 rounded text-xs font-bold font-mono uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap overflow-hidden bg-nofx-gold text-black hover:bg-yellow-400 shadow-[0_0_20px_rgba(240,185,11,0.2)] hover:shadow-[0_0_30px_rgba(240,185,11,0.4)]"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
@@ -984,15 +1086,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
<div
|
||||
className="mb-6 rounded-xl border px-4 py-4 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start md:justify-between gap-3"
|
||||
style={{
|
||||
borderColor: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.55)' : 'rgba(245, 158, 11, 0.45)',
|
||||
background: claw402BalanceAlert.blocking ? 'rgba(127, 29, 29, 0.22)' : 'rgba(120, 53, 15, 0.18)',
|
||||
borderColor: claw402BalanceAlert.blocking
|
||||
? 'rgba(239, 68, 68, 0.55)'
|
||||
: 'rgba(245, 158, 11, 0.45)',
|
||||
background: claw402BalanceAlert.blocking
|
||||
? 'rgba(127, 29, 29, 0.22)'
|
||||
: 'rgba(120, 53, 15, 0.18)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="mt-0.5 rounded-full p-2"
|
||||
style={{
|
||||
background: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.16)' : 'rgba(245, 158, 11, 0.14)',
|
||||
background: claw402BalanceAlert.blocking
|
||||
? 'rgba(239, 68, 68, 0.16)'
|
||||
: 'rgba(245, 158, 11, 0.14)',
|
||||
color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
|
||||
}}
|
||||
>
|
||||
@@ -1001,11 +1109,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
<div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A' }}
|
||||
style={{
|
||||
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
|
||||
}}
|
||||
>
|
||||
{claw402BalanceAlert.title}
|
||||
</div>
|
||||
<div className="text-sm mt-1 leading-6" style={{ color: '#D4D4D8' }}>
|
||||
<div
|
||||
className="text-sm mt-1 leading-6"
|
||||
style={{ color: '#D4D4D8' }}
|
||||
>
|
||||
{claw402BalanceAlert.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1013,10 +1126,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => enabledClaw402Model && handleModelClick(enabledClaw402Model.id)}
|
||||
onClick={() =>
|
||||
enabledClaw402Model && handleModelClick(enabledClaw402Model.id)
|
||||
}
|
||||
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
|
||||
style={{
|
||||
borderColor: claw402BalanceAlert.blocking ? 'rgba(248, 113, 113, 0.45)' : 'rgba(251, 191, 36, 0.35)',
|
||||
borderColor: claw402BalanceAlert.blocking
|
||||
? 'rgba(248, 113, 113, 0.45)'
|
||||
: 'rgba(251, 191, 36, 0.35)',
|
||||
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
|
||||
background: 'rgba(0, 0, 0, 0.18)',
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getSystemConfig, invalidateSystemConfig } from '../lib/config'
|
||||
import { reset401Flag, httpClient } from '../lib/httpClient'
|
||||
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
|
||||
import { ROUTES } from '../router/paths'
|
||||
import { useLanguage } from './LanguageContext'
|
||||
|
||||
interface User {
|
||||
@@ -43,6 +45,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -120,8 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', nextPath)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(nextPath)
|
||||
}
|
||||
|
||||
const login = async (email: string, password: string, mode?: UserMode) => {
|
||||
@@ -145,7 +147,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Unexpected success response
|
||||
return { success: false, message: data.message || 'Unexpected login response' }
|
||||
return {
|
||||
success: false,
|
||||
message: data.message || 'Unexpected login response',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
@@ -184,12 +189,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(returnUrl)
|
||||
} else {
|
||||
// Redirect to dashboard
|
||||
window.history.pushState({}, '', '/dashboard')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(ROUTES.dashboard)
|
||||
}
|
||||
return { success: true }
|
||||
} else {
|
||||
@@ -244,13 +247,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
message: result.message || 'Registration failed',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth register error:', error);
|
||||
console.error('Auth register error:', error)
|
||||
// Re-throw if it's a critical error, or return structured error
|
||||
// Since httpClient throws on 500, we should return a structured error response
|
||||
// to let the UI display it gracefully without crashing.
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Detailed server error'
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Detailed server error',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +280,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return { success: false, message: data.error }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Password reset failed, please try again' }
|
||||
return {
|
||||
success: false,
|
||||
message: 'Password reset failed, please try again',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,8 +302,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
invalidateSystemConfig()
|
||||
window.history.pushState({}, '', '/')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(ROUTES.home)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ArrowRight, Copy, RefreshCw, Shield, Wallet, X } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import type { BeginnerOnboardingResponse } from '../types'
|
||||
import { setBeginnerWalletAddress, markBeginnerOnboardingCompleted } from '../lib/onboarding'
|
||||
import {
|
||||
setBeginnerWalletAddress,
|
||||
markBeginnerOnboardingCompleted,
|
||||
} from '../lib/onboarding'
|
||||
|
||||
export function BeginnerOnboardingPage() {
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -79,8 +77,7 @@ export function BeginnerOnboardingPage() {
|
||||
|
||||
const handleContinue = () => {
|
||||
markBeginnerOnboardingCompleted()
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate('/traders')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -104,7 +101,9 @@ export function BeginnerOnboardingPage() {
|
||||
<div>
|
||||
<div
|
||||
className={`font-semibold uppercase text-nofx-gold/80 ${
|
||||
isZh ? 'text-[11px] tracking-[0.34em]' : 'text-[10px] tracking-[0.2em]'
|
||||
isZh
|
||||
? 'text-[11px] tracking-[0.34em]'
|
||||
: 'text-[10px] tracking-[0.2em]'
|
||||
}`}
|
||||
>
|
||||
{isZh ? '新手保护' : 'Beginner Guard'}
|
||||
@@ -136,7 +135,9 @@ export function BeginnerOnboardingPage() {
|
||||
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,11,16,0.94),rgba(5,7,10,0.88))] shadow-[0_24px_120px_rgba(0,0,0,0.58)] backdrop-blur-2xl">
|
||||
{loading ? (
|
||||
<div className="flex min-h-[390px] items-center justify-center px-6 text-sm text-zinc-400">
|
||||
{isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
|
||||
{isZh
|
||||
? '正在准备你的 Base 钱包...'
|
||||
: 'Preparing your Base wallet...'}
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="grid lg:grid-cols-[0.82fr_1.18fr]">
|
||||
@@ -147,13 +148,17 @@ export function BeginnerOnboardingPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-[15px] font-medium text-zinc-300">
|
||||
{isZh ? '充值地址(Base USDC)' : 'Deposit address (Base USDC)'}
|
||||
{isZh
|
||||
? '充值地址(Base USDC)'
|
||||
: 'Deposit address (Base USDC)'}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-[24px] border border-emerald-400/20 bg-emerald-500/7 px-5 py-3.5 shadow-[0_0_0_1px_rgba(16,185,129,0.08)]">
|
||||
<div className="text-left">
|
||||
<div className="flex items-baseline gap-3 font-mono font-bold tracking-tight text-emerald-300">
|
||||
<span className="text-[22px]">{data.balance_usdc}</span>
|
||||
<span className="text-[22px]">
|
||||
{data.balance_usdc}
|
||||
</span>
|
||||
<span className="text-[20px]">USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,12 +169,16 @@ export function BeginnerOnboardingPage() {
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-emerald-300/20 bg-black/20 text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label={isZh ? '刷新余额' : 'Refresh balance'}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-zinc-500">
|
||||
{isZh ? '$5-$10 可以用很久' : '$5-$10 usually lasts a long time'}
|
||||
{isZh
|
||||
? '$5-$10 可以用很久'
|
||||
: '$5-$10 usually lasts a long time'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -187,7 +196,9 @@ export function BeginnerOnboardingPage() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.address, isZh ? '地址' : 'Address')}
|
||||
onClick={() =>
|
||||
copyText(data.address, isZh ? '地址' : 'Address')
|
||||
}
|
||||
className="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-zinc-300 transition hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
aria-label={isZh ? '复制地址' : 'Copy address'}
|
||||
>
|
||||
@@ -199,16 +210,27 @@ export function BeginnerOnboardingPage() {
|
||||
<div className="pt-1">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-nofx-gold">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>{isZh ? '私钥,请立即备份' : 'Private key, back it up now'}</span>
|
||||
<span>
|
||||
{isZh
|
||||
? '私钥,请立即备份'
|
||||
: 'Private key, back it up now'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-stretch gap-3">
|
||||
<div className="min-w-0 flex-1 rounded-[24px] border border-nofx-gold/20 bg-[linear-gradient(180deg,rgba(32,25,7,0.44),rgba(14,10,3,0.28))] px-5 py-3 font-mono text-[13px] leading-6 text-amber-100 shadow-[0_0_0_1px_rgba(240,185,11,0.05)]">
|
||||
<div className="overflow-x-auto whitespace-nowrap">{data.private_key}</div>
|
||||
<div className="overflow-x-auto whitespace-nowrap">
|
||||
{data.private_key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.private_key, isZh ? '私钥' : 'Private key')}
|
||||
onClick={() =>
|
||||
copyText(
|
||||
data.private_key,
|
||||
isZh ? '私钥' : 'Private key'
|
||||
)
|
||||
}
|
||||
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-nofx-gold/20 bg-nofx-gold/10 text-nofx-gold transition hover:bg-nofx-gold/15"
|
||||
aria-label={isZh ? '复制私钥' : 'Copy private key'}
|
||||
>
|
||||
@@ -220,7 +242,9 @@ export function BeginnerOnboardingPage() {
|
||||
|
||||
<div
|
||||
className={`rounded-[24px] border border-white/15 bg-black/18 px-5 py-3.5 text-zinc-500 ${
|
||||
isZh ? 'text-xs lg:whitespace-nowrap' : 'text-[11px] leading-6'
|
||||
isZh
|
||||
? 'text-xs lg:whitespace-nowrap'
|
||||
: 'text-[11px] leading-6'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2 text-zinc-600">•</span>
|
||||
@@ -246,7 +270,9 @@ export function BeginnerOnboardingPage() {
|
||||
isZh ? 'text-[20px]' : 'text-[16px] sm:text-[18px]'
|
||||
}`}
|
||||
>
|
||||
<span>{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}</span>
|
||||
<span>
|
||||
{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}
|
||||
</span>
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -34,24 +34,8 @@ export function LandingPage() {
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={(page) => {
|
||||
const pathMap: Record<string, string> = {
|
||||
'data': '/data',
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'strategy': '/strategy',
|
||||
'faq': '/faq',
|
||||
}
|
||||
const path = pathMap[page]
|
||||
if (path) {
|
||||
window.location.href = path
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen bg-nofx-bg text-nofx-text font-sans selection:bg-nofx-gold selection:text-black">
|
||||
|
||||
<TerminalHero />
|
||||
|
||||
<LiveFeed />
|
||||
|
||||
+157
-58
@@ -1,6 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react'
|
||||
import {
|
||||
User,
|
||||
Cpu,
|
||||
Building2,
|
||||
MessageCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
@@ -20,8 +31,11 @@ type Tab = 'account' | 'models' | 'exchanges' | 'telegram'
|
||||
export function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('account')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(
|
||||
() => getUserMode() ?? 'advanced'
|
||||
)
|
||||
|
||||
// Account state
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@@ -53,7 +67,8 @@ export function SettingsPage() {
|
||||
.catch(() => toast.error('Failed to load AI models'))
|
||||
}
|
||||
if (activeTab === 'exchanges') {
|
||||
api.getExchangeConfigs()
|
||||
api
|
||||
.getExchangeConfigs()
|
||||
.then(setExchanges)
|
||||
.catch(() => toast.error('Failed to load exchanges'))
|
||||
}
|
||||
@@ -82,7 +97,9 @@ export function SettingsPage() {
|
||||
toast.success('Password updated successfully')
|
||||
setNewPassword('')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update password')
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Failed to update password'
|
||||
)
|
||||
} finally {
|
||||
setChangingPassword(false)
|
||||
}
|
||||
@@ -104,8 +121,7 @@ export function SettingsPage() {
|
||||
)
|
||||
|
||||
const nextPath = getPostAuthPath(nextMode)
|
||||
window.history.pushState({}, '', nextPath)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
navigate(nextPath)
|
||||
}
|
||||
|
||||
const handleSaveModel = async (
|
||||
@@ -118,33 +134,48 @@ export function SettingsPage() {
|
||||
const existingModel = configuredModels.find((m) => m.id === modelId)
|
||||
const modelTemplate = supportedModels.find((m) => m.id === modelId)
|
||||
const modelToUpdate = existingModel || modelTemplate
|
||||
if (!modelToUpdate) { toast.error('Model not found'); return }
|
||||
if (!modelToUpdate) {
|
||||
toast.error('Model not found')
|
||||
return
|
||||
}
|
||||
|
||||
let updatedModels: AIModel[]
|
||||
if (existingModel) {
|
||||
updatedModels = configuredModels.map((m) =>
|
||||
m.id === modelId
|
||||
? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true }
|
||||
? {
|
||||
...m,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}
|
||||
: m
|
||||
)
|
||||
} else {
|
||||
updatedModels = [...configuredModels, {
|
||||
...modelToUpdate,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}]
|
||||
updatedModels = [
|
||||
...configuredModels,
|
||||
{
|
||||
...modelToUpdate,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
updatedModels.map((m) => [m.provider, {
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
}])
|
||||
updatedModels.map((m) => [
|
||||
m.provider,
|
||||
{
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
},
|
||||
])
|
||||
),
|
||||
}
|
||||
await api.updateModelConfigs(request)
|
||||
@@ -161,16 +192,27 @@ export function SettingsPage() {
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
try {
|
||||
const updatedModels = configuredModels.map((m) =>
|
||||
m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m
|
||||
m.id === modelId
|
||||
? {
|
||||
...m,
|
||||
apiKey: '',
|
||||
customApiUrl: '',
|
||||
customModelName: '',
|
||||
enabled: false,
|
||||
}
|
||||
: m
|
||||
)
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
updatedModels.map((m) => [m.provider, {
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
}])
|
||||
updatedModels.map((m) => [
|
||||
m.provider,
|
||||
{
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
},
|
||||
])
|
||||
),
|
||||
}
|
||||
await api.updateModelConfigs(request)
|
||||
@@ -223,7 +265,7 @@ export function SettingsPage() {
|
||||
},
|
||||
}
|
||||
await api.updateExchangeConfigsEncrypted(request)
|
||||
toast.success('Exchange config updated')
|
||||
toast.success('Exchange config updated')
|
||||
} else {
|
||||
const createRequest = {
|
||||
exchange_type: exchangeType,
|
||||
@@ -243,7 +285,7 @@ export function SettingsPage() {
|
||||
lighter_api_key_index: lighterApiKeyIndex || 0,
|
||||
}
|
||||
await api.createExchangeEncrypted(createRequest)
|
||||
toast.success('Exchange account created')
|
||||
toast.success('Exchange account created')
|
||||
}
|
||||
const refreshed = await api.getExchangeConfigs()
|
||||
setExchanges(refreshed)
|
||||
@@ -275,7 +317,10 @@ export function SettingsPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pt-20 pb-12 px-4" style={{ background: '#0B0E11' }}>
|
||||
<div
|
||||
className="min-h-screen pt-20 pb-12 px-4"
|
||||
style={{ background: '#0B0E11' }}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-xl font-bold text-white mb-6">Settings</h1>
|
||||
|
||||
@@ -286,9 +331,10 @@ export function SettingsPage() {
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${activeTab === tab.key
|
||||
? 'bg-nofx-gold text-black'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
${
|
||||
activeTab === tab.key
|
||||
? 'bg-nofx-gold text-black'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -299,7 +345,6 @@ export function SettingsPage() {
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
|
||||
|
||||
{/* Account Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div className="space-y-6">
|
||||
@@ -322,8 +367,12 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
|
||||
{userMode === 'beginner'
|
||||
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner'
|
||||
: language === 'zh' ? '当前:老手模式' : 'Current: Advanced'}
|
||||
? language === 'zh'
|
||||
? '当前:新手模式'
|
||||
: 'Current: Beginner'
|
||||
: language === 'zh'
|
||||
? '当前:老手模式'
|
||||
: 'Current: Advanced'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -369,10 +418,14 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||
<h3 className="text-sm font-semibold text-white mb-4">
|
||||
Change Password
|
||||
</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">New Password</label>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -387,7 +440,11 @@ export function SettingsPage() {
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
{showPassword ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,10 +465,14 @@ export function SettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured
|
||||
{configuredModels.length} model
|
||||
{configuredModels.length !== 1 ? 's' : ''} configured
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setEditingModel(null); setShowModelModal(true) }}
|
||||
onClick={() => {
|
||||
setEditingModel(null)
|
||||
setShowModelModal(true)
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -428,7 +489,10 @@ export function SettingsPage() {
|
||||
{configuredModels.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => { setEditingModel(model.id); setShowModelModal(true) }}
|
||||
onClick={() => {
|
||||
setEditingModel(model.id)
|
||||
setShowModelModal(true)
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -436,15 +500,24 @@ export function SettingsPage() {
|
||||
<Cpu size={14} className="text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{model.name}</p>
|
||||
<p className="text-xs text-zinc-500">{model.provider}</p>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{model.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{model.provider}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}
|
||||
>
|
||||
{model.enabled ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<Pencil size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
<Pencil
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -458,10 +531,14 @@ export function SettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected
|
||||
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '}
|
||||
connected
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setEditingExchange(null); setShowExchangeModal(true) }}
|
||||
onClick={() => {
|
||||
setEditingExchange(null)
|
||||
setShowExchangeModal(true)
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -478,7 +555,10 @@ export function SettingsPage() {
|
||||
{exchanges.map((exchange) => (
|
||||
<button
|
||||
key={exchange.id}
|
||||
onClick={() => { setEditingExchange(exchange.id); setShowExchangeModal(true) }}
|
||||
onClick={() => {
|
||||
setEditingExchange(exchange.id)
|
||||
setShowExchangeModal(true)
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -486,11 +566,18 @@ export function SettingsPage() {
|
||||
<Building2 size={14} className="text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p>
|
||||
<p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{exchange.account_name || exchange.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 capitalize">
|
||||
{exchange.exchange_type || exchange.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -502,7 +589,8 @@ export function SettingsPage() {
|
||||
{activeTab === 'telegram' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Connect a Telegram bot to receive trading notifications and interact with your traders.
|
||||
Connect a Telegram bot to receive trading notifications and
|
||||
interact with your traders.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowTelegramModal(true)}
|
||||
@@ -512,9 +600,14 @@ export function SettingsPage() {
|
||||
<div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center">
|
||||
<MessageCircle size={14} className="text-[#0088cc]" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white">Configure Telegram Bot</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
Configure Telegram Bot
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -530,7 +623,10 @@ export function SettingsPage() {
|
||||
editingModelId={editingModel}
|
||||
onSave={handleSaveModel}
|
||||
onDelete={handleDeleteModel}
|
||||
onClose={() => { setShowModelModal(false); setEditingModel(null) }}
|
||||
onClose={() => {
|
||||
setShowModelModal(false)
|
||||
setEditingModel(null)
|
||||
}}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
@@ -544,7 +640,10 @@ export function SettingsPage() {
|
||||
editingExchangeId={editingExchange}
|
||||
onSave={handleSaveExchange}
|
||||
onDelete={handleDeleteExchange}
|
||||
onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }}
|
||||
onClose={() => {
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
}}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
TrendingUp,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
Activity,
|
||||
Terminal,
|
||||
Cpu,
|
||||
Database
|
||||
Database,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -39,14 +40,24 @@ interface PublicStrategy {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const strategyStyles: Record<string, { color: string; border: string; glow: string; shadow: string; icon: any; bg: string }> = {
|
||||
const strategyStyles: Record<
|
||||
string,
|
||||
{
|
||||
color: string
|
||||
border: string
|
||||
glow: string
|
||||
shadow: string
|
||||
icon: any
|
||||
bg: string
|
||||
}
|
||||
> = {
|
||||
scalper: {
|
||||
color: 'text-[#F0B90B]',
|
||||
border: 'border-[#F0B90B]/30',
|
||||
glow: 'shadow-[0_0_20px_rgba(240,185,11,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(240,185,11,0.25)]',
|
||||
bg: 'bg-[#F0B90B]/5',
|
||||
icon: Zap
|
||||
icon: Zap,
|
||||
},
|
||||
swing: {
|
||||
color: 'text-cyan-400',
|
||||
@@ -54,7 +65,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: 'shadow-[0_0_20px_rgba(34,211,238,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(34,211,238,0.25)]',
|
||||
bg: 'bg-cyan-400/5',
|
||||
icon: TrendingUp
|
||||
icon: TrendingUp,
|
||||
},
|
||||
arbitrage: {
|
||||
color: 'text-purple-400',
|
||||
@@ -62,7 +73,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: 'shadow-[0_0_20px_rgba(192,132,252,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(192,132,252,0.25)]',
|
||||
bg: 'bg-purple-400/5',
|
||||
icon: Layers
|
||||
icon: Layers,
|
||||
},
|
||||
conservative: {
|
||||
color: 'text-emerald-400',
|
||||
@@ -70,7 +81,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: 'shadow-[0_0_20px_rgba(52,211,153,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(52,211,153,0.25)]',
|
||||
bg: 'bg-emerald-400/5',
|
||||
icon: Shield
|
||||
icon: Shield,
|
||||
},
|
||||
aggressive: {
|
||||
color: 'text-red-500',
|
||||
@@ -78,7 +89,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: 'shadow-[0_0_20px_rgba(239,68,68,0.15)]',
|
||||
shadow: 'hover:shadow-[0_0_30px_rgba(239,68,68,0.25)]',
|
||||
bg: 'bg-red-500/5',
|
||||
icon: Target
|
||||
icon: Target,
|
||||
},
|
||||
default: {
|
||||
color: 'text-zinc-400',
|
||||
@@ -86,8 +97,8 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
|
||||
glow: '',
|
||||
shadow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.05)]',
|
||||
bg: 'bg-zinc-800/20',
|
||||
icon: Activity
|
||||
}
|
||||
icon: Activity,
|
||||
},
|
||||
}
|
||||
|
||||
function getStrategyStyle(name: string) {
|
||||
@@ -95,12 +106,15 @@ function getStrategyStyle(name: string) {
|
||||
if (lowerName.includes('scalp')) return strategyStyles.scalper
|
||||
if (lowerName.includes('swing')) return strategyStyles.swing
|
||||
if (lowerName.includes('arb')) return strategyStyles.arbitrage
|
||||
if (lowerName.includes('safe') || lowerName.includes('conserv')) return strategyStyles.conservative
|
||||
if (lowerName.includes('aggress') || lowerName.includes('high')) return strategyStyles.aggressive
|
||||
if (lowerName.includes('safe') || lowerName.includes('conserv'))
|
||||
return strategyStyles.conservative
|
||||
if (lowerName.includes('aggress') || lowerName.includes('high'))
|
||||
return strategyStyles.aggressive
|
||||
return strategyStyles.default
|
||||
}
|
||||
|
||||
export function StrategyMarketPage() {
|
||||
const navigate = useNavigate()
|
||||
const { language } = useLanguage()
|
||||
const { token, user } = useAuth()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -120,23 +134,28 @@ export function StrategyMarketPage() {
|
||||
},
|
||||
{
|
||||
refreshInterval: 60000,
|
||||
revalidateOnFocus: false
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
)
|
||||
|
||||
const filteredStrategies = strategies?.filter(s => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return s.name.toLowerCase().includes(query) ||
|
||||
s.description?.toLowerCase().includes(query)
|
||||
}
|
||||
return true
|
||||
}) || []
|
||||
const filteredStrategies =
|
||||
strategies?.filter((s) => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
s.name.toLowerCase().includes(query) ||
|
||||
s.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}) || []
|
||||
|
||||
const handleCopyConfig = async (strategy: PublicStrategy) => {
|
||||
if (!strategy.config) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(strategy.config, null, 2))
|
||||
await navigator.clipboard.writeText(
|
||||
JSON.stringify(strategy.config, null, 2)
|
||||
)
|
||||
setCopiedId(strategy.id)
|
||||
toast.success(tr('copied'))
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
@@ -147,14 +166,16 @@ export function StrategyMarketPage() {
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).replace(',', '')
|
||||
return date
|
||||
.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.replace(',', '')
|
||||
}
|
||||
|
||||
const getIndicatorList = (config: any) => {
|
||||
@@ -174,15 +195,15 @@ export function StrategyMarketPage() {
|
||||
return (
|
||||
<DeepVoidBackground className="min-h-screen text-white font-mono py-12">
|
||||
<div className="w-full px-4 md:px-8 space-y-8">
|
||||
|
||||
<div className="w-full relative z-10">
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="mb-12 border-b border-zinc-800 pb-8 relative">
|
||||
<div className="absolute top-0 right-0 p-2 border border-zinc-800 rounded bg-black/50 text-xs text-zinc-500 font-mono hidden md:block">
|
||||
SYSTEM_STATUS: <span className="text-emerald-500 animate-pulse">ONLINE</span>
|
||||
SYSTEM_STATUS:{' '}
|
||||
<span className="text-emerald-500 animate-pulse">ONLINE</span>
|
||||
<br />
|
||||
MARKET_UPLINK: <span className="text-emerald-500">ESTABLISHED</span>
|
||||
MARKET_UPLINK:{' '}
|
||||
<span className="text-emerald-500">ESTABLISHED</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
@@ -191,11 +212,15 @@ export function StrategyMarketPage() {
|
||||
<Database className="w-8 h-8 text-nofx-gold relative z-10" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text" data-text={tr('title')}>
|
||||
<h1
|
||||
className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text"
|
||||
data-text={tr('title')}
|
||||
>
|
||||
{tr('title')}
|
||||
</h1>
|
||||
<p className="text-xs text-nofx-gold tracking-[0.3em] font-bold mt-1">
|
||||
// {tr('subtitle')}
|
||||
{'// '}
|
||||
{tr('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,16 +257,21 @@ export function StrategyMarketPage() {
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${selectedCategory === cat
|
||||
? 'text-black font-bold'
|
||||
: 'text-zinc-500 hover:text-white'
|
||||
}`}
|
||||
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${
|
||||
selectedCategory === cat
|
||||
? 'text-black font-bold'
|
||||
: 'text-zinc-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{selectedCategory === cat && (
|
||||
<motion.div
|
||||
layoutId="filter-highlight"
|
||||
className="absolute inset-0 bg-nofx-gold"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{tr(cat)}</span>
|
||||
@@ -260,11 +290,22 @@ export function StrategyMarketPage() {
|
||||
<Cpu size={24} className="text-nofx-gold/50" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">{tr('loading')}</p>
|
||||
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">
|
||||
{tr('loading')}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div>
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0s' }}
|
||||
></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.4s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,7 +320,9 @@ export function StrategyMarketPage() {
|
||||
<h3 className="text-xl font-bold text-zinc-300 font-mono tracking-tight mb-2">
|
||||
[{tr('noStrategies')}]
|
||||
</h3>
|
||||
<p className="text-zinc-600 text-xs tracking-wide uppercase">{tr('noStrategiesDesc')}</p>
|
||||
<p className="text-zinc-600 text-xs tracking-wide uppercase">
|
||||
{tr('noStrategiesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -290,9 +333,10 @@ export function StrategyMarketPage() {
|
||||
{filteredStrategies.map((strategy, i) => {
|
||||
const style = getStrategyStyle(strategy.name)
|
||||
const Icon = style.icon
|
||||
const indicators = strategy.config_visible && strategy.config
|
||||
? getIndicatorList(strategy.config)
|
||||
: []
|
||||
const indicators =
|
||||
strategy.config_visible && strategy.config
|
||||
? getIndicatorList(strategy.config)
|
||||
: []
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -304,16 +348,24 @@ export function StrategyMarketPage() {
|
||||
className={`group relative bg-black border border-zinc-800 hover:border-zinc-600 transition-all duration-300 ${style.shadow}`}
|
||||
>
|
||||
{/* Holographic Border Highlight */}
|
||||
<div className={`absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
<div className={`absolute bottom-0 right-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
<div
|
||||
className={`absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
|
||||
></div>
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
|
||||
></div>
|
||||
|
||||
{/* Category Side Strip */}
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}></div>
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}
|
||||
></div>
|
||||
|
||||
<div className="p-6 relative">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-2 rounded-none border ${style.border} ${style.bg}`}>
|
||||
<div
|
||||
className={`p-2 rounded-none border ${style.border} ${style.bg}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${style.color}`} />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono">
|
||||
@@ -332,7 +384,9 @@ export function StrategyMarketPage() {
|
||||
</div>
|
||||
|
||||
{/* Name and Description */}
|
||||
<h3 className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}>
|
||||
<h3
|
||||
className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}
|
||||
>
|
||||
{strategy.name}
|
||||
<span className="absolute -bottom-1 left-0 w-8 h-[2px] bg-zinc-800 group-hover:bg-nofx-gold transition-colors"></span>
|
||||
</h3>
|
||||
@@ -343,12 +397,22 @@ export function StrategyMarketPage() {
|
||||
{/* Meta Data */}
|
||||
<div className="grid grid-cols-2 gap-y-2 mb-6 text-[10px] font-mono text-zinc-600">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-700 uppercase">{tr('author')}</span>
|
||||
<span className="text-zinc-400 group-hover:text-white transition-colors">@{strategy.author_email?.split('@')[0] || 'UNKNOWN'}</span>
|
||||
<span className="text-zinc-700 uppercase">
|
||||
{tr('author')}
|
||||
</span>
|
||||
<span className="text-zinc-400 group-hover:text-white transition-colors">
|
||||
@
|
||||
{strategy.author_email?.split('@')[0] ||
|
||||
'UNKNOWN'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-right">
|
||||
<span className="text-zinc-700 uppercase">{tr('createdAt')}</span>
|
||||
<span className="text-zinc-400">{formatDate(strategy.created_at)}</span>
|
||||
<span className="text-zinc-700 uppercase">
|
||||
{tr('createdAt')}
|
||||
</span>
|
||||
<span className="text-zinc-400">
|
||||
{formatDate(strategy.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -358,14 +422,20 @@ export function StrategyMarketPage() {
|
||||
<div className="space-y-3">
|
||||
{/* Indicators */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
{indicators.length > 0 ? indicators.map((ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
className="px-1.5 py-0.5 border border-zinc-700 bg-zinc-800 text-[9px] text-zinc-300 font-mono whitespace-nowrap"
|
||||
>
|
||||
{ind}
|
||||
{indicators.length > 0 ? (
|
||||
indicators.map((ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
className="px-1.5 py-0.5 border border-zinc-700 bg-zinc-800 text-[9px] text-zinc-300 font-mono whitespace-nowrap"
|
||||
>
|
||||
{ind}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
NO_INDICATORS
|
||||
</span>
|
||||
)) : <span className="text-[9px] text-zinc-600">NO_INDICATORS</span>}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk Control */}
|
||||
@@ -373,22 +443,38 @@ export function StrategyMarketPage() {
|
||||
<div className="flex justify-between items-center text-[10px]">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-600 scale-90 origin-left">LEV</span>
|
||||
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.btc_eth_max_leverage || '-'}x</span>
|
||||
<span className="text-zinc-600 scale-90 origin-left">
|
||||
LEV
|
||||
</span>
|
||||
<span className="text-zinc-300 font-bold">
|
||||
{strategy.config.risk_control
|
||||
.btc_eth_max_leverage || '-'}
|
||||
x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-600 scale-90 origin-left">POS</span>
|
||||
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.max_positions || '-'}</span>
|
||||
<span className="text-zinc-600 scale-90 origin-left">
|
||||
POS
|
||||
</span>
|
||||
<span className="text-zinc-300 font-bold">
|
||||
{strategy.config.risk_control
|
||||
.max_positions || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Activity size={12} className="text-zinc-700" />
|
||||
<Activity
|
||||
size={12}
|
||||
className="text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-600">
|
||||
<EyeOff size={16} className="mb-1 opacity-50" />
|
||||
<span className="text-[9px] uppercase tracking-widest">{tr('configHiddenDesc')}</span>
|
||||
<span className="text-[9px] uppercase tracking-widest">
|
||||
{tr('configHiddenDesc')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -403,7 +489,9 @@ export function StrategyMarketPage() {
|
||||
{copiedId === strategy.id ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-500">{tr('copied')}</span>
|
||||
<span className="text-emerald-500">
|
||||
{tr('copied')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -413,13 +501,15 @@ export function StrategyMarketPage() {
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button disabled className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-800 bg-black text-zinc-700 cursor-not-allowed flex items-center justify-center gap-2">
|
||||
<button
|
||||
disabled
|
||||
className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-800 bg-black text-zinc-700 cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Shield size={12} />
|
||||
{tr('hideConfig')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
@@ -436,13 +526,23 @@ export function StrategyMarketPage() {
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-16 mb-20 flex justify-center"
|
||||
>
|
||||
<div className="relative group cursor-pointer" onClick={() => window.location.href = '/strategy'}>
|
||||
<div
|
||||
className="relative group cursor-pointer"
|
||||
onClick={() => navigate('/strategy')}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-nofx-gold to-yellow-600 rounded blur opacity-25 group-hover:opacity-75 transition duration-1000 group-hover:duration-200"></div>
|
||||
<div className="relative px-8 py-4 bg-black border border-zinc-800 hover:border-nofx-gold/50 flex items-center gap-4 transition-all">
|
||||
<Hexagon className="text-nofx-gold animate-spin-slow" size={24} />
|
||||
<Hexagon
|
||||
className="text-nofx-gold animate-spin-slow"
|
||||
size={24}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">{tr('shareYours')}</div>
|
||||
<div className="text-[10px] text-zinc-500 font-mono">CONTRIBUTE TO THE GLOBAL DATABASE</div>
|
||||
<div className="text-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">
|
||||
{tr('shareYours')}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500 font-mono">
|
||||
CONTRIBUTE TO THE GLOBAL DATABASE
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[1px] h-8 bg-zinc-800 mx-2"></div>
|
||||
<div className="text-xs font-mono text-zinc-400 group-hover:translate-x-1 transition-transform">
|
||||
@@ -452,7 +552,6 @@ export function StrategyMarketPage() {
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
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={
|
||||
user ? (
|
||||
<Navigate to={ROUTES.welcome} replace />
|
||||
) : 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