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)}` +}