From 7ae5bf824724c7fd6c460f171e0b96ba5be15220 Mon Sep 17 00:00:00 2001 From: Lance Date: Fri, 17 Apr 2026 19:13:35 +0800 Subject: [PATCH] release: merge dev into main (2026-04-17) (#1484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(store): prevent deletion of active strategies and update translations (#1461) Co-authored-by: Dean * 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) * refactor: replace window.location with useNavigate for routing in auth components (#1470) Co-authored-by: Dean * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 * 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 * 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 --------- Co-authored-by: deanokk Co-authored-by: Dean Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: root --- .gitignore | 3 + api/handler_ai_model.go | 2 +- api/strategy.go | 15 +- manager/trader_manager.go | 25 + mcp/client.go | 35 +- mcp/payment/claw402.go | 81 ++ mcp/payment/x402.go | 3 +- mcp/provider/gemini.go | 2 +- store/ai_model.go | 37 + trader/auto_trader.go | 21 +- trader/auto_trader_grid.go | 11 + trader/okx/trader.go | 13 +- trader/okx/trader_account.go | 53 +- trader/okx/trader_margin_mode_test.go | 247 ++++++ trader/okx/trader_orders.go | 20 +- wallet/balance_cache.go | 67 ++ wallet/usdc.go | 66 +- web/src/App.tsx | 714 +----------------- web/src/components/auth/LoginPage.tsx | 33 +- .../components/auth/LoginRequiredOverlay.tsx | 53 +- web/src/components/auth/RegisterPage.tsx | 93 ++- .../auth/RegistrationDisabled.test.tsx | 32 +- .../components/auth/RegistrationDisabled.tsx | 5 +- web/src/components/auth/ResetPasswordPage.tsx | 10 +- web/src/components/common/HeaderBar.tsx | 278 +++++-- web/src/components/common/SiteFooter.tsx | 103 +++ .../components/common/WhitelistFullPage.tsx | 26 +- web/src/components/landing/HeroSection.tsx | 35 +- web/src/components/landing/LoginModal.tsx | 5 +- web/src/components/landing/core/AgentGrid.tsx | 287 +++---- web/src/components/trader/AITradersPage.tsx | 287 ++++--- web/src/contexts/AuthContext.tsx | 30 +- web/src/pages/BeginnerOnboardingPage.tsx | 72 +- web/src/pages/LandingPage.tsx | 16 - web/src/pages/SettingsPage.tsx | 215 ++++-- web/src/pages/StrategyMarketPage.tsx | 255 +++++-- web/src/router/AppRoutes.tsx | 541 +++++++++++++ web/src/router/paths.test.ts | 32 + web/src/router/paths.ts | 83 ++ 39 files changed, 2549 insertions(+), 1357 deletions(-) create mode 100644 trader/okx/trader_margin_mode_test.go create mode 100644 wallet/balance_cache.go create mode 100644 web/src/components/common/SiteFooter.tsx create mode 100644 web/src/router/AppRoutes.tsx create mode 100644 web/src/router/paths.test.ts create mode 100644 web/src/router/paths.ts diff --git a/.gitignore b/.gitignore index cae87c9e..89880c2a 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ dmypy.json # Pyre type checker .pyre/ PR_DESCRIPTION.md + +# Go build artifacts +/nofx-server diff --git a/api/handler_ai_model.go b/api/handler_ai_model.go index fcda2098..cba3d01c 100644 --- a/api/handler_ai_model.go +++ b/api/handler_ai_model.go @@ -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"}, diff --git a/api/strategy.go b/api/strategy.go index 1985c384..64105fa5 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -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) +} diff --git a/manager/trader_manager.go b/manager/trader_manager.go index ab4b6583..36b745b8 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -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 +} diff --git a/mcp/client.go b/mcp/client.go index b70ac1cf..6916de3c 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -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, + }) } diff --git a/mcp/payment/claw402.go b/mcp/payment/claw402.go index 3d8381f0..f28ca1ca 100644 --- a/mcp/payment/claw402.go +++ b/mcp/payment/claw402.go @@ -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") diff --git a/mcp/payment/x402.go b/mcp/payment/x402.go index 7ce70fdc..577da51f 100644 --- a/mcp/payment/x402.go +++ b/mcp/payment/x402.go @@ -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)) diff --git a/mcp/provider/gemini.go b/mcp/provider/gemini.go index c90ec3ed..3da0306c 100644 --- a/mcp/provider/gemini.go +++ b/mcp/provider/gemini.go @@ -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() { diff --git a/store/ai_model.go b/store/ai_model.go index e1af9d0f..ef577cc1 100644 --- a/store/ai_model.go +++ b/store/ai_model.go @@ -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, diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 42d6a13f..0a0a6786 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -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) diff --git a/trader/auto_trader_grid.go b/trader/auto_trader_grid.go index 6151dbbb..c16a5d8d 100644 --- a/trader/auto_trader_grid.go +++ b/trader/auto_trader_grid.go @@ -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) diff --git a/trader/okx/trader.go b/trader/okx/trader.go index b46f23c4..41e4a45f 100644 --- a/trader/okx/trader.go +++ b/trader/okx/trader.go @@ -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) diff --git a/trader/okx/trader_account.go b/trader/okx/trader_account.go index e10fadf0..9220b3d8 100644 --- a/trader/okx/trader_account.go +++ b/trader/okx/trader_account.go @@ -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 } diff --git a/trader/okx/trader_margin_mode_test.go b/trader/okx/trader_margin_mode_test.go new file mode 100644 index 00000000..e4bbb694 --- /dev/null +++ b/trader/okx/trader_margin_mode_test.go @@ -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"]) + } +} diff --git a/trader/okx/trader_orders.go b/trader/okx/trader_orders.go index 33697acc..2676db3f 100644 --- a/trader/okx/trader_orders.go +++ b/trader/okx/trader_orders.go @@ -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", diff --git a/wallet/balance_cache.go b/wallet/balance_cache.go new file mode 100644 index 00000000..54e1a9b5 --- /dev/null +++ b/wallet/balance_cache.go @@ -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) +} diff --git a/wallet/usdc.go b/wallet/usdc.go index 41952724..c40f696b 100644 --- a/wallet/usdc.go +++ b/wallet/usdc.go @@ -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 } diff --git a/web/src/App.tsx b/web/src/App.tsx index c547726d..538c7d30 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = { - '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(getInitialPage()) - // 从 URL 参数读取初始 trader 标识(格式: name-id前4位) - const [selectedTraderSlug, setSelectedTraderSlug] = useState(() => { - const params = new URLSearchParams(window.location.search) - return params.get('trader') || undefined - }) - const [selectedTraderId, setSelectedTraderId] = useState() - - // 生成 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('--:--:--') - const [decisionsLimit, setDecisionsLimit] = useState(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( - user && token ? 'traders' : null, - () => api.getTraders(currentPage === 'trader'), - { - refreshInterval: 10000, - shouldRetryOnError: false, // 避免在后端未运行时无限重试 - } - ) - - // 获取exchanges列表(用于显示交易所名称) - const { data: exchanges } = useSWR( - 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( - currentPage === 'trader' && selectedTraderId - ? `status-${selectedTraderId}` - : null, - () => api.getStatus(selectedTraderId, true), - { - refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存) - revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求 - dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求 - } - ) - - const { data: account } = useSWR( - 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( - 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( - 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( - 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 ( -
-
- NoFx Logo -

{t('loading', language)}

-
-
- ) - } - - // First-time setup: redirect to /setup if system not initialized - if (systemConfig && !systemConfig.initialized && !user) { - return - } - - // Handle specific routes regardless of authentication - if (route === '/login') { - return - } - if (route === '/setup') { - // If already initialized, redirect to login - if (systemConfig?.initialized) { - window.location.href = '/login' - return null - } - return - } - 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 ( -
- - - setLoginOverlayOpen(false)} - featureName={loginOverlayFeature} - /> -
- ) - } - if (route === '/reset-password') { - return - } - if (route === '/settings') { - if ((!user || !token) && !hasPersistedAuth) { - window.location.href = '/login' - return null - } - return ( -
- - -
- ) - } - // Data page - publicly accessible with embedded dashboard - if (route === '/data') { - const dataPageNavigate = (page: Page) => { - navigateToPage(page) - } - return ( -
- -
- -
- setLoginOverlayOpen(false)} - featureName={loginOverlayFeature} - /> -
- ) - } - // Show landing page for root route - if (route === '/' || route === '') { - return - } - - // Redirect unauthenticated users to landing page - if (!user || !token) { - return - } - - return ( -
- - - {/* Main Content with Page Transitions */} -
- - - {currentPage === 'competition' ? ( - - ) : currentPage === 'data' ? ( - - ) : currentPage === 'strategy-market' ? ( - - ) : currentPage === 'traders' ? ( - { - 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' ? ( - - ) : ( - { - 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} - /> - )} - - -
- - {/* Footer */} - - - {/* Login Required Overlay */} - setLoginOverlayOpen(false)} - featureName={loginOverlayFeature} - /> - - {showBeginnerOnboarding && } -
- ) -} - - -// Wrap App with providers -export default function AppWithProviders() { +export default function App() { return ( - + diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index b9ff9097..f5a37689 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -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(null) + const [expiredToastId, setExpiredToastId] = useState( + null + ) const [mode, setMode] = useState('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() {
- {/* Logo + Title */}
- NOFX + NOFX
-

Welcome back

+

+ Welcome back +

Sign in to your account

{/* Card */}
- {/* Email */}
@@ -178,7 +192,6 @@ export function LoginPage() {
-
diff --git a/web/src/components/auth/LoginRequiredOverlay.tsx b/web/src/components/auth/LoginRequiredOverlay.tsx index 46f51fd7..c55179b6 100644 --- a/web/src/components/auth/LoginRequiredOverlay.tsx +++ b/web/src/components/auth/LoginRequiredOverlay.tsx @@ -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) => @@ -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 ( @@ -40,7 +41,6 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ disableAnimation onClick={onClose} > -
- auth_protocol.exe + + auth_protocol.exe +
- {tr('accessDenied')} + + {tr('accessDenied')} +
@@ -83,8 +87,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ {/* Terminal Text */}
-

{tr('title')}

-

{subtitle}

+

+ {tr('title')} +

+

+ {subtitle} +

@@ -96,7 +104,10 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
{benefits.map((benefit, i) => ( -
+
{benefit}
))} @@ -105,22 +116,24 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ {/* Action Buttons */}
@@ -131,14 +144,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ [ {tr('abort')} ]
-
{/* Corner Accents */}
- diff --git a/web/src/components/auth/RegisterPage.tsx b/web/src/components/auth/RegisterPage.tsx index e03af180..7ea20a5c 100644 --- a/web/src/components/auth/RegisterPage.tsx +++ b/web/src/components/auth/RegisterPage.tsx @@ -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 ( - +
@@ -122,7 +136,11 @@ export function RegisterPage() {
- NoFx Logo + NoFx Logo

@@ -140,7 +158,7 @@ export function RegisterPage() {
(window.location.href = '/')} + onClick={() => navigate('/')} title="Close / Return Home" >
@@ -155,7 +173,9 @@ export function RegisterPage() {
- System Check: READY + + System Check: READY +
@@ -165,7 +185,9 @@ export function RegisterPage() {
- +
- +
- +
@@ -227,7 +259,14 @@ export function RegisterPage() {
- + 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} /> -

* CASE SENSITIVE ALPHANUMERIC

+

+ * CASE SENSITIVE ALPHANUMERIC +

)} @@ -270,7 +317,9 @@ export function RegisterPage() { @@ -295,14 +346,14 @@ export function RegisterPage() {

EXISTING_OPERATOR?{' '}

@@ -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" > - + @@ -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" > - + @@ -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" > - + @@ -227,7 +282,7 @@ export default function HeaderBar({
{onLogout && (
) )} @@ -361,17 +425,67 @@ export default function HeaderBar({ {/* Navigation Links */}
{(() => { - 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 && ( {[ - { href: OFFICIAL_LINKS.github, icon: }, - { href: OFFICIAL_LINKS.twitter, icon: }, - { href: OFFICIAL_LINKS.telegram, icon: } + { + href: OFFICIAL_LINKS.github, + icon: ( + + ), + }, + { + href: OFFICIAL_LINKS.twitter, + icon: ( + + ), + }, + { + href: OFFICIAL_LINKS.telegram, + icon: ( + + ), + }, ].map((link, i) => ( - + {link.icon} @@ -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'} @@ -489,13 +624,18 @@ export default function HeaderBar({ {t('exitLogin', language)} ) : ( - currentPage !== 'login' && currentPage !== 'register' && ( - { + 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)} - + ) )}
diff --git a/web/src/components/common/SiteFooter.tsx b/web/src/components/common/SiteFooter.tsx new file mode 100644 index 00000000..6ee10322 --- /dev/null +++ b/web/src/components/common/SiteFooter.tsx @@ -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 ( + + ) +} diff --git a/web/src/components/common/WhitelistFullPage.tsx b/web/src/components/common/WhitelistFullPage.tsx index d18593fa..a0bd8eaa 100644 --- a/web/src/components/common/WhitelistFullPage.tsx +++ b/web/src/components/common/WhitelistFullPage.tsx @@ -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" >
- {/* Top Bar */}
@@ -60,9 +62,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) { {/* Description */}

- [SYSTEM_MESSAGE]: YOUR IDENTIFIER IS NOT ON THE ACTIVE WHITELIST. -

- Platform capacity limits have been reached for the current beta phase. Prioritized access is currently reserved for authorized operators only. + [SYSTEM_MESSAGE]: YOUR + IDENTIFIER IS NOT ON THE ACTIVE WHITELIST. +
+
+ Platform capacity limits have been reached for the current beta + phase. Prioritized access is currently reserved for authorized + operators only.

{/* Info Box */} @@ -70,9 +76,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
-

Authorization Protocol

+

+ Authorization Protocol +

- 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.

@@ -109,14 +119,12 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
-
{/* Footer */}
ERR_CODE: WLIST_0x403 // SECURITY_LAYER_ACTIVE
-
diff --git a/web/src/components/landing/HeroSection.tsx b/web/src/components/landing/HeroSection.tsx index a8fca091..74581576 100644 --- a/web/src/components/landing/HeroSection.tsx +++ b/web/src/components/landing/HeroSection.tsx @@ -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) {
{/* 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" > - - - {t('liveCompetition', language) || 'Live Competition'} - - + + + {t('liveCompetition', language) || 'Live Competition'} + + + {[ { 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) => ( void @@ -7,6 +8,7 @@ interface LoginModalProps { } export default function LoginModal({ onClose, language }: LoginModalProps) { + const navigate = useNavigate() return ( { - 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" diff --git a/web/src/components/landing/core/AgentGrid.tsx b/web/src/components/landing/core/AgentGrid.tsx index 668be660..10f62c03 100644 --- a/web/src/components/landing/core/AgentGrid.tsx +++ b/web/src/components/landing/core/AgentGrid.tsx @@ -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 ( -
+ return ( +
+ {/* Background Details */} +
+ +
- {/* Background Details */} -
- +
+
+
+
+ MARKET SELECT
+

+ STRATEGY{' '} + + UNITS + +

+
+
+ SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE + PRE-TRAINED ON HISTORICAL TICKS. +
+
-
+ {/* Grid Container - Removing scroll tracking for stability test */} +
+ {agents.map((agent, i) => { + const Icon = agent.icon -
-
-
- MARKET SELECT -
-

- STRATEGY UNITS -

+ return ( + + {/* Top "Hinge" decoration */} +
+ +
+ {/* Header */} +
+
+
-
- SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE PRE-TRAINED ON HISTORICAL TICKS. +
+
+ Class +
+
+ {agent.class} +
+
+ + {/* Name & Desc */} +

+ {agent.name} +

+

+ {agent.desc} +

+ + {/* Stats Grid */} +
+
+
+ APY +
+
+ {agent.apy} +
+
+
+
+ Win % +
+
+ {agent.winRate} +
+
+
+
+ Risk +
+
+ {agent.risk} +
+
+
+ + {/* Action Btn */} +
- {/* Grid Container - Removing scroll tracking for stability test */} -
- {agents.map((agent, i) => { - const Icon = agent.icon - - return ( - - {/* Top "Hinge" decoration */} -
- -
- {/* Header */} -
-
- -
-
-
Class
-
{agent.class}
-
-
- - {/* Name & Desc */} -

{agent.name}

-

{agent.desc}

- - {/* Stats Grid */} -
-
-
APY
-
{agent.apy}
-
-
-
Win %
-
{agent.winRate}
-
-
-
Risk
-
{agent.risk}
-
-
- - {/* Action Btn */} - -
- - {/* Decorative Background Elements */} -
-
- -
- ) - })} -
-
-
- ) + {/* Decorative Background Elements */} +
+
+ + ) + })} +
+
+ + ) } diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index 7160ccf8..cd0bc908 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -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([]) const [allExchanges, setAllExchanges] = useState([]) const [supportedModels, setSupportedModels] = useState([]) - const [visibleTraderAddresses, setVisibleTraderAddresses] = useState>(new Set()) - const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState>(new Set()) + const [visibleTraderAddresses, setVisibleTraderAddresses] = useState< + Set + >(new Set()) + const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState< + Set + >(new Set()) const [copiedId, setCopiedId] = useState(null) const [quickSetupLoading, setQuickSetupLoading] = useState(false) - const [beginnerWalletAddress, setBeginnerWalletAddress] = useState(() => 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( - user && token ? 'traders' : null, - api.getTraders, - { refreshInterval: 5000 } - ) + const { + data: traders, + mutate: mutateTraders, + isLoading: isTradersLoading, + } = useSWR(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 ( @@ -952,7 +1051,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {

- {isZh ? '$5-$10 可以用很久' : '$5-$10 usually lasts a long time'} + {isZh + ? '$5-$10 可以用很久' + : '$5-$10 usually lasts a long time'}
@@ -187,7 +196,9 @@ export function BeginnerOnboardingPage() {
diff --git a/web/src/pages/LandingPage.tsx b/web/src/pages/LandingPage.tsx index 9e36e26c..495921c2 100644 --- a/web/src/pages/LandingPage.tsx +++ b/web/src/pages/LandingPage.tsx @@ -34,24 +34,8 @@ export function LandingPage() { user={user} onLogout={logout} onLoginRequired={handleLoginRequired} - onPageChange={(page) => { - const pathMap: Record = { - '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 - } - }} />
- diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 78c50808..47aad93a 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -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('account') - const [userMode, setUserModeState] = useState(() => getUserMode() ?? 'advanced') + const [userMode, setUserModeState] = useState( + () => 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 ( -
+

Settings

@@ -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 */}
- {/* Account Tab */} {activeTab === 'account' && (
@@ -322,8 +367,12 @@ export function SettingsPage() {
{userMode === 'beginner' - ? language === 'zh' ? '当前:新手模式' : 'Current: Beginner' - : language === 'zh' ? '当前:老手模式' : 'Current: Advanced'} + ? language === 'zh' + ? '当前:新手模式' + : 'Current: Beginner' + : language === 'zh' + ? '当前:老手模式' + : 'Current: Advanced'}
@@ -369,10 +418,14 @@ export function SettingsPage() {
-

Change Password

+

+ Change Password +

- +
setShowPassword(!showPassword)} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors" > - {showPassword ? : } + {showPassword ? ( + + ) : ( + + )}
@@ -408,10 +465,14 @@ export function SettingsPage() {

- {configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured + {configuredModels.length} model + {configuredModels.length !== 1 ? 's' : ''} configured

- + {model.enabled ? 'Active' : 'Inactive'} - +
))} @@ -458,10 +531,14 @@ export function SettingsPage() {

- {exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected + {exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '} + connected

- + ))}
@@ -502,7 +589,8 @@ export function SettingsPage() { {activeTab === 'telegram' && (

- 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.

- +
)} @@ -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} />
@@ -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} />
diff --git a/web/src/pages/StrategyMarketPage.tsx b/web/src/pages/StrategyMarketPage.tsx index dbf64c4f..cf49a477 100644 --- a/web/src/pages/StrategyMarketPage.tsx +++ b/web/src/pages/StrategyMarketPage.tsx @@ -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 = { +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 { - 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 (
-
- {/* Header Section */}
- SYSTEM_STATUS: ONLINE + SYSTEM_STATUS:{' '} + ONLINE
- MARKET_UPLINK: ESTABLISHED + MARKET_UPLINK:{' '} + ESTABLISHED
@@ -191,11 +212,15 @@ export function StrategyMarketPage() {
-

+

{tr('title')}

- // {tr('subtitle')} + {'// '} + {tr('subtitle')}

@@ -232,16 +257,21 @@ export function StrategyMarketPage() {
-

{tr('loading')}

+

+ {tr('loading')} +

-
-
-
+
+
+
)} @@ -279,7 +320,9 @@ export function StrategyMarketPage() {

[{tr('noStrategies')}]

-

{tr('noStrategiesDesc')}

+

+ {tr('noStrategiesDesc')} +

)} @@ -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 ( {/* Holographic Border Highlight */} -
-
+
+
{/* Category Side Strip */} -
+
{/* Header */}
-
+
@@ -332,7 +384,9 @@ export function StrategyMarketPage() {
{/* Name and Description */} -

+

{strategy.name}

@@ -343,12 +397,22 @@ export function StrategyMarketPage() { {/* Meta Data */}
- {tr('author')} - @{strategy.author_email?.split('@')[0] || 'UNKNOWN'} + + {tr('author')} + + + @ + {strategy.author_email?.split('@')[0] || + 'UNKNOWN'} +
- {tr('createdAt')} - {formatDate(strategy.created_at)} + + {tr('createdAt')} + + + {formatDate(strategy.created_at)} +
@@ -358,14 +422,20 @@ export function StrategyMarketPage() {
{/* Indicators */}
- {indicators.length > 0 ? indicators.map((ind) => ( - - {ind} + {indicators.length > 0 ? ( + indicators.map((ind) => ( + + {ind} + + )) + ) : ( + + NO_INDICATORS - )) : NO_INDICATORS} + )}
{/* Risk Control */} @@ -373,22 +443,38 @@ export function StrategyMarketPage() {
- LEV - {strategy.config.risk_control.btc_eth_max_leverage || '-'}x + + LEV + + + {strategy.config.risk_control + .btc_eth_max_leverage || '-'} + x +
- POS - {strategy.config.risk_control.max_positions || '-'} + + POS + + + {strategy.config.risk_control + .max_positions || '-'} +
- +
)}
) : (
- {tr('configHiddenDesc')} + + {tr('configHiddenDesc')} +
)}
@@ -403,7 +489,9 @@ export function StrategyMarketPage() { {copiedId === strategy.id ? ( <> - {tr('copied')} + + {tr('copied')} + ) : ( <> @@ -413,13 +501,15 @@ export function StrategyMarketPage() { )} ) : ( - )}
-
) @@ -436,13 +526,23 @@ export function StrategyMarketPage() { transition={{ delay: 0.3 }} className="mt-16 mb-20 flex justify-center" > -
window.location.href = '/strategy'}> +
navigate('/strategy')} + >
- +
-
{tr('shareYours')}
-
CONTRIBUTE TO THE GLOBAL DATABASE
+
+ {tr('shareYours')} +
+
+ CONTRIBUTE TO THE GLOBAL DATABASE +
@@ -452,7 +552,6 @@ export function StrategyMarketPage() {
)} -
diff --git a/web/src/router/AppRoutes.tsx b/web/src/router/AppRoutes.tsx new file mode 100644 index 00000000..7ee5d2e8 --- /dev/null +++ b/web/src/router/AppRoutes.tsx @@ -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 ( +
+
+ NoFx Logo +

{t('loading', language)}

+
+
+ ) +} + +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 ? ( + + + {children} + + + ) : ( + children + ) + + return ( +
+ + + {wrapInMain ? ( +
{content}
+ ) : ( + content + )} + + {showFooter ? : null} + + setLoginOverlayOpen(false)} + featureName={loginOverlayFeature} + /> + + {extraContent} +
+ ) +} + +function TradersRoute({ + showBeginnerOnboarding = false, +}: { + showBeginnerOnboarding?: boolean +}) { + const navigate = useNavigate() + const { user, token } = useAuth() + const { data: traders } = useSWR( + user && token ? 'traders-route' : null, + api.getTraders, + { + refreshInterval: 5000, + shouldRetryOnError: false, + } + ) + + return ( + : null} + > + { + const trader = traders?.find((item) => item.trader_id === traderId) + navigate( + buildDashboardPath(trader ? getTraderSlug(trader) : undefined) + ) + }} + /> + + ) +} + +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() + const [lastUpdate, setLastUpdate] = useState('--:--:--') + 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( + user && token ? 'traders-dashboard' : null, + () => api.getTraders(true), + { + refreshInterval: 10000, + shouldRetryOnError: false, + } + ) + + const { data: exchanges } = useSWR( + 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( + selectedTraderId ? `status-${selectedTraderId}` : null, + () => api.getStatus(selectedTraderId, true), + { + refreshInterval: 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + } + ) + + const { data: account } = useSWR( + 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( + 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( + 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( + 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 ( + + { + 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} + /> + + ) +} + +export function AppRoutes() { + const { user, token, isLoading } = useAuth() + const { config: systemConfig, loading: configLoading } = useSystemConfig() + const isAuthenticated = !!user && !!token + + if (isLoading || configLoading) { + return + } + + if (systemConfig && !systemConfig.initialized && !user) { + return + } + + return ( + <> + + + } /> + } /> + } /> + } /> + + ) : systemConfig?.initialized ? ( + + ) : ( + + ) + } + /> + + + + } + /> + + + + } + /> + + + + ) : ( + + ) + } + /> + + ) : ( + + ) + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + : } + /> + : } + /> + + + + ) : ( + + ) + } + /> + } /> + + + ) +} diff --git a/web/src/router/paths.test.ts b/web/src/router/paths.test.ts new file mode 100644 index 00000000..d78eba87 --- /dev/null +++ b/web/src/router/paths.test.ts @@ -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) + }) +}) diff --git a/web/src/router/paths.ts b/web/src/router/paths.ts new file mode 100644 index 00000000..b12b55b5 --- /dev/null +++ b/web/src/router/paths.ts @@ -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 = { + 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 = { + 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)}` +}