release: merge dev into main (2026-04-17) (#1484)

* feat(store): prevent deletion of active strategies and update translations (#1461)

Co-authored-by: Dean <afei.wuhao@gmail.com>

* fix: allow model switching without re-entering wallet key

Users with existing wallets could not switch AI models because the
"Start Trading" button required a valid private key even when one was
already configured. Now the button is enabled when hasExistingWallet
is true, and handleSubmit passes an empty key so the backend preserves
the existing key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace window.location with useNavigate for routing in auth components (#1470)

Co-authored-by: Dean <afei.wuhao@gmail.com>

* feat(trader): implement margin mode handling for order and leverage settings

* refactor(trader): update SetMarginMode to avoid legacy endpoint and improve logging

* feat(api): enhance strategy handling by integrating claw402 wallet key validation

Added validation for the claw402 model's wallet key during strategy test runs. If the selected AI model is claw402, the server now checks for a valid wallet key and returns appropriate error messages if it's missing or if the model fails to load. This ensures better error handling and user feedback when working with AI models.

* refactor(api): streamline claw402 wallet key retrieval and error handling

Refactored the strategy handling logic to encapsulate claw402 wallet key retrieval in a new method, `resolveStrategyDataWalletKey`. This improves code readability and maintains consistent error handling for missing or invalid wallet keys during strategy test runs. The changes enhance the overall robustness of the AI model integration.

* feat(trader): add claw402 wallet key resolution for trader configuration

Implemented a new method, `resolveTraderDataWalletKey`, to retrieve the claw402 wallet key based on the selected AI model and user ID. This enhancement allows for better integration of the claw402 model within the trader configuration, ensuring that the correct wallet key is used for trading operations. The `AutoTraderConfig` struct has been updated to include the new `Claw402WalletKey` field, improving the overall handling of wallet keys in the trading process.

* feat(claw402): preflight USDC balance before AI calls (#1479)

* chore: ignore nofx-server build artifact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(claw402): preflight USDC balance before AI calls

Short-circuit claw402 Call/CallWithRequestFull when the wallet balance
can't cover the estimated cost of the call, surfacing ErrInsufficientFunds
instead of letting x402 fail mid-flight after the sign step.

- wallet: cached balance lookup (30s TTL, per-address mutex) to avoid
  hammering the Base RPC; separate error-returning and display-only APIs
  so callers can distinguish zero balance from an unreachable RPC.
- claw402: 1.5× safety multiplier on the flat per-call estimate, 4.0×
  for reasoner models whose chain-of-thought cost can blow past the
  flat rate. Fail-open on RPC errors — x402 still gates actually-empty
  wallets, and we prefer availability over extra strictness.
- shortAddr redacts the wallet in error strings to avoid leaking the
  full address into telemetry bundles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(telemetry): report token usage for SSE streaming paths (#1475)

* fix(telemetry): report token usage for SSE streaming paths

ParseSSEStream already parsed the usage block from SSE chunks but only
printed it, so claw402 streaming calls (and native streaming) never
fired TokenUsageCallback. GA4 therefore undercounted AI usage on the
streaming path.

Return the parsed usage from ParseSSEStream and have both callers fire
the callback with their own Provider/Model.

* chore: drop leftover debug Printf in ParseSSEStream

Telemetry is now wired via TokenUsageCallback, so the Printf is
redundant noise in the stream path.

* fix(gemini): update default model to gemini-3.1-pro

Google discontinued gemini-3-pro-preview on 2026-03-26 and directs all
callers to gemini-3.1-pro / gemini-3.1-pro-preview. Users on their own
API key were getting errors from the native Gemini endpoint because the
provider default pointed at the retired ID. Claw402 was unaffected
because its route map already used gemini-3.1-pro.

Align both the native provider default and the handler's preset list
with gemini-3.1-pro so every code path sends a live model ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract ResolveClaw402WalletKey to store layer and expand OKX margin mode tests

- Move duplicated claw402 wallet resolution logic into store.AIModelStore.ResolveClaw402WalletKey
- api/strategy.go and manager/trader_manager.go now delegate to the shared method
- Add detailed doc comment on OKX SetMarginMode explaining the local-state-only approach
  and why the legacy /api/v5/account/set-isolated-mode endpoint is not called
- Add 3 new test cases: cross mode leverage, OpenShort tdMode, SetTakeProfit tdMode

* fix(auth): prevent SetupPage remount from wiping freshly-set auth token (#1481)

After #1470 moved routing into react-router, SetupPage is rendered at two
different tree positions (top-level guard + /setup Route). When register
success flushSync-sets `user`, the top-level guard stops matching and the
Route-level SetupPage mounts as a new instance, re-running its cleanup
useEffect and removing the auth_token that handlePostAuthSuccess just wrote.
Subsequent requests 401 and bounce the user back to /login.

Redirect /setup to /welcome when user is already set so SetupPage is never
re-mounted during the auth transition.

* fix(wallet): handle JSON-RPC null error field in balance query

Some RPC implementations return explicit "error": null on success.
json.RawMessage deserializes this as the 4-byte literal "null", so
len() > 0 was true, causing every balance query to fail with
"rpc error: null". Skip the null literal to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: deanokk <wuhao@vergex.trade>
Co-authored-by: Dean <afei.wuhao@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: root <root@localhost.localdomain>
This commit is contained in:
Lance
2026-04-17 19:13:35 +08:00
committed by GitHub
parent c93ee337a7
commit 7ae5bf8247
39 changed files with 2549 additions and 1357 deletions
+3
View File
@@ -126,3 +126,6 @@ dmypy.json
# Pyre type checker
.pyre/
PR_DESCRIPTION.md
# Go build artifacts
/nofx-server
+1 -1
View File
@@ -214,7 +214,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3.1-pro"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
+14 -1
View File
@@ -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)
}
+25
View File
@@ -703,6 +703,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.CustomAPIKey = string(aiModelCfg.APIKey)
}
traderConfig.Claw402WalletKey = resolveTraderDataWalletKey(st, traderCfg.UserID, aiModelCfg)
// Create trader instance
at, err := trader.NewAutoTrader(traderConfig, st, traderCfg.UserID)
if err != nil {
@@ -741,3 +743,26 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
return nil
}
func resolveTraderDataWalletKey(st *store.Store, userID string, selectedModel *store.AIModel) string {
// Fast path: selected model is itself a claw402 model.
if selectedModel != nil && selectedModel.Provider == "claw402" {
if walletKey := string(selectedModel.APIKey); walletKey != "" {
return walletKey
}
}
if st == nil {
return ""
}
// Fallback: find any configured claw402 model for this user so that paid
// NofxAI data sources work even when a non-claw402 model (e.g. deepseek) is
// selected as the AI brain.
preferredID := ""
walletKey, err := st.AIModel().ResolveClaw402WalletKey(userID, preferredID)
if err != nil {
logger.Warnf("⚠️ Failed to load claw402 wallet for trader data routing: %v", err)
return ""
}
return walletKey
}
+28 -7
View File
@@ -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,
})
}
+81
View File
@@ -2,6 +2,7 @@ package payment
import (
"crypto/ecdsa"
"fmt"
"net/http"
"strings"
@@ -9,8 +10,44 @@ import (
"nofx/mcp"
"nofx/mcp/provider"
"nofx/store"
"nofx/wallet"
)
// Per-call cost buffers for preflight. Reasoner models emit long chain-of-thought
// tokens whose cost can far exceed the flat per-call estimate in store.GetModelPrice,
// so they use a larger multiplier.
const (
preflightSafetyMultiplier = 1.5
preflightReasonerSafetyMultiplier = 4.0
)
// ErrInsufficientFunds is returned when the claw402 wallet does not hold
// enough USDC to cover the estimated cost of a call. Callers can type-assert
// to surface balance/needed/address to the UI.
type ErrInsufficientFunds struct {
Address string
Balance float64
Needed float64
Model string
}
func (e *ErrInsufficientFunds) Error() string {
return fmt.Sprintf(
"claw402 insufficient USDC: wallet=%s balance=$%.4f needed=$%.4f model=%s",
shortAddr(e.Address), e.Balance, e.Needed, e.Model,
)
}
// shortAddr renders 0x1234…abcd for log/error strings that may leak into
// telemetry bundles. The full address stays on the struct for programmatic use.
func shortAddr(addr string) string {
if len(addr) < 10 {
return addr
}
return addr[:6] + "…" + addr[len(addr)-4:]
}
const (
DefaultClaw402URL = "https://claw402.ai"
DefaultClaw402Model = "glm-5"
@@ -128,13 +165,57 @@ func (c *Claw402Client) resolveEndpoint() string {
func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
func (c *Claw402Client) Call(systemPrompt, userPrompt string) (string, error) {
if err := c.preflightBalance(); err != nil {
return "", err
}
return X402CallStream(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt, nil)
}
func (c *Claw402Client) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
if err := c.preflightBalance(); err != nil {
return nil, err
}
return X402CallFull(c.Client, c.signPayment, "Claw402", req)
}
// walletAddress derives the EVM address from the configured private key.
// Returns "" when no key has been set (client unconfigured).
func (c *Claw402Client) walletAddress() string {
if c.privateKey == nil {
return ""
}
return crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
}
// preflightBalance short-circuits a call when the wallet cannot cover the
// estimated cost. RPC failures fall through — x402 will still reject an
// actually-empty wallet, so we prefer availability over extra strictness.
func (c *Claw402Client) preflightBalance() error {
addr := c.walletAddress()
if addr == "" {
return nil
}
balance, err := wallet.QueryUSDCBalanceCached(addr)
if err != nil {
c.Log.Warnf("⚠️ [MCP] Claw402 balance preflight skipped (RPC error): %v", err)
return nil
}
multiplier := preflightSafetyMultiplier
if strings.Contains(strings.ToLower(c.Model), "reasoner") {
multiplier = preflightReasonerSafetyMultiplier
}
needed := store.GetModelPrice(c.Model) * multiplier
if balance < needed {
return &ErrInsufficientFunds{
Address: addr,
Balance: balance,
Needed: needed,
Model: c.Model,
}
}
return nil
}
// signPayment signs x402 v2 EIP-712 payment on Base chain + USDC.
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
+2 -1
View File
@@ -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))
+1 -1
View File
@@ -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() {
+37
View File
@@ -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,
+5 -4
View File
@@ -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"
)
@@ -93,6 +93,7 @@ type AutoTraderConfig struct {
CustomAPIURL string
CustomAPIKey string
CustomModelName string
Claw402WalletKey string
// Scan configuration
ScanInterval time.Duration // Scan interval (recommended 3 minutes)
@@ -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)
+11
View File
@@ -324,6 +324,17 @@ func (at *AutoTrader) InitializeGrid() error {
at.gridState.IsInitialized = true
// Keep grid orders aligned with the trader's configured cross/isolated mode.
if err := at.trader.SetMarginMode(gridConfig.Symbol, at.config.IsCrossMargin); err != nil {
logger.Warnf("[Grid] Failed to set margin mode for %s: %v", gridConfig.Symbol, err)
} else {
marginMode := "cross"
if !at.config.IsCrossMargin {
marginMode = "isolated"
}
logger.Infof("[Grid] Margin mode set to %s for %s", marginMode, gridConfig.Symbol)
}
// CRITICAL: Set leverage on exchange before trading
if err := at.trader.SetLeverage(gridConfig.Symbol, gridConfig.Leverage); err != nil {
logger.Warnf("[Grid] Failed to set leverage %dx on exchange: %v", gridConfig.Leverage, err)
+11 -2
View File
@@ -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)
+23 -30
View File
@@ -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
}
+247
View File
@@ -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"])
}
}
+15 -5
View File
@@ -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",
+67
View File
@@ -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)
}
+39 -25
View File
@@ -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"`
Error json.RawMessage `json:"error"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return "0.00"
return 0, fmt.Errorf("decode rpc response: %w", err)
}
if len(rpcResp.Error) > 0 && string(rpcResp.Error) != "null" {
return 0, fmt.Errorf("rpc error: %s", string(rpcResp.Error))
}
// Parse hex result
hexStr := strings.TrimPrefix(rpcResp.Result, "0x")
if hexStr == "" || hexStr == "0" {
return "0.00"
if hexStr == "" {
return 0, nil
}
balance, ok := new(big.Int).SetString(hexStr, 16)
if !ok {
return 0, fmt.Errorf("invalid hex balance: %q", rpcResp.Result)
}
balance := new(big.Int)
balance.SetString(hexStr, 16)
// Convert to float with 6 decimals
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(USDCDecimals), nil)
whole := new(big.Int).Div(balance, divisor)
whole := new(big.Int).Quo(balance, divisor)
remainder := new(big.Int).Mod(balance, divisor)
return fmt.Sprintf("%d.%06d", whole, remainder)
// Preserve 6-decimal precision without float drift.
frac := fmt.Sprintf("%06d", remainder.Int64())
combined := whole.String() + "." + frac
var out float64
if _, err := fmt.Sscanf(combined, "%f", &out); err != nil {
return 0, fmt.Errorf("parse balance %q: %w", combined, err)
}
return out, nil
}
+5 -709
View File
@@ -1,718 +1,14 @@
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import useSWR from 'swr'
import { api } from './lib/api'
import { TraderDashboardPage } from './pages/TraderDashboardPage'
import { AITradersPage } from './components/trader/AITradersPage'
import { LoginPage } from './components/auth/LoginPage'
import { SetupPage } from './components/modals/SetupPage'
import { SettingsPage } from './pages/SettingsPage'
import { ResetPasswordPage } from './components/auth/ResetPasswordPage'
import { CompetitionPage } from './components/trader/CompetitionPage'
import { LandingPage } from './pages/LandingPage'
import { FAQPage } from './pages/FAQPage'
import { StrategyStudioPage } from './pages/StrategyStudioPage'
import { StrategyMarketPage } from './pages/StrategyMarketPage'
import { DataPage } from './pages/DataPage'
import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage'
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
import HeaderBar from './components/common/HeaderBar'
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
import { t } from './i18n/translations'
import { useSystemConfig } from './hooks/useSystemConfig'
import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding'
import { AuthProvider } from './contexts/AuthContext'
import { LanguageProvider } from './contexts/LanguageContext'
import { AppRoutes } from './router/AppRoutes'
import { OFFICIAL_LINKS } from './constants/branding'
import type {
SystemStatus,
AccountInfo,
Position,
DecisionRecord,
Statistics,
TraderInfo,
Exchange,
} from './types'
type Page =
| 'competition'
| 'traders'
| 'trader'
| 'strategy'
| 'strategy-market'
| 'data'
| 'faq'
| 'login'
| 'register'
function App() {
const { language, setLanguage } = useLanguage()
const { user, token, logout, isLoading } = useAuth()
const { config: systemConfig, loading: configLoading } = useSystemConfig()
const [route, setRoute] = useState(window.location.pathname)
// 从URL路径读取初始页面状态(支持刷新保持页面)
const getInitialPage = (): Page => {
const path = window.location.pathname
const hash = window.location.hash.slice(1) // 去掉 #
if (path === '/welcome') return 'traders'
if (path === '/traders' || hash === 'traders') return 'traders'
if (path === '/strategy' || hash === 'strategy') return 'strategy'
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
if (path === '/data' || hash === 'data') return 'data'
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
return 'trader'
return 'competition' // 默认为竞赛页面
}
// Login required overlay state
const [loginOverlayOpen, setLoginOverlayOpen] = useState(false)
const [loginOverlayFeature, setLoginOverlayFeature] = useState('')
const handleLoginRequired = (featureName: string) => {
setLoginOverlayFeature(featureName)
setLoginOverlayOpen(true)
}
// Unified page navigation handler
const navigateToPage = (page: Page) => {
const pathMap: Record<Page, string> = {
'competition': '/competition',
'strategy-market': '/strategy-market',
'data': '/data',
'traders': '/traders',
'trader': '/dashboard',
'strategy': '/strategy',
'faq': '/faq',
'login': '/login',
'register': '/register',
}
const path = pathMap[page]
if (path) {
window.history.pushState({}, '', path)
setRoute(path)
setCurrentPage(page)
}
}
const [currentPage, setCurrentPage] = useState<Page>(getInitialPage())
// 从 URL 参数读取初始 trader 标识(格式: name-id前4位)
const [selectedTraderSlug, setSelectedTraderSlug] = useState<string | undefined>(() => {
const params = new URLSearchParams(window.location.search)
return params.get('trader') || undefined
})
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>()
// 生成 trader URL slugname + ID 前 4 位)
const getTraderSlug = (trader: TraderInfo) => {
const idPrefix = trader.trader_id.slice(0, 4)
return `${trader.trader_name}-${idPrefix}`
}
// 从 slug 解析并匹配 trader
const findTraderBySlug = (slug: string, traderList: TraderInfo[]) => {
// slug 格式: name-xxxx (xxxx 是 ID 前 4 位)
const lastDashIndex = slug.lastIndexOf('-')
if (lastDashIndex === -1) {
// 没有 dash,直接按 name 匹配
return traderList.find(t => t.trader_name === slug)
}
const name = slug.slice(0, lastDashIndex)
const idPrefix = slug.slice(lastDashIndex + 1)
return traderList.find(t =>
t.trader_name === name && t.trader_id.startsWith(idPrefix)
)
}
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
const hasPersistedAuth =
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
// Poll-off states: stop polling after 3 consecutive failures
const [accountPollOff, setAccountPollOff] = useState(false)
const [positionsPollOff, setPositionsPollOff] = useState(false)
const [decisionsPollOff, setDecisionsPollOff] = useState(false)
// Reset poll-off states when trader changes
useEffect(() => {
setAccountPollOff(false)
setPositionsPollOff(false)
setDecisionsPollOff(false)
}, [selectedTraderId])
// 监听URL变化,同步页面状态
useEffect(() => {
const handleRouteChange = () => {
const path = window.location.pathname
const hash = window.location.hash.slice(1)
const params = new URLSearchParams(window.location.search)
const traderParam = params.get('trader')
if (path === '/welcome') {
setCurrentPage('traders')
} else if (path === '/traders' || hash === 'traders') {
setCurrentPage('traders')
} else if (path === '/strategy' || hash === 'strategy') {
setCurrentPage('strategy')
} else if (path === '/strategy-market' || hash === 'strategy-market') {
setCurrentPage('strategy-market')
} else if (path === '/data' || hash === 'data') {
setCurrentPage('data')
} else if (
path === '/dashboard' ||
hash === 'trader' ||
hash === 'details'
) {
setCurrentPage('trader')
// 如果 URL 中有 trader 参数(slug 格式),更新选中的 trader
setSelectedTraderSlug(traderParam || undefined)
} else if (
path === '/competition' ||
hash === 'competition' ||
hash === ''
) {
setCurrentPage('competition')
}
setRoute(path)
}
window.addEventListener('hashchange', handleRouteChange)
window.addEventListener('popstate', handleRouteChange)
return () => {
window.removeEventListener('hashchange', handleRouteChange)
window.removeEventListener('popstate', handleRouteChange)
}
}, [])
// 切换页面时更新URL hash (当前通过按钮直接调用setCurrentPage,这个函数暂时保留用于未来扩展)
// const navigateToPage = (page: Page) => {
// setCurrentPage(page);
// window.location.hash = page === 'competition' ? '' : 'trader';
// };
// 获取trader列表(仅在用户登录时)
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
() => api.getTraders(currentPage === 'trader'),
{
refreshInterval: 10000,
shouldRetryOnError: false, // 避免在后端未运行时无限重试
}
)
// 获取exchanges列表(用于显示交易所名称)
const { data: exchanges } = useSWR<Exchange[]>(
user && token ? 'exchanges' : null,
api.getExchangeConfigs,
{
refreshInterval: 60000, // 1分钟刷新一次
shouldRetryOnError: false,
}
)
// 当获取到traders后,根据 URL 中的 trader slug 设置选中的 trader,或默认选中第一个
useEffect(() => {
if (!traders || traders.length === 0) {
return
}
if (selectedTraderSlug) {
// 通过 slug 找到对应的 trader
const trader = findTraderBySlug(selectedTraderSlug, traders)
const nextTraderId = trader?.trader_id || traders[0].trader_id
if (nextTraderId !== selectedTraderId) {
setSelectedTraderId(nextTraderId)
}
return
}
if (!selectedTraderId) {
setSelectedTraderId(traders[0].trader_id)
}
}, [traders, selectedTraderId, selectedTraderSlug])
// 如果在trader页面,获取该trader的数据
const { data: status } = useSWR<SystemStatus>(
currentPage === 'trader' && selectedTraderId
? `status-${selectedTraderId}`
: null,
() => api.getStatus(selectedTraderId, true),
{
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
}
)
const { data: account } = useSWR<AccountInfo>(
currentPage === 'trader' && selectedTraderId
? `account-${selectedTraderId}`
: null,
() => api.getAccount(selectedTraderId, true),
{
refreshInterval: accountPollOff ? 0 : 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setAccountPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (accountPollOff) setAccountPollOff(false) },
}
)
const { data: positions } = useSWR<Position[]>(
currentPage === 'trader' && selectedTraderId
? `positions-${selectedTraderId}`
: null,
() => api.getPositions(selectedTraderId, true),
{
refreshInterval: positionsPollOff ? 0 : 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setPositionsPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) },
}
)
const { data: decisions } = useSWR<DecisionRecord[]>(
currentPage === 'trader' && selectedTraderId
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
: null,
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
{
refreshInterval: decisionsPollOff ? 0 : 30000,
revalidateOnFocus: false,
dedupingInterval: 20000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setDecisionsPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) },
}
)
const { data: stats } = useSWR<Statistics>(
currentPage === 'trader' && selectedTraderId
? `statistics-${selectedTraderId}`
: null,
() => api.getStatistics(selectedTraderId, true),
{
refreshInterval: 30000, // 30秒刷新(统计数据更新频率较低)
revalidateOnFocus: false,
dedupingInterval: 20000,
}
)
useEffect(() => {
if (account) {
const now = new Date().toLocaleTimeString()
setLastUpdate(now)
}
}, [account])
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
const effectiveAccount = account
const effectivePositions = positions
const effectiveDecisions = decisions
// Handle routing
useEffect(() => {
const handlePopState = () => {
setRoute(window.location.pathname)
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
// Set current page based on route for consistent navigation state
useEffect(() => {
if (route === '/welcome') {
setCurrentPage('traders')
} else if (route === '/competition') {
setCurrentPage('competition')
} else if (route === '/traders') {
setCurrentPage('traders')
} else if (route === '/dashboard') {
setCurrentPage('trader')
}
}, [route])
const showBeginnerOnboarding =
route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding()
// Show loading spinner while checking auth or config
if (isLoading || configLoading) {
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: '#0B0E11' }}
>
<div className="text-center">
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 mx-auto mb-4 animate-pulse"
/>
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
</div>
</div>
)
}
// First-time setup: redirect to /setup if system not initialized
if (systemConfig && !systemConfig.initialized && !user) {
return <SetupPage />
}
// Handle specific routes regardless of authentication
if (route === '/login') {
return <LoginPage />
}
if (route === '/setup') {
// If already initialized, redirect to login
if (systemConfig?.initialized) {
window.location.href = '/login'
return null
}
return <SetupPage />
}
if (route === '/welcome') {
if ((!user || !token) && !hasPersistedAuth) {
window.location.href = '/login'
return null
}
if (getUserMode() !== 'beginner') {
window.location.href = '/traders'
return null
}
}
if (route === '/faq') {
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage="faq"
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
<FAQPage />
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
</div>
)
}
if (route === '/reset-password') {
return <ResetPasswordPage />
}
if (route === '/settings') {
if ((!user || !token) && !hasPersistedAuth) {
window.location.href = '/login'
return null
}
return (
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
<HeaderBar
isLoggedIn={!!user}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
<SettingsPage />
</div>
)
}
// Data page - publicly accessible with embedded dashboard
if (route === '/data') {
const dataPageNavigate = (page: Page) => {
navigateToPage(page)
}
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage="data"
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={dataPageNavigate}
/>
<main className="pt-16">
<DataPage />
</main>
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
</div>
)
}
// Show landing page for root route
if (route === '/' || route === '') {
return <LandingPage />
}
// Redirect unauthenticated users to landing page
if (!user || !token) {
return <LandingPage />
}
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage={currentPage}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
{/* Main Content with Page Transitions */}
<main className="min-h-screen pt-16">
<AnimatePresence mode="wait">
<motion.div
key={currentPage}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
{currentPage === 'competition' ? (
<CompetitionPage />
) : currentPage === 'data' ? (
<DataPage />
) : currentPage === 'strategy-market' ? (
<StrategyMarketPage />
) : currentPage === 'traders' ? (
<AITradersPage
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
const trader = traders?.find((item) => item.trader_id === traderId)
const url = new URL(window.location.href)
url.pathname = '/dashboard'
if (trader) {
const slug = getTraderSlug(trader)
url.searchParams.set('trader', slug)
setSelectedTraderSlug(slug)
} else {
url.searchParams.delete('trader')
setSelectedTraderSlug(undefined)
}
window.history.pushState({}, '', url.toString())
setRoute('/dashboard')
setCurrentPage('trader')
}}
/>
) : currentPage === 'strategy' ? (
<StrategyStudioPage />
) : (
<TraderDashboardPage
selectedTrader={selectedTrader}
status={status}
account={effectiveAccount}
accountFailed={accountPollOff}
positions={effectivePositions}
positionsFailed={positionsPollOff}
decisions={effectiveDecisions}
decisionsFailed={decisionsPollOff}
decisionsLimit={decisionsLimit}
onDecisionsLimitChange={setDecisionsLimit}
stats={stats}
lastUpdate={lastUpdate}
language={language}
traders={traders}
tradersError={tradersError}
selectedTraderId={selectedTraderId}
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
// 更新 URL 参数(使用 slug: name-id前4位)
const trader = traders?.find(t => t.trader_id === traderId)
if (trader) {
const slug = getTraderSlug(trader)
setSelectedTraderSlug(slug)
const url = new URL(window.location.href)
url.searchParams.set('trader', slug)
window.history.replaceState({}, '', url.toString())
}
}}
onNavigateToTraders={() => {
window.history.pushState({}, '', '/traders')
setRoute('/traders')
setCurrentPage('traders')
}}
exchanges={exchanges}
/>
)}
</motion.div>
</AnimatePresence>
</main>
{/* Footer */}
<footer
className="mt-16"
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
>
<div
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
style={{ color: '#5E6673' }}
>
<p>{t('footerTitle', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
{/* GitHub */}
<a
href={OFFICIAL_LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#F0B90B'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="18"
height="18"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub
</a>
{/* Twitter/X */}
<a
href={OFFICIAL_LINKS.twitter}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#1DA1F2'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
Twitter
</a>
{/* Telegram */}
<a
href={OFFICIAL_LINKS.telegram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#0088cc'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
Telegram
</a>
</div>
</div>
</footer>
{/* Login Required Overlay */}
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
{showBeginnerOnboarding && <BeginnerOnboardingPage />}
</div>
)
}
// Wrap App with providers
export default function AppWithProviders() {
export default function App() {
return (
<LanguageProvider>
<AuthProvider>
<ConfirmDialogProvider>
<App />
<AppRoutes />
</ConfirmDialogProvider>
</AuthProvider>
</LanguageProvider>
+23 -10
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext'
@@ -13,12 +14,15 @@ import { invalidateSystemConfig } from '../../lib/config'
export function LoginPage() {
const { language } = useLanguage()
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(
null
)
const [mode, setMode] = useState<UserMode>('beginner')
// Clean up stale auth state once on mount
@@ -31,7 +35,9 @@ export function LoginPage() {
// Show session-expired toast (re-runs on language change to update text)
useEffect(() => {
if (sessionStorage.getItem('from401') === 'true') {
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
const id = toast.warning(t('sessionExpired', language), {
duration: Infinity,
})
setExpiredToastId(id)
sessionStorage.removeItem('from401')
}
@@ -48,7 +54,9 @@ export function LoginPage() {
sessionStorage.removeItem('from401')
invalidateSystemConfig()
toast.success(t('forgotAccountSuccess', language))
setTimeout(() => { window.location.href = '/setup' }, 1500)
setTimeout(() => {
navigate('/setup')
}, 1500)
} else {
const data = await res.json()
toast.error(data.error || 'Reset failed')
@@ -79,23 +87,27 @@ export function LoginPage() {
<div className="flex-1 flex items-center justify-center px-4 py-16">
<div className="w-full max-w-sm">
{/* Logo + Title */}
<div className="text-center mb-10">
<div className="flex justify-center mb-5">
<div className="relative">
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" />
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10" />
<img
src="/icons/nofx.svg"
alt="NOFX"
className="w-14 h-14 relative z-10"
/>
</div>
</div>
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome back</h1>
<h1 className="text-2xl font-bold text-white mb-1.5">
Welcome back
</h1>
<p className="text-zinc-500 text-sm">Sign in to your account</p>
</div>
{/* Card */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-8 shadow-2xl">
<form onSubmit={handleLogin} className="space-y-5">
{/* Email */}
<div>
<label className="block text-xs font-medium text-zinc-400 mb-2">
@@ -120,7 +132,7 @@ export function LoginPage() {
</label>
<button
type="button"
onClick={() => window.location.href = '/reset-password'}
onClick={() => navigate('/reset-password')}
className="text-xs text-zinc-500 hover:text-nofx-gold transition-colors"
>
{t('forgotPassword', language)}
@@ -164,7 +176,9 @@ export function LoginPage() {
disabled={loading}
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
{loading
? t('loggingIn', language) || 'Signing in...'
: t('signIn', language) || 'Sign In'}
</button>
</form>
@@ -178,7 +192,6 @@ export function LoginPage() {
</button>
</div>
</div>
</div>
</div>
</DeepVoidBackground>
@@ -1,5 +1,6 @@
import { motion, AnimatePresence } from 'framer-motion'
import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react'
import { Link } from 'react-router-dom'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
@@ -10,7 +11,11 @@ interface LoginRequiredOverlayProps {
featureName?: string
}
export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequiredOverlayProps) {
export function LoginRequiredOverlay({
isOpen,
onClose,
featureName,
}: LoginRequiredOverlayProps) {
const { language } = useLanguage()
const tr = (key: string, params?: Record<string, string | number>) =>
@@ -20,11 +25,7 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
? tr('subtitleWithFeature', { featureName })
: tr('subtitleDefault')
const benefits = [
tr('benefit1'),
tr('benefit2'),
tr('benefit4'),
]
const benefits = [tr('benefit1'), tr('benefit2'), tr('benefit4')]
return (
<AnimatePresence>
@@ -40,7 +41,6 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
disableAnimation
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -53,7 +53,9 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
<div className="flex items-center justify-between px-3 py-2 bg-nofx-bg-lighter border-b border-nofx-gold/20">
<div className="flex items-center gap-2">
<Terminal size={12} className="text-nofx-gold" />
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">auth_protocol.exe</span>
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">
auth_protocol.exe
</span>
</div>
<button
onClick={onClose}
@@ -75,7 +77,9 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
<div className="bg-nofx-bg border border-red-500/50 text-red-500 px-4 py-2 flex items-center gap-3 shadow-[0_0_15px_rgba(239,68,68,0.2)]">
<AlertTriangle size={18} className="animate-pulse" />
<span className="font-bold tracking-widest text-sm uppercase">{tr('accessDenied')}</span>
<span className="font-bold tracking-widest text-sm uppercase">
{tr('accessDenied')}
</span>
</div>
</div>
</div>
@@ -83,8 +87,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
{/* Terminal Text */}
<div className="space-y-4 mb-8">
<div className="text-center">
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{tr('title')}</h2>
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">{subtitle}</p>
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">
{tr('title')}
</h2>
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">
{subtitle}
</p>
</div>
<div className="bg-nofx-bg-lighter border-l-2 border-nofx-gold/20 p-3 my-4">
@@ -96,7 +104,10 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
<div className="grid grid-cols-2 gap-2">
{benefits.map((benefit, i) => (
<div key={i} className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide">
<div
key={i}
className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide"
>
<span className="text-nofx-gold"></span> {benefit}
</div>
))}
@@ -105,22 +116,24 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
{/* Action Buttons */}
<div className="space-y-3">
<a
href="/login"
<Link
to="/login"
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-black font-bold text-xs uppercase tracking-widest hover:bg-yellow-400 transition-all shadow-neon hover:shadow-[0_0_25px_rgba(240,185,11,0.4)] group"
>
<LogIn size={14} />
<span>{tr('loginButton')}</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-&gt;</span>
</a>
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">
-&gt;
</span>
</Link>
<a
href="/register"
<Link
to="/register"
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-nofx-gold/20 text-nofx-text-muted hover:text-white hover:border-nofx-gold font-bold text-xs uppercase tracking-widest transition-all hover:bg-nofx-gold/10"
>
<UserPlus size={14} />
<span>{tr('registerButton')}</span>
</a>
</Link>
</div>
<div className="mt-4 text-center">
@@ -131,14 +144,12 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
[ {tr('abort')} ]
</button>
</div>
</div>
</div>
{/* Corner Accents */}
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div>
<div className="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-nofx-gold"></div>
</motion.div>
</DeepVoidBackground>
</motion.div>
+72 -21
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import PasswordChecklist from 'react-password-checklist'
import { toast } from 'sonner'
import { useAuth } from '../../contexts/AuthContext'
@@ -13,6 +14,7 @@ import { WhitelistFullPage } from '../common/WhitelistFullPage'
export function RegisterPage() {
const { language } = useLanguage()
const { register } = useAuth()
const navigate = useNavigate()
const [view, setView] = useState<'register' | 'whitelist-full'>('register')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -61,7 +63,11 @@ export function RegisterPage() {
setLoading(true)
try {
const result = await register(email, password, betaCode.trim() || undefined)
const result = await register(
email,
password,
betaCode.trim() || undefined
)
const isWhitelistError = (msg: string) => {
const lowerMsg = msg.toLowerCase()
@@ -86,7 +92,10 @@ export function RegisterPage() {
// success path is handled in AuthContext (auto login + navigation)
} catch (e) {
console.error('Registration error:', e)
const errorMsg = e instanceof Error ? e.message : 'Registration failed due to server error'
const errorMsg =
e instanceof Error
? e.message
: 'Registration failed due to server error'
const lowerMsg = errorMsg.toLowerCase()
if (
lowerMsg.includes('whitelist') ||
@@ -106,15 +115,20 @@ export function RegisterPage() {
}
return (
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
<DeepVoidBackground
className="min-h-screen flex items-center justify-center py-12 font-mono"
disableAnimation
>
<div className="w-full max-w-lg relative z-10 px-6">
<div className="flex justify-between items-center mb-8">
<button
onClick={() => (window.location.href = '/')}
onClick={() => navigate('/')}
className="flex items-center gap-2 text-zinc-500 hover:text-white transition-colors group px-3 py-1.5 rounded border border-transparent hover:border-zinc-700 bg-black/20 backdrop-blur-sm"
>
<div className="w-2 h-2 rounded-full bg-red-500 group-hover:animate-pulse"></div>
<span className="text-xs font-mono uppercase tracking-widest">&lt; ABORT_REGISTRATION</span>
<span className="text-xs font-mono uppercase tracking-widest">
&lt; ABORT_REGISTRATION
</span>
</button>
</div>
@@ -122,7 +136,11 @@ export function RegisterPage() {
<div className="flex justify-center mb-6">
<div className="relative">
<div className="absolute -inset-2 bg-nofx-gold/20 rounded-full blur-xl animate-pulse"></div>
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain relative z-10 opacity-90" />
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 object-contain relative z-10 opacity-90"
/>
</div>
</div>
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase mb-2">
@@ -140,7 +158,7 @@ export function RegisterPage() {
<div className="flex gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full bg-red-500/50 hover:bg-red-500 cursor-pointer transition-colors"
onClick={() => (window.location.href = '/')}
onClick={() => navigate('/')}
title="Close / Return Home"
></div>
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></div>
@@ -155,7 +173,9 @@ export function RegisterPage() {
<div className="mb-6 font-mono text-xs space-y-1 text-zinc-500 border-b border-zinc-800/50 pb-4">
<div className="flex gap-2">
<span className="text-emerald-500"></span>
<span>System Check: <span className="text-emerald-500">READY</span></span>
<span>
System Check: <span className="text-emerald-500">READY</span>
</span>
</div>
<div className="flex gap-2">
<span className="text-emerald-500"></span>
@@ -165,7 +185,9 @@ export function RegisterPage() {
<form onSubmit={handleRegister} className="space-y-5">
<div>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('email', language)}</label>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
{t('email', language)}
</label>
<input
type="email"
value={email}
@@ -178,7 +200,9 @@ export function RegisterPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('password', language)}</label>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
{t('password', language)}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
@@ -199,7 +223,9 @@ export function RegisterPage() {
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('confirmPassword', language)}</label>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
{t('confirmPassword', language)}
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
@@ -211,10 +237,16 @@ export function RegisterPage() {
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
>
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
{showConfirmPassword ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
</div>
</div>
@@ -227,7 +259,14 @@ export function RegisterPage() {
</div>
<div className="text-xs font-mono text-zinc-400">
<PasswordChecklist
rules={['minLength', 'capital', 'lowercase', 'number', 'specialChar', 'match']}
rules={[
'minLength',
'capital',
'lowercase',
'number',
'specialChar',
'match',
]}
minLength={8}
value={password}
valueAgain={confirmPassword}
@@ -248,17 +287,25 @@ export function RegisterPage() {
{betaMode && (
<div>
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">Priority Access Code</label>
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">
Priority Access Code
</label>
<input
type="text"
value={betaCode}
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())}
onChange={(e) =>
setBetaCode(
e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase()
)
}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono tracking-widest"
placeholder="XXXXXX"
maxLength={6}
required={betaMode}
/>
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">* CASE SENSITIVE ALPHANUMERIC</p>
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">
* CASE SENSITIVE ALPHANUMERIC
</p>
</div>
)}
@@ -270,7 +317,9 @@ export function RegisterPage() {
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim()) || !passwordValid}
disabled={
loading || (betaMode && !betaCode.trim()) || !passwordValid
}
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_15px_rgba(255,215,0,0.1)] hover:shadow-[0_0_25px_rgba(255,215,0,0.25)] flex items-center justify-center gap-2 group mt-4"
>
{loading ? (
@@ -278,7 +327,9 @@ export function RegisterPage() {
) : (
<>
<span>CREATE_ACCOUNT</span>
<span className="group-hover:translate-x-1 transition-transform">-&gt;</span>
<span className="group-hover:translate-x-1 transition-transform">
-&gt;
</span>
</>
)}
</button>
@@ -295,14 +346,14 @@ export function RegisterPage() {
<p className="text-xs font-mono text-zinc-500">
EXISTING_OPERATOR?{' '}
<button
onClick={() => (window.location.href = '/login')}
onClick={() => navigate('/login')}
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
>
ACCESS TERMINAL
</button>
</p>
<button
onClick={() => (window.location.href = '/')}
onClick={() => navigate('/')}
className="text-[10px] text-zinc-600 hover:text-red-500 transition-colors uppercase tracking-widest hover:underline decoration-red-500/30 font-mono"
>
[ ABORT_REGISTRATION_RETURN_HOME ]
@@ -1,8 +1,19 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { RegistrationDisabled } from './RegistrationDisabled'
import { LanguageProvider } from '../../contexts/LanguageContext'
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useNavigate: () => mockNavigate,
}
})
// Mock useLanguage hook
vi.mock('../../contexts/LanguageContext', async () => {
const actual = await vi.importActual('../../contexts/LanguageContext')
@@ -21,9 +32,11 @@ vi.mock('../../contexts/LanguageContext', async () => {
describe('RegistrationDisabled Component', () => {
const renderComponent = () => {
return render(
<MemoryRouter>
<LanguageProvider>
<RegistrationDisabled />
</LanguageProvider>
</MemoryRouter>
)
}
@@ -48,7 +61,9 @@ describe('RegistrationDisabled Component', () => {
it('should display registration closed message', () => {
renderComponent()
const message = screen.getByText(/User registration is currently disabled/i)
const message = screen.getByText(
/User registration is currently disabled/i
)
expect(message).toBeTruthy()
})
@@ -61,19 +76,12 @@ describe('RegistrationDisabled Component', () => {
describe('Navigation', () => {
it('should navigate to login page when button is clicked', () => {
const pushStateSpy = vi.spyOn(window.history, 'pushState')
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
renderComponent()
const button = screen.getByRole('button', { name: /back to login/i })
fireEvent.click(button)
expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/login')
expect(dispatchEventSpy).toHaveBeenCalled()
pushStateSpy.mockRestore()
dispatchEventSpy.mockRestore()
expect(mockNavigate).toHaveBeenCalledWith('/login')
})
})
@@ -1,12 +1,13 @@
import { useNavigate } from 'react-router-dom'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
export function RegistrationDisabled() {
const { language } = useLanguage()
const navigate = useNavigate()
const handleBackToLogin = () => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
navigate('/login')
}
return (
@@ -1,4 +1,5 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
@@ -11,6 +12,7 @@ import { toast } from 'sonner'
export function ResetPasswordPage() {
const { language } = useLanguage()
const { resetPassword } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
@@ -41,8 +43,7 @@ export function ResetPasswordPage() {
toast.success(t('resetPasswordSuccess', language) || '重置成功')
// 3秒后跳转到登录页面
setTimeout(() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
navigate('/login')
}, 3000)
} else {
const msg = result.message || t('resetPasswordFailed', language)
@@ -64,10 +65,7 @@ export function ResetPasswordPage() {
<div className="w-full max-w-md">
{/* Back to Login */}
<button
onClick={() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
}}
onClick={() => navigate('/login')}
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
style={{ color: '#848E9C' }}
>
+206 -66
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
import { t, type Language } from '../../i18n/translations'
@@ -10,17 +10,7 @@ import {
setUserMode,
type UserMode,
} from '../../lib/onboarding'
type Page =
| 'competition'
| 'traders'
| 'trader'
| 'strategy'
| 'strategy-market'
| 'data'
| 'faq'
| 'login'
| 'register'
import { getCurrentPageForPath, ROUTES, type Page } from '../../router/paths'
interface HeaderBarProps {
onLoginClick?: () => void
@@ -47,16 +37,20 @@ export default function HeaderBar({
onLoginRequired,
}: HeaderBarProps) {
const navigate = useNavigate()
const location = useLocation()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
const [userMode, setUserModeState] = useState<UserMode>(
() => getUserMode() ?? 'advanced'
)
const dropdownRef = useRef<HTMLDivElement>(null)
const userDropdownRef = useRef<HTMLDivElement>(null)
const resolvedCurrentPage =
currentPage ?? getCurrentPageForPath(location.pathname)
const navigateInApp = (path: string) => {
navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
const handleSwitchMode = (nextMode: UserMode) => {
@@ -94,14 +88,12 @@ export default function HeaderBar({
{/* Logo - Always go to home page */}
<div
onClick={() => {
window.location.href = '/'
navigateInApp(ROUTES.home)
}}
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer"
>
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
<span className="text-lg font-bold text-nofx-gold">
NOFX
</span>
<span className="text-lg font-bold text-nofx-gold">NOFX</span>
</div>
{/* Desktop Menu */}
@@ -111,17 +103,67 @@ export default function HeaderBar({
{/* Navigation tabs configuration */}
{(() => {
// Define all navigation tabs
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
const navTabs: {
page: Page
path: string
label: string
requiresAuth: boolean
}[] = [
{
page: 'data',
path: ROUTES.data,
label:
language === 'zh'
? '数据'
: language === 'id'
? 'Data'
: 'Data',
requiresAuth: false,
},
{
page: 'strategy-market',
path: ROUTES.strategyMarket,
label:
language === 'zh'
? '策略市场'
: language === 'id'
? 'Pasar'
: 'Market',
requiresAuth: true,
},
{
page: 'traders',
path: ROUTES.traders,
label: t('configNav', language),
requiresAuth: true,
},
{
page: 'trader',
path: ROUTES.dashboard,
label: t('dashboardNav', language),
requiresAuth: true,
},
{
page: 'strategy',
path: ROUTES.strategy,
label: t('strategyNav', language),
requiresAuth: true,
},
{
page: 'competition',
path: ROUTES.competition,
label: t('realtimeNav', language),
requiresAuth: true,
},
{
page: 'faq',
path: ROUTES.faq,
label: t('faqNav', language),
requiresAuth: false,
},
]
const handleNavClick = (tab: typeof navTabs[0]) => {
const handleNavClick = (tab: (typeof navTabs)[0]) => {
// If requires auth and not logged in, show login prompt
if (tab.requiresAuth && !isLoggedIn) {
onLoginRequired?.(tab.label)
@@ -131,7 +173,7 @@ export default function HeaderBar({
if (onPageChange) {
onPageChange(tab.page)
}
navigate(tab.path)
navigateInApp(tab.path)
}
return navTabs.map((tab) => (
@@ -139,12 +181,10 @@ export default function HeaderBar({
key={tab.page}
onClick={() => handleNavClick(tab)}
className={`text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 px-3 py-2 rounded-lg
${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
${resolvedCurrentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
>
{currentPage === tab.page && (
<span
className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
/>
{resolvedCurrentPage === tab.page && (
<span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" />
)}
{tab.label}
</button>
@@ -164,7 +204,12 @@ export default function HeaderBar({
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-white hover:bg-white/5"
title="GitHub"
>
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<svg
width="18"
height="18"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
</a>
@@ -176,7 +221,12 @@ export default function HeaderBar({
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#1DA1F2] hover:bg-[#1DA1F2]/10"
title="Twitter"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
@@ -188,7 +238,12 @@ export default function HeaderBar({
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#0088cc] hover:bg-[#0088cc]/10"
title="Telegram"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
</a>
@@ -227,7 +282,7 @@ export default function HeaderBar({
</div>
<button
onClick={() => {
window.location.href = '/settings'
navigateInApp(ROUTES.settings)
setUserDropdownOpen(false)
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
@@ -236,13 +291,21 @@ export default function HeaderBar({
Settings
</button>
<button
onClick={() => handleSwitchMode(userMode === 'beginner' ? 'advanced' : 'beginner')}
onClick={() =>
handleSwitchMode(
userMode === 'beginner' ? 'advanced' : 'beginner'
)
}
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
>
<Settings className="w-3.5 h-3.5" />
{userMode === 'beginner'
? language === 'zh' ? '切到老手模式' : 'Switch to Advanced'
: language === 'zh' ? '切到手模式' : 'Switch to Beginner'}
? language === 'zh'
? '切到手模式'
: 'Switch to Advanced'
: language === 'zh'
? '切到新手模式'
: 'Switch to Beginner'}
</button>
{onLogout && (
<button
@@ -261,15 +324,16 @@ export default function HeaderBar({
</div>
) : (
/* Show login/register buttons when not logged in and not on login/register pages */
currentPage !== 'login' &&
currentPage !== 'register' && (
resolvedCurrentPage !== 'login' &&
resolvedCurrentPage !== 'register' && (
<div className="flex items-center gap-3">
<a
href="/login"
<button
type="button"
onClick={() => navigateInApp(ROUTES.login)}
className="px-3 py-2 text-sm font-medium transition-colors rounded text-nofx-text-muted hover:text-white"
>
{t('signIn', language)}
</a>
</button>
</div>
)
)}
@@ -361,17 +425,67 @@ export default function HeaderBar({
{/* Navigation Links */}
<div className="flex flex-col gap-6 mb-12">
{(() => {
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
const navTabs: {
page: Page
path: string
label: string
requiresAuth: boolean
}[] = [
{
page: 'data',
path: ROUTES.data,
label:
language === 'zh'
? '数据'
: language === 'id'
? 'Data'
: 'Data',
requiresAuth: false,
},
{
page: 'strategy-market',
path: ROUTES.strategyMarket,
label:
language === 'zh'
? '策略市场'
: language === 'id'
? 'Pasar'
: 'Market',
requiresAuth: true,
},
{
page: 'traders',
path: ROUTES.traders,
label: t('configNav', language),
requiresAuth: true,
},
{
page: 'trader',
path: ROUTES.dashboard,
label: t('dashboardNav', language),
requiresAuth: true,
},
{
page: 'strategy',
path: ROUTES.strategy,
label: t('strategyNav', language),
requiresAuth: true,
},
{
page: 'competition',
path: ROUTES.competition,
label: t('realtimeNav', language),
requiresAuth: true,
},
{
page: 'faq',
path: ROUTES.faq,
label: t('faqNav', language),
requiresAuth: false,
},
]
const handleMobileNavClick = (tab: typeof navTabs[0]) => {
const handleMobileNavClick = (tab: (typeof navTabs)[0]) => {
if (tab.requiresAuth && !isLoggedIn) {
onLoginRequired?.(tab.label)
setMobileMenuOpen(false)
@@ -380,7 +494,7 @@ export default function HeaderBar({
if (onPageChange) {
onPageChange(tab.page)
}
navigate(tab.path)
navigateInApp(tab.path)
setMobileMenuOpen(false)
}
@@ -392,9 +506,9 @@ export default function HeaderBar({
transition={{ delay: 0.1 + i * 0.05 }}
onClick={() => handleMobileNavClick(tab)}
className={`text-2xl font-black tracking-tight text-left flex items-center gap-3
${currentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
${resolvedCurrentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
>
{currentPage === tab.page && (
{resolvedCurrentPage === tab.page && (
<motion.div
layoutId="active-indicator"
className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
@@ -438,9 +552,24 @@ export default function HeaderBar({
{/* Social Links */}
<div className="flex items-center gap-4">
{[
{ href: OFFICIAL_LINKS.github, icon: <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" /> },
{ href: OFFICIAL_LINKS.twitter, icon: <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> },
{ href: OFFICIAL_LINKS.telegram, icon: <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /> }
{
href: OFFICIAL_LINKS.github,
icon: (
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
),
},
{
href: OFFICIAL_LINKS.twitter,
icon: (
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
),
},
{
href: OFFICIAL_LINKS.telegram,
icon: (
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
),
},
].map((link, i) => (
<a
key={i}
@@ -449,7 +578,12 @@ export default function HeaderBar({
rel="noopener noreferrer"
className="w-12 h-12 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 hover:text-nofx-gold hover:border-nofx-gold transition-colors"
>
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
<svg
width="20"
height="20"
viewBox="0 0 16 16"
fill="currentColor"
>
{link.icon}
</svg>
</a>
@@ -467,7 +601,8 @@ 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
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'
}`}
@@ -489,13 +624,18 @@ export default function HeaderBar({
{t('exitLogin', language)}
</button>
) : (
currentPage !== 'login' && currentPage !== 'register' && (
<a
href="/login"
resolvedCurrentPage !== 'login' &&
resolvedCurrentPage !== 'register' && (
<button
type="button"
onClick={() => {
navigateInApp(ROUTES.login)
setMobileMenuOpen(false)
}}
className="flex items-center justify-center bg-nofx-gold text-black rounded-lg font-bold text-sm hover:bg-yellow-400 transition-colors"
>
{t('signIn', language)}
</a>
</button>
)
)}
</div>
+103
View File
@@ -0,0 +1,103 @@
import { OFFICIAL_LINKS } from '../../constants/branding'
import { t, type Language } from '../../i18n/translations'
interface SiteFooterProps {
language: Language
}
export function SiteFooter({ language }: SiteFooterProps) {
return (
<footer
className="mt-16"
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
>
<div
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
style={{ color: '#5E6673' }}
>
<p>{t('footerTitle', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
<a
href={OFFICIAL_LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#F0B90B'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub
</a>
<a
href={OFFICIAL_LINKS.twitter}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#1DA1F2'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
Twitter
</a>
<a
href={OFFICIAL_LINKS.telegram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#0088cc'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
Telegram
</a>
</div>
</div>
</footer>
)
}
@@ -1,5 +1,6 @@
import { motion } from 'framer-motion'
import { ShieldAlert, ArrowLeft, Twitter, Send, Lock } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { OFFICIAL_LINKS } from '../../constants/branding'
interface WhitelistFullPageProps {
@@ -7,11 +8,13 @@ interface WhitelistFullPageProps {
}
export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
const navigate = useNavigate()
const handleBackToLogin = () => {
if (onBack) {
onBack()
} else {
window.location.href = '/login'
navigate('/login')
}
}
@@ -29,7 +32,6 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
className="max-w-lg w-full relative z-10"
>
<div className="bg-zinc-900/40 backdrop-blur-md border border-red-500/30 rounded-lg overflow-hidden relative group">
{/* Top Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-red-900/20 border-b border-red-500/30">
<div className="flex gap-1.5 opacity-50">
@@ -60,9 +62,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
{/* Description */}
<p className="text-xs text-zinc-400 mb-8 leading-relaxed font-mono px-4">
<span className="text-red-400">[SYSTEM_MESSAGE]:</span> YOUR IDENTIFIER IS NOT ON THE ACTIVE WHITELIST.
<br /><br />
Platform capacity limits have been reached for the current beta phase. Prioritized access is currently reserved for authorized operators only.
<span className="text-red-400">[SYSTEM_MESSAGE]:</span> YOUR
IDENTIFIER IS NOT ON THE ACTIVE WHITELIST.
<br />
<br />
Platform capacity limits have been reached for the current beta
phase. Prioritized access is currently reserved for authorized
operators only.
</p>
{/* Info Box */}
@@ -70,9 +76,13 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
<div className="flex items-start gap-3">
<Lock className="w-4 h-4 text-red-500 mt-0.5" />
<div>
<h3 className="text-xs font-bold text-red-400 uppercase mb-1">Authorization Protocol</h3>
<h3 className="text-xs font-bold text-red-400 uppercase mb-1">
Authorization Protocol
</h3>
<p className="text-[10px] text-zinc-500 leading-tight">
Access is rolled out in batches. If you believe this is an error, please verify your credentials or contact system administrators.
Access is rolled out in batches. If you believe this is an
error, please verify your credentials or contact system
administrators.
</p>
</div>
</div>
@@ -109,14 +119,12 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
</a>
</div>
</div>
</div>
{/* Footer */}
<div className="bg-black/80 p-2 text-[9px] text-zinc-700 text-center border-t border-zinc-800 font-mono uppercase">
ERR_CODE: WLIST_0x403 // SECURITY_LAYER_ACTIVE
</div>
</div>
</motion.div>
</div>
+21 -8
View File
@@ -1,5 +1,6 @@
import { motion } from 'framer-motion'
import { ArrowRight, Play, Github, Zap } from 'lucide-react'
import { Link } from 'react-router-dom'
import { t, Language } from '../../i18n/translations'
import { useGitHubStats } from '../../hooks/useGitHubStats'
import { useCounterAnimation } from '../../hooks/useCounterAnimation'
@@ -33,7 +34,8 @@ export default function HeroSection({ language }: HeroSectionProps) {
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full"
style={{
background: 'radial-gradient(circle, rgba(240, 185, 11, 0.08) 0%, transparent 70%)',
background:
'radial-gradient(circle, rgba(240, 185, 11, 0.08) 0%, transparent 70%)',
}}
/>
{/* Floating Orbs */}
@@ -138,8 +140,7 @@ export default function HeroSection({ language }: HeroSectionProps) {
transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12"
>
<motion.a
href="/competition"
<motion.div
className="group flex items-center gap-3 px-8 py-4 rounded-xl font-bold text-lg transition-all"
style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
@@ -152,10 +153,12 @@ export default function HeroSection({ language }: HeroSectionProps) {
}}
whileTap={{ scale: 0.98 }}
>
<Link to="/competition" className="flex items-center gap-3">
<Play className="w-5 h-5" />
{t('liveCompetition', language) || 'Live Competition'}
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</motion.a>
</Link>
</motion.div>
<motion.a
href={OFFICIAL_LINKS.github}
@@ -188,9 +191,18 @@ export default function HeroSection({ language }: HeroSectionProps) {
>
{[
{ label: 'GitHub Stars', value: `${(stars / 1000).toFixed(1)}K+` },
{ label: language === 'zh' ? '支持交易所' : 'Exchanges', value: '5+' },
{ label: language === 'zh' ? 'AI 模型' : 'AI Models', value: '10+' },
{ label: language === 'zh' ? '开源免费' : 'Open Source', value: '100%' },
{
label: language === 'zh' ? '支持交易所' : 'Exchanges',
value: '5+',
},
{
label: language === 'zh' ? 'AI 模型' : 'AI Models',
value: '10+',
},
{
label: language === 'zh' ? '开源免费' : 'Open Source',
value: '100%',
},
].map((stat, index) => (
<motion.div
key={stat.label}
@@ -202,7 +214,8 @@ export default function HeroSection({ language }: HeroSectionProps) {
<div
className="text-3xl sm:text-4xl font-bold mb-1"
style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
background:
'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
+3 -2
View File
@@ -1,5 +1,6 @@
import { motion } from 'framer-motion'
import { X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t, Language } from '../../i18n/translations'
interface LoginModalProps {
onClose: () => void
@@ -7,6 +8,7 @@ interface LoginModalProps {
}
export default function LoginModal({ onClose, language }: LoginModalProps) {
const navigate = useNavigate()
return (
<motion.div
@@ -49,8 +51,7 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
<div className="space-y-3">
<motion.button
onClick={() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
navigate('/login')
onClose()
}}
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
+78 -49
View File
@@ -1,80 +1,87 @@
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",
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
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: '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: '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 navigate = useNavigate()
const handleInitialize = () => {
if (user) {
window.location.href = '/strategy-market'
navigate('/strategy-market')
} else {
window.location.href = '/login'
navigate('/login')
}
}
return (
<section id="market-scanner" className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden">
<section
id="market-scanner"
className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden"
>
{/* Background Details */}
<div className="absolute top-0 right-0 p-10 opacity-20 pointer-events-none">
<Hexagon className="w-64 h-64 text-zinc-800" strokeWidth={0.5} />
</div>
<div className="max-w-7xl mx-auto px-6 relative z-10">
<div className="flex flex-col md:flex-row justify-between items-end mb-10 md:mb-16 gap-6">
<div>
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase">
<Crosshair className="w-4 h-4" /> MARKET SELECT
</div>
<h2 className="text-4xl md:text-5xl font-black text-white uppercase tracking-tighter">
STRATEGY <span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">UNITS</span>
STRATEGY{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">
UNITS
</span>
</h2>
</div>
<div className="font-mono text-right text-xs text-zinc-500 max-w-xs">
SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE PRE-TRAINED ON HISTORICAL TICKS.
SELECT AN AUTONOMOUS AGENT TO BEGIN DEPLOYMENT. UNITS ARE
PRE-TRAINED ON HISTORICAL TICKS.
</div>
</div>
@@ -101,28 +108,50 @@ export default function AgentGrid() {
<Icon className={`w-8 h-8 ${agent.color}`} />
</div>
<div className="text-right">
<div className="text-[10px] font-mono text-zinc-500 uppercase">Class</div>
<div className={`font-bold font-mono tracking-wider ${agent.color}`}>{agent.class}</div>
<div className="text-[10px] font-mono text-zinc-500 uppercase">
Class
</div>
<div
className={`font-bold font-mono tracking-wider ${agent.color}`}
>
{agent.class}
</div>
</div>
</div>
{/* Name & Desc */}
<h3 className="text-3xl font-bold text-white mb-2 tracking-tight group-hover:text-nofx-accent transition-colors">{agent.name}</h3>
<p className="text-zinc-500 text-sm mb-8 leading-relaxed h-10">{agent.desc}</p>
<h3 className="text-3xl font-bold text-white mb-2 tracking-tight group-hover:text-nofx-accent transition-colors">
{agent.name}
</h3>
<p className="text-zinc-500 text-sm mb-8 leading-relaxed h-10">
{agent.desc}
</p>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-px bg-zinc-800/50 border border-zinc-800 rounded overflow-hidden mb-8">
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">APY</div>
<div className="text-green-400 font-bold">{agent.apy}</div>
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
APY
</div>
<div className="text-green-400 font-bold">
{agent.apy}
</div>
</div>
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">Win %</div>
<div className="text-white font-bold">{agent.winRate}</div>
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
Win %
</div>
<div className="text-white font-bold">
{agent.winRate}
</div>
</div>
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">Risk</div>
<div className={`${agent.color} font-bold`}>{agent.risk}</div>
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
Risk
</div>
<div className={`${agent.color} font-bold`}>
{agent.risk}
</div>
</div>
</div>
@@ -131,14 +160,14 @@ export default function AgentGrid() {
onClick={handleInitialize}
className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${agent.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white cursor-pointer`}
>
<span className={agent.color}>[</span> INITIALIZE <span className={agent.color}>]</span>
<span className={agent.color}>[</span> INITIALIZE{' '}
<span className={agent.color}>]</span>
</button>
</div>
{/* Decorative Background Elements */}
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full blur-2xl group-hover:opacity-50 transition-opacity opacity-20"></div>
<div className="absolute inset-0 bg-scanlines opacity-20 pointer-events-none"></div>
</motion.div>
)
})}
+194 -77
View File
@@ -20,12 +20,7 @@ import { ModelConfigModal } from './ModelConfigModal'
import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList'
import { BeginnerGuideCards } from './BeginnerGuideCards'
import {
AlertTriangle,
Bot,
Plus,
MessageCircle,
} from 'lucide-react'
import { AlertTriangle, Bot, Plus, MessageCircle } from 'lucide-react'
import { confirmToast } from '../../lib/notify'
import { toast } from 'sonner'
import {
@@ -55,11 +50,17 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [allModels, setAllModels] = useState<AIModel[]>([])
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<
Set<string>
>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<
Set<string>
>(new Set())
const [copiedId, setCopiedId] = useState<string | null>(null)
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => getBeginnerWalletAddress())
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<
string | null
>(() => getBeginnerWalletAddress())
const isBeginnerMode = getUserMode() === 'beginner'
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message.trim() !== '') {
@@ -74,54 +75,98 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
) => {
const traderName = params.trader_name || params.traderName || 'this trader'
const modelName = params.model_name || params.modelName || 'selected model'
const exchangeName = params.exchange_name || params.exchangeName || 'selected exchange account'
const reason = localizeTraderReason(params.reason_key, params.reason || fallback)
const exchangeName =
params.exchange_name || params.exchangeName || 'selected exchange account'
const reason = localizeTraderReason(
params.reason_key,
params.reason || fallback
)
const symbol = params.symbol || ''
const zh = language === 'zh'
switch (errorKey) {
case 'trader.create.invalid_request':
return zh ? '提交的信息不完整,或者格式不正确。请检查后重新提交。' : 'The submitted information is incomplete or invalid. Please review it and try again.'
return zh
? '提交的信息不完整,或者格式不正确。请检查后重新提交。'
: 'The submitted information is incomplete or invalid. Please review it and try again.'
case 'trader.create.invalid_btc_eth_leverage':
return zh ? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。' : 'BTC/ETH leverage must be between 1x and 50x.'
return zh
? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。'
: 'BTC/ETH leverage must be between 1x and 50x.'
case 'trader.create.invalid_altcoin_leverage':
return zh ? '山寨币杠杆倍数需要在 1 到 20 倍之间。' : 'Altcoin leverage must be between 1x and 20x.'
return zh
? '山寨币杠杆倍数需要在 1 到 20 倍之间。'
: 'Altcoin leverage must be between 1x and 20x.'
case 'trader.create.invalid_symbol':
return zh ? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。` : `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
return zh
? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。`
: `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
case 'trader.create.model_not_found':
return zh ? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。' : 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
return zh
? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。'
: 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
case 'trader.create.model_disabled':
return zh ? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。` : `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
return zh
? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。`
: `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
case 'trader.create.model_missing_credentials':
return zh ? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。` : `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
return zh
? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。`
: `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
case 'trader.create.strategy_required':
return zh ? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。' : 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
return zh
? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。'
: 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
case 'trader.create.strategy_not_found':
return zh ? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。' : 'The selected strategy no longer exists. Please choose another available strategy.'
return zh
? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。'
: 'The selected strategy no longer exists. Please choose another available strategy.'
case 'trader.create.exchange_not_found':
return zh ? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。' : 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
return zh
? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。'
: 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
case 'trader.create.exchange_disabled':
return zh ? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。` : `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
return zh
? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。`
: `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
case 'trader.create.exchange_missing_fields':
return zh ? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。` : `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
return zh
? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。`
: `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
case 'trader.create.exchange_unsupported':
return zh ? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。` : `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
return zh
? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。`
: `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
case 'trader.create.exchange_probe_failed':
return zh ? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}` : `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
return zh
? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}`
: `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
case 'trader.start.strategy_missing':
return zh ? `机器人「${traderName}」缺少有效的交易策略配置。` : `Trader "${traderName}" does not have a valid strategy configuration.`
return zh
? `机器人「${traderName}」缺少有效的交易策略配置。`
: `Trader "${traderName}" does not have a valid strategy configuration.`
case 'trader.start.model_not_found':
return zh ? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。` : `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
return zh
? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。`
: `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
case 'trader.start.model_disabled':
return zh ? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。` : `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
return zh
? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。`
: `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
case 'trader.start.exchange_not_found':
return zh ? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。` : `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
return zh
? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。`
: `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
case 'trader.start.exchange_disabled':
return zh ? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。` : `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
return zh
? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。`
: `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
case 'trader.start.setup_invalid':
case 'trader.start.load_failed':
return zh ? `机器人「${traderName}」暂时还不能启动,原因是:${reason}` : `Trader "${traderName}" cannot be started yet because ${reason}`
return zh
? `机器人「${traderName}」暂时还不能启动,原因是:${reason}`
: `Trader "${traderName}" cannot be started yet because ${reason}`
default:
return fallback
}
@@ -131,34 +176,69 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
switch (reasonKey) {
case 'trader.reason.strategy_config_invalid':
return zh ? '当前策略配置内容已损坏,系统暂时无法解析' : 'the current strategy configuration is corrupted and cannot be parsed'
return zh
? '当前策略配置内容已损坏,系统暂时无法解析'
: 'the current strategy configuration is corrupted and cannot be parsed'
case 'trader.reason.strategy_missing':
return zh ? '当前机器人缺少有效的交易策略配置' : 'the trader is missing a valid strategy configuration'
return zh
? '当前机器人缺少有效的交易策略配置'
: 'the trader is missing a valid strategy configuration'
case 'trader.reason.private_key_invalid':
return zh ? '私钥格式不正确,系统无法识别' : 'the private key format is invalid and cannot be recognized'
return zh
? '私钥格式不正确,系统无法识别'
: 'the private key format is invalid and cannot be recognized'
case 'trader.reason.hyperliquid_init_failed':
return zh ? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确' : 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
return zh
? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确'
: 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
case 'trader.reason.aster_init_failed':
return zh ? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确' : 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
return zh
? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确'
: 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
case 'trader.reason.exchange_meta_unavailable':
return zh ? '系统暂时无法从交易所读取账户元信息' : 'the system could not read account metadata from the exchange'
return zh
? '系统暂时无法从交易所读取账户元信息'
: 'the system could not read account metadata from the exchange'
case 'trader.reason.hyperliquid_agent_balance_too_high':
return zh ? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求' : 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
return zh
? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求'
: 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
case 'trader.reason.exchange_account_init_failed':
return zh ? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配' : 'exchange account initialization failed. Please verify that the wallet address and API key match'
return zh
? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配'
: 'exchange account initialization failed. Please verify that the wallet address and API key match'
case 'trader.reason.exchange_unsupported':
return zh ? '当前交易所类型暂不支持机器人初始化' : 'the selected exchange type is not currently supported for trader initialization'
return zh
? '当前交易所类型暂不支持机器人初始化'
: 'the selected exchange type is not currently supported for trader initialization'
case 'trader.reason.exchange_balance_unavailable':
return zh ? '系统暂时无法从交易所读取账户余额' : 'the system could not read the account balance from the exchange'
return zh
? '系统暂时无法从交易所读取账户余额'
: 'the system could not read the account balance from the exchange'
case 'trader.reason.exchange_service_unreachable':
return zh ? '系统暂时无法连接交易所服务' : 'the system could not reach the exchange service right now'
return zh
? '系统暂时无法连接交易所服务'
: 'the system could not reach the exchange service right now'
default:
return fallback || (zh ? '系统返回了一个未知错误' : 'an unknown error was returned by the system')
return (
fallback ||
(zh
? '系统返回了一个未知错误'
: 'an unknown error was returned by the system')
)
}
}
const normalizeActionableDescription = (error: unknown, message: string, title: string) => {
const normalizeActionableDescription = (
error: unknown,
message: string,
title: string
) => {
if (error instanceof ApiError && error.errorKey) {
return formatActionableDescriptionByKey(error.errorKey, error.errorParams, message)
return formatActionableDescriptionByKey(
error.errorKey,
error.errorParams,
message
)
}
const prefixes = [
@@ -247,12 +327,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const navigateInApp = (path: string) => {
navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
// Toggle wallet address visibility for a trader
const toggleTraderAddressVisibility = (traderId: string) => {
setVisibleTraderAddresses(prev => {
setVisibleTraderAddresses((prev) => {
const next = new Set(prev)
if (next.has(traderId)) {
next.delete(traderId)
@@ -265,7 +344,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
// Toggle wallet address visibility for an exchange
const toggleExchangeAddressVisibility = (exchangeId: string) => {
setVisibleExchangeAddresses(prev => {
setVisibleExchangeAddresses((prev) => {
const next = new Set(prev)
if (next.has(exchangeId)) {
next.delete(exchangeId)
@@ -287,11 +366,13 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
}
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
api.getTraders,
{ refreshInterval: 5000 }
)
const {
data: traders,
mutate: mutateTraders,
isLoading: isTradersLoading,
} = useSWR<TraderInfo[]>(user && token ? 'traders' : null, api.getTraders, {
refreshInterval: 5000,
})
const {
data: exchangeAccountStateData,
mutate: mutateExchangeAccountStates,
@@ -323,18 +404,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
try {
const [
modelConfigs,
exchangeConfigs,
models,
] = await Promise.all([
const [modelConfigs, exchangeConfigs, models] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
])
setAllModels(modelConfigs)
const clawWalletAddress =
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
modelConfigs.find((model) => model.provider === 'claw402')
?.walletAddress || null
if (clawWalletAddress) {
setBeginnerWalletAddress(clawWalletAddress)
persistBeginnerWalletAddress(clawWalletAddress)
@@ -365,10 +443,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}) || []
const enabledModels = allModels?.filter((m) => m.enabled) || []
const enabledClaw402Model = enabledModels.find((model) => model.provider === 'claw402') || null
const enabledClaw402Balance = parseBalanceUsdc(enabledClaw402Model?.balanceUsdc)
const enabledClaw402Model =
enabledModels.find((model) => model.provider === 'claw402') || null
const enabledClaw402Balance = parseBalanceUsdc(
enabledClaw402Model?.balanceUsdc
)
const claw402BalanceAlert =
enabledClaw402Model && enabledClaw402Balance !== null && enabledClaw402Balance < 1
enabledClaw402Model &&
enabledClaw402Balance !== null &&
enabledClaw402Balance < 1
? {
blocking: enabledClaw402Balance <= 0,
title:
@@ -379,7 +462,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
: enabledClaw402Balance <= 0
? 'Claw402 wallet balance is zero'
: 'Claw402 wallet balance is low',
description: getClaw402BalanceMessage(enabledClaw402Balance, enabledClaw402Balance <= 0),
description: getClaw402BalanceMessage(
enabledClaw402Balance,
enabledClaw402Balance <= 0
),
}
: null
const enabledExchanges =
@@ -415,7 +501,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
const getExchangeUsageInfo = (exchangeId: string) => {
const usingTraders = traders?.filter((tr) => tr.exchange_id === exchangeId) || []
const usingTraders =
traders?.filter((tr) => tr.exchange_id === exchangeId) || []
const runningCount = usingTraders.filter((tr) => tr.is_running).length
const totalCount = usingTraders.length
return { runningCount, totalCount, usingTraders }
@@ -548,17 +635,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} catch (error) {
console.error('Failed to toggle trader:', error)
showActionableError(
running ? t('aiTradersToast.stopFailed', language) : t('aiTradersToast.startFailed', language),
running
? t('aiTradersToast.stopFailed', language)
: t('aiTradersToast.startFailed', language),
error
)
}
}
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
const handleToggleCompetition = async (
traderId: string,
currentShowInCompetition: boolean
) => {
try {
const newValue = !currentShowInCompetition
await api.toggleCompetition(traderId, newValue)
toast.success(newValue ? t('aiTradersToast.showInCompetition', language) : t('aiTradersToast.hideInCompetition', language))
toast.success(
newValue
? t('aiTradersToast.showInCompetition', language)
: t('aiTradersToast.hideInCompetition', language)
)
await mutateTraders()
} catch (error) {
@@ -888,10 +984,13 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
}
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
const claw402Configured = configuredModels.some(
(model) => model.provider === 'claw402'
)
const hasStrategies = (strategies?.length || 0) > 0
const hasCreatedTrader = (traders?.length || 0) > 0
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
const canCreateTrader =
configuredModels.length > 0 && configuredExchanges.length > 0
return (
<DeepVoidBackground className="py-8" disableAnimation>
@@ -952,7 +1051,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<button
onClick={() => setShowCreateModal(true)}
disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
disabled={
configuredModels.length === 0 ||
configuredExchanges.length === 0
}
className="group relative px-6 py-2 rounded text-xs font-bold font-mono uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap overflow-hidden bg-nofx-gold text-black hover:bg-yellow-400 shadow-[0_0_20px_rgba(240,185,11,0.2)] hover:shadow-[0_0_30px_rgba(240,185,11,0.4)]"
>
<span className="relative z-10 flex items-center gap-2">
@@ -984,15 +1086,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<div
className="mb-6 rounded-xl border px-4 py-4 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start md:justify-between gap-3"
style={{
borderColor: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.55)' : 'rgba(245, 158, 11, 0.45)',
background: claw402BalanceAlert.blocking ? 'rgba(127, 29, 29, 0.22)' : 'rgba(120, 53, 15, 0.18)',
borderColor: claw402BalanceAlert.blocking
? 'rgba(239, 68, 68, 0.55)'
: 'rgba(245, 158, 11, 0.45)',
background: claw402BalanceAlert.blocking
? 'rgba(127, 29, 29, 0.22)'
: 'rgba(120, 53, 15, 0.18)',
}}
>
<div className="flex items-start gap-3">
<div
className="mt-0.5 rounded-full p-2"
style={{
background: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.16)' : 'rgba(245, 158, 11, 0.14)',
background: claw402BalanceAlert.blocking
? 'rgba(239, 68, 68, 0.16)'
: 'rgba(245, 158, 11, 0.14)',
color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
}}
>
@@ -1001,11 +1109,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<div>
<div
className="text-sm font-semibold"
style={{ color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A' }}
style={{
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
}}
>
{claw402BalanceAlert.title}
</div>
<div className="text-sm mt-1 leading-6" style={{ color: '#D4D4D8' }}>
<div
className="text-sm mt-1 leading-6"
style={{ color: '#D4D4D8' }}
>
{claw402BalanceAlert.description}
</div>
</div>
@@ -1013,10 +1126,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<button
type="button"
onClick={() => enabledClaw402Model && handleModelClick(enabledClaw402Model.id)}
onClick={() =>
enabledClaw402Model && handleModelClick(enabledClaw402Model.id)
}
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
style={{
borderColor: claw402BalanceAlert.blocking ? 'rgba(248, 113, 113, 0.45)' : 'rgba(251, 191, 36, 0.35)',
borderColor: claw402BalanceAlert.blocking
? 'rgba(248, 113, 113, 0.45)'
: 'rgba(251, 191, 36, 0.35)',
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
background: 'rgba(0, 0, 0, 0.18)',
}}
+18 -12
View File
@@ -1,8 +1,10 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import { flushSync } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { getSystemConfig, invalidateSystemConfig } from '../lib/config'
import { reset401Flag, httpClient } from '../lib/httpClient'
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
import { ROUTES } from '../router/paths'
import { useLanguage } from './LanguageContext'
interface User {
@@ -43,6 +45,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { language } = useLanguage()
const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -120,8 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
sessionStorage.removeItem('returnUrl')
}
window.history.pushState({}, '', nextPath)
window.dispatchEvent(new PopStateEvent('popstate'))
navigate(nextPath)
}
const login = async (email: string, password: string, mode?: UserMode) => {
@@ -145,7 +147,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
// Unexpected success response
return { success: false, message: data.message || 'Unexpected login response' }
return {
success: false,
message: data.message || 'Unexpected login response',
}
} else {
return {
success: false,
@@ -184,12 +189,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const returnUrl = sessionStorage.getItem('returnUrl')
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
window.history.pushState({}, '', returnUrl)
window.dispatchEvent(new PopStateEvent('popstate'))
navigate(returnUrl)
} else {
// Redirect to dashboard
window.history.pushState({}, '', '/dashboard')
window.dispatchEvent(new PopStateEvent('popstate'))
navigate(ROUTES.dashboard)
}
return { success: true }
} else {
@@ -244,13 +247,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
message: result.message || 'Registration failed',
}
} catch (error) {
console.error('Auth register error:', error);
console.error('Auth register error:', error)
// Re-throw if it's a critical error, or return structured error
// Since httpClient throws on 500, we should return a structured error response
// to let the UI display it gracefully without crashing.
return {
success: false,
message: error instanceof Error ? error.message : 'Detailed server error'
message:
error instanceof Error ? error.message : 'Detailed server error',
}
}
}
@@ -276,7 +280,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: 'Password reset failed, please try again' }
return {
success: false,
message: 'Password reset failed, please try again',
}
}
}
@@ -295,8 +302,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
invalidateSystemConfig()
window.history.pushState({}, '', '/')
window.dispatchEvent(new PopStateEvent('popstate'))
navigate(ROUTES.home)
}
return (
+49 -23
View File
@@ -1,21 +1,19 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowRight,
Copy,
RefreshCw,
Shield,
Wallet,
X,
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { ArrowRight, Copy, RefreshCw, Shield, Wallet, X } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { toast } from 'sonner'
import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api'
import type { BeginnerOnboardingResponse } from '../types'
import { setBeginnerWalletAddress, markBeginnerOnboardingCompleted } from '../lib/onboarding'
import {
setBeginnerWalletAddress,
markBeginnerOnboardingCompleted,
} from '../lib/onboarding'
export function BeginnerOnboardingPage() {
const { language } = useLanguage()
const navigate = useNavigate()
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -79,8 +77,7 @@ export function BeginnerOnboardingPage() {
const handleContinue = () => {
markBeginnerOnboardingCompleted()
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
navigate('/traders')
}
return (
@@ -104,7 +101,9 @@ export function BeginnerOnboardingPage() {
<div>
<div
className={`font-semibold uppercase text-nofx-gold/80 ${
isZh ? 'text-[11px] tracking-[0.34em]' : 'text-[10px] tracking-[0.2em]'
isZh
? 'text-[11px] tracking-[0.34em]'
: 'text-[10px] tracking-[0.2em]'
}`}
>
{isZh ? '新手保护' : 'Beginner Guard'}
@@ -136,7 +135,9 @@ export function BeginnerOnboardingPage() {
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,11,16,0.94),rgba(5,7,10,0.88))] shadow-[0_24px_120px_rgba(0,0,0,0.58)] backdrop-blur-2xl">
{loading ? (
<div className="flex min-h-[390px] items-center justify-center px-6 text-sm text-zinc-400">
{isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
{isZh
? '正在准备你的 Base 钱包...'
: 'Preparing your Base wallet...'}
</div>
) : data ? (
<div className="grid lg:grid-cols-[0.82fr_1.18fr]">
@@ -147,13 +148,17 @@ export function BeginnerOnboardingPage() {
</div>
<div className="mt-4 text-[15px] font-medium text-zinc-300">
{isZh ? '充值地址(Base USDC' : 'Deposit address (Base USDC)'}
{isZh
? '充值地址(Base USDC'
: 'Deposit address (Base USDC)'}
</div>
<div className="mt-4 flex items-center justify-between gap-3 rounded-[24px] border border-emerald-400/20 bg-emerald-500/7 px-5 py-3.5 shadow-[0_0_0_1px_rgba(16,185,129,0.08)]">
<div className="text-left">
<div className="flex items-baseline gap-3 font-mono font-bold tracking-tight text-emerald-300">
<span className="text-[22px]">{data.balance_usdc}</span>
<span className="text-[22px]">
{data.balance_usdc}
</span>
<span className="text-[20px]">USDC</span>
</div>
</div>
@@ -164,12 +169,16 @@ export function BeginnerOnboardingPage() {
className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-emerald-300/20 bg-black/20 text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={isZh ? '刷新余额' : 'Refresh balance'}
>
<RefreshCw className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`}
/>
</button>
</div>
<div className="mt-4 text-sm text-zinc-500">
{isZh ? '$5-$10 可以用很久' : '$5-$10 usually lasts a long time'}
{isZh
? '$5-$10 可以用很久'
: '$5-$10 usually lasts a long time'}
</div>
</div>
</section>
@@ -187,7 +196,9 @@ export function BeginnerOnboardingPage() {
</div>
<button
type="button"
onClick={() => copyText(data.address, isZh ? '地址' : 'Address')}
onClick={() =>
copyText(data.address, isZh ? '地址' : 'Address')
}
className="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-zinc-300 transition hover:border-white/20 hover:bg-white/10 hover:text-white"
aria-label={isZh ? '复制地址' : 'Copy address'}
>
@@ -199,16 +210,27 @@ export function BeginnerOnboardingPage() {
<div className="pt-1">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-nofx-gold">
<Shield className="h-4 w-4" />
<span>{isZh ? '私钥,请立即备份' : 'Private key, back it up now'}</span>
<span>
{isZh
? '私钥,请立即备份'
: 'Private key, back it up now'}
</span>
</div>
<div className="flex items-stretch gap-3">
<div className="min-w-0 flex-1 rounded-[24px] border border-nofx-gold/20 bg-[linear-gradient(180deg,rgba(32,25,7,0.44),rgba(14,10,3,0.28))] px-5 py-3 font-mono text-[13px] leading-6 text-amber-100 shadow-[0_0_0_1px_rgba(240,185,11,0.05)]">
<div className="overflow-x-auto whitespace-nowrap">{data.private_key}</div>
<div className="overflow-x-auto whitespace-nowrap">
{data.private_key}
</div>
</div>
<div className="flex shrink-0 flex-col justify-end">
<button
type="button"
onClick={() => copyText(data.private_key, isZh ? '私钥' : 'Private key')}
onClick={() =>
copyText(
data.private_key,
isZh ? '私钥' : 'Private key'
)
}
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-nofx-gold/20 bg-nofx-gold/10 text-nofx-gold transition hover:bg-nofx-gold/15"
aria-label={isZh ? '复制私钥' : 'Copy private key'}
>
@@ -220,7 +242,9 @@ export function BeginnerOnboardingPage() {
<div
className={`rounded-[24px] border border-white/15 bg-black/18 px-5 py-3.5 text-zinc-500 ${
isZh ? 'text-xs lg:whitespace-nowrap' : 'text-[11px] leading-6'
isZh
? 'text-xs lg:whitespace-nowrap'
: 'text-[11px] leading-6'
}`}
>
<span className="mr-2 text-zinc-600"></span>
@@ -246,7 +270,9 @@ export function BeginnerOnboardingPage() {
isZh ? 'text-[20px]' : 'text-[16px] sm:text-[18px]'
}`}
>
<span>{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}</span>
<span>
{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}
</span>
<ArrowRight className="h-5 w-5" />
</button>
-16
View File
@@ -34,24 +34,8 @@ export function LandingPage() {
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={(page) => {
const pathMap: Record<string, string> = {
'data': '/data',
'competition': '/competition',
'strategy-market': '/strategy-market',
'traders': '/traders',
'trader': '/dashboard',
'strategy': '/strategy',
'faq': '/faq',
}
const path = pathMap[page]
if (path) {
window.location.href = path
}
}}
/>
<div className="min-h-screen bg-nofx-bg text-nofx-text font-sans selection:bg-nofx-gold selection:text-black">
<TerminalHero />
<LiveFeed />
+140 -41
View File
@@ -1,6 +1,17 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react'
import {
User,
Cpu,
Building2,
MessageCircle,
Eye,
EyeOff,
ChevronRight,
Plus,
Pencil,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api'
@@ -20,8 +31,11 @@ type Tab = 'account' | 'models' | 'exchanges' | 'telegram'
export function SettingsPage() {
const { user } = useAuth()
const { language } = useLanguage()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('account')
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
const [userMode, setUserModeState] = useState<UserMode>(
() => getUserMode() ?? 'advanced'
)
// Account state
const [newPassword, setNewPassword] = useState('')
@@ -53,7 +67,8 @@ export function SettingsPage() {
.catch(() => toast.error('Failed to load AI models'))
}
if (activeTab === 'exchanges') {
api.getExchangeConfigs()
api
.getExchangeConfigs()
.then(setExchanges)
.catch(() => toast.error('Failed to load exchanges'))
}
@@ -82,7 +97,9 @@ export function SettingsPage() {
toast.success('Password updated successfully')
setNewPassword('')
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update password')
toast.error(
err instanceof Error ? err.message : 'Failed to update password'
)
} finally {
setChangingPassword(false)
}
@@ -104,8 +121,7 @@ export function SettingsPage() {
)
const nextPath = getPostAuthPath(nextMode)
window.history.pushState({}, '', nextPath)
window.dispatchEvent(new PopStateEvent('popstate'))
navigate(nextPath)
}
const handleSaveModel = async (
@@ -118,33 +134,48 @@ export function SettingsPage() {
const existingModel = configuredModels.find((m) => m.id === modelId)
const modelTemplate = supportedModels.find((m) => m.id === modelId)
const modelToUpdate = existingModel || modelTemplate
if (!modelToUpdate) { toast.error('Model not found'); return }
if (!modelToUpdate) {
toast.error('Model not found')
return
}
let updatedModels: AIModel[]
if (existingModel) {
updatedModels = configuredModels.map((m) =>
m.id === modelId
? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true }
? {
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
: m
)
} else {
updatedModels = [...configuredModels, {
updatedModels = [
...configuredModels,
{
...modelToUpdate,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}]
},
]
}
const request = {
models: Object.fromEntries(
updatedModels.map((m) => [m.provider, {
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, {
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)
@@ -275,7 +317,10 @@ export function SettingsPage() {
]
return (
<div className="min-h-screen pt-20 pb-12 px-4" style={{ background: '#0B0E11' }}>
<div
className="min-h-screen pt-20 pb-12 px-4"
style={{ background: '#0B0E11' }}
>
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-bold text-white mb-6">Settings</h1>
@@ -286,7 +331,8 @@ 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
${
activeTab === tab.key
? 'bg-nofx-gold text-black'
: 'text-zinc-400 hover:text-white'
}`}
@@ -299,7 +345,6 @@ export function SettingsPage() {
{/* Tab Content */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
{/* Account Tab */}
{activeTab === 'account' && (
<div className="space-y-6">
@@ -322,8 +367,12 @@ export function SettingsPage() {
</div>
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
{userMode === 'beginner'
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner'
: language === 'zh' ? '当前:手模式' : 'Current: Advanced'}
? language === 'zh'
? '当前:手模式'
: 'Current: Beginner'
: language === 'zh'
? '当前:老手模式'
: 'Current: Advanced'}
</span>
</div>
@@ -369,10 +418,14 @@ export function SettingsPage() {
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
<h3 className="text-sm font-semibold text-white mb-4">
Change Password
</h3>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-2">New Password</label>
<label className="block text-xs font-medium text-zinc-400 mb-2">
New Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
@@ -387,7 +440,11 @@ export function SettingsPage() {
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
{showPassword ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
</div>
</div>
@@ -408,10 +465,14 @@ export function SettingsPage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-400">
{configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured
{configuredModels.length} model
{configuredModels.length !== 1 ? 's' : ''} configured
</p>
<button
onClick={() => { setEditingModel(null); setShowModelModal(true) }}
onClick={() => {
setEditingModel(null)
setShowModelModal(true)
}}
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
>
<Plus size={14} />
@@ -428,7 +489,10 @@ export function SettingsPage() {
{configuredModels.map((model) => (
<button
key={model.id}
onClick={() => { setEditingModel(model.id); setShowModelModal(true) }}
onClick={() => {
setEditingModel(model.id)
setShowModelModal(true)
}}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
>
<div className="flex items-center gap-3">
@@ -436,15 +500,24 @@ export function SettingsPage() {
<Cpu size={14} className="text-zinc-300" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{model.name}</p>
<p className="text-xs text-zinc-500">{model.provider}</p>
<p className="text-sm font-medium text-white">
{model.name}
</p>
<p className="text-xs text-zinc-500">
{model.provider}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}>
<span
className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}
>
{model.enabled ? 'Active' : 'Inactive'}
</span>
<Pencil size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
<Pencil
size={14}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
</div>
</button>
))}
@@ -458,10 +531,14 @@ export function SettingsPage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-400">
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''}{' '}
connected
</p>
<button
onClick={() => { setEditingExchange(null); setShowExchangeModal(true) }}
onClick={() => {
setEditingExchange(null)
setShowExchangeModal(true)
}}
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
>
<Plus size={14} />
@@ -478,7 +555,10 @@ export function SettingsPage() {
{exchanges.map((exchange) => (
<button
key={exchange.id}
onClick={() => { setEditingExchange(exchange.id); setShowExchangeModal(true) }}
onClick={() => {
setEditingExchange(exchange.id)
setShowExchangeModal(true)
}}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
>
<div className="flex items-center gap-3">
@@ -486,11 +566,18 @@ export function SettingsPage() {
<Building2 size={14} className="text-zinc-300" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p>
<p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p>
<p className="text-sm font-medium text-white">
{exchange.account_name || exchange.name}
</p>
<p className="text-xs text-zinc-500 capitalize">
{exchange.exchange_type || exchange.type}
</p>
</div>
</div>
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
<ChevronRight
size={14}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
</button>
))}
</div>
@@ -502,7 +589,8 @@ export function SettingsPage() {
{activeTab === 'telegram' && (
<div className="space-y-4">
<p className="text-sm text-zinc-400">
Connect a Telegram bot to receive trading notifications and interact with your traders.
Connect a Telegram bot to receive trading notifications and
interact with your traders.
</p>
<button
onClick={() => setShowTelegramModal(true)}
@@ -512,9 +600,14 @@ export function SettingsPage() {
<div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center">
<MessageCircle size={14} className="text-[#0088cc]" />
</div>
<span className="text-sm font-medium text-white">Configure Telegram Bot</span>
<span className="text-sm font-medium text-white">
Configure Telegram Bot
</span>
</div>
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
<ChevronRight
size={14}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
</button>
</div>
)}
@@ -530,7 +623,10 @@ export function SettingsPage() {
editingModelId={editingModel}
onSave={handleSaveModel}
onDelete={handleDeleteModel}
onClose={() => { setShowModelModal(false); setEditingModel(null) }}
onClose={() => {
setShowModelModal(false)
setEditingModel(null)
}}
language={language}
/>
</div>
@@ -544,7 +640,10 @@ export function SettingsPage() {
editingExchangeId={editingExchange}
onSave={handleSaveExchange}
onDelete={handleDeleteExchange}
onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }}
onClose={() => {
setShowExchangeModal(false)
setEditingExchange(null)
}}
language={language}
/>
</div>
+156 -57
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import useSWR from 'swr'
import {
TrendingUp,
@@ -15,7 +16,7 @@ import {
Activity,
Terminal,
Cpu,
Database
Database,
} from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext'
@@ -39,14 +40,24 @@ interface PublicStrategy {
updated_at: string
}
const strategyStyles: Record<string, { color: string; border: string; glow: string; shadow: string; icon: any; bg: string }> = {
const strategyStyles: Record<
string,
{
color: string
border: string
glow: string
shadow: string
icon: any
bg: string
}
> = {
scalper: {
color: 'text-[#F0B90B]',
border: 'border-[#F0B90B]/30',
glow: 'shadow-[0_0_20px_rgba(240,185,11,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(240,185,11,0.25)]',
bg: 'bg-[#F0B90B]/5',
icon: Zap
icon: Zap,
},
swing: {
color: 'text-cyan-400',
@@ -54,7 +65,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
glow: 'shadow-[0_0_20px_rgba(34,211,238,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(34,211,238,0.25)]',
bg: 'bg-cyan-400/5',
icon: TrendingUp
icon: TrendingUp,
},
arbitrage: {
color: 'text-purple-400',
@@ -62,7 +73,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
glow: 'shadow-[0_0_20px_rgba(192,132,252,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(192,132,252,0.25)]',
bg: 'bg-purple-400/5',
icon: Layers
icon: Layers,
},
conservative: {
color: 'text-emerald-400',
@@ -70,7 +81,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
glow: 'shadow-[0_0_20px_rgba(52,211,153,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(52,211,153,0.25)]',
bg: 'bg-emerald-400/5',
icon: Shield
icon: Shield,
},
aggressive: {
color: 'text-red-500',
@@ -78,7 +89,7 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
glow: 'shadow-[0_0_20px_rgba(239,68,68,0.15)]',
shadow: 'hover:shadow-[0_0_30px_rgba(239,68,68,0.25)]',
bg: 'bg-red-500/5',
icon: Target
icon: Target,
},
default: {
color: 'text-zinc-400',
@@ -86,8 +97,8 @@ const strategyStyles: Record<string, { color: string; border: string; glow: stri
glow: '',
shadow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.05)]',
bg: 'bg-zinc-800/20',
icon: Activity
}
icon: Activity,
},
}
function getStrategyStyle(name: string) {
@@ -95,12 +106,15 @@ function getStrategyStyle(name: string) {
if (lowerName.includes('scalp')) return strategyStyles.scalper
if (lowerName.includes('swing')) return strategyStyles.swing
if (lowerName.includes('arb')) return strategyStyles.arbitrage
if (lowerName.includes('safe') || lowerName.includes('conserv')) return strategyStyles.conservative
if (lowerName.includes('aggress') || lowerName.includes('high')) return strategyStyles.aggressive
if (lowerName.includes('safe') || lowerName.includes('conserv'))
return strategyStyles.conservative
if (lowerName.includes('aggress') || lowerName.includes('high'))
return strategyStyles.aggressive
return strategyStyles.default
}
export function StrategyMarketPage() {
const navigate = useNavigate()
const { language } = useLanguage()
const { token, user } = useAuth()
const [searchQuery, setSearchQuery] = useState('')
@@ -120,15 +134,18 @@ export function StrategyMarketPage() {
},
{
refreshInterval: 60000,
revalidateOnFocus: false
revalidateOnFocus: false,
}
)
const filteredStrategies = strategies?.filter(s => {
const filteredStrategies =
strategies?.filter((s) => {
if (searchQuery) {
const query = searchQuery.toLowerCase()
return s.name.toLowerCase().includes(query) ||
return (
s.name.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query)
)
}
return true
}) || []
@@ -136,7 +153,9 @@ export function StrategyMarketPage() {
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', {
return date
.toLocaleDateString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(',', '')
hour12: false,
})
.replace(',', '')
}
const getIndicatorList = (config: any) => {
@@ -174,15 +195,15 @@ export function StrategyMarketPage() {
return (
<DeepVoidBackground className="min-h-screen text-white font-mono py-12">
<div className="w-full px-4 md:px-8 space-y-8">
<div className="w-full relative z-10">
{/* Header Section */}
<div className="mb-12 border-b border-zinc-800 pb-8 relative">
<div className="absolute top-0 right-0 p-2 border border-zinc-800 rounded bg-black/50 text-xs text-zinc-500 font-mono hidden md:block">
SYSTEM_STATUS: <span className="text-emerald-500 animate-pulse">ONLINE</span>
SYSTEM_STATUS:{' '}
<span className="text-emerald-500 animate-pulse">ONLINE</span>
<br />
MARKET_UPLINK: <span className="text-emerald-500">ESTABLISHED</span>
MARKET_UPLINK:{' '}
<span className="text-emerald-500">ESTABLISHED</span>
</div>
<div className="flex items-center gap-4 mb-4">
@@ -191,11 +212,15 @@ export function StrategyMarketPage() {
<Database className="w-8 h-8 text-nofx-gold relative z-10" />
</div>
<div>
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text" data-text={tr('title')}>
<h1
className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text"
data-text={tr('title')}
>
{tr('title')}
</h1>
<p className="text-xs text-nofx-gold tracking-[0.3em] font-bold mt-1">
// {tr('subtitle')}
{'// '}
{tr('subtitle')}
</p>
</div>
</div>
@@ -232,7 +257,8 @@ export function StrategyMarketPage() {
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${selectedCategory === cat
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${
selectedCategory === cat
? 'text-black font-bold'
: 'text-zinc-500 hover:text-white'
}`}
@@ -241,7 +267,11 @@ export function StrategyMarketPage() {
<motion.div
layoutId="filter-highlight"
className="absolute inset-0 bg-nofx-gold"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
transition={{
type: 'spring',
bounce: 0.2,
duration: 0.6,
}}
/>
)}
<span className="relative z-10">{tr(cat)}</span>
@@ -260,11 +290,22 @@ export function StrategyMarketPage() {
<Cpu size={24} className="text-nofx-gold/50" />
</div>
</div>
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">{tr('loading')}</p>
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">
{tr('loading')}
</p>
<div className="flex gap-1">
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div>
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div>
<div
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
style={{ animationDelay: '0s' }}
></div>
<div
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
style={{ animationDelay: '0.2s' }}
></div>
<div
className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce"
style={{ animationDelay: '0.4s' }}
></div>
</div>
</div>
)}
@@ -279,7 +320,9 @@ export function StrategyMarketPage() {
<h3 className="text-xl font-bold text-zinc-300 font-mono tracking-tight mb-2">
[{tr('noStrategies')}]
</h3>
<p className="text-zinc-600 text-xs tracking-wide uppercase">{tr('noStrategiesDesc')}</p>
<p className="text-zinc-600 text-xs tracking-wide uppercase">
{tr('noStrategiesDesc')}
</p>
</div>
)}
@@ -290,7 +333,8 @@ export function StrategyMarketPage() {
{filteredStrategies.map((strategy, i) => {
const style = getStrategyStyle(strategy.name)
const Icon = style.icon
const indicators = strategy.config_visible && strategy.config
const indicators =
strategy.config_visible && strategy.config
? getIndicatorList(strategy.config)
: []
@@ -304,16 +348,24 @@ export function StrategyMarketPage() {
className={`group relative bg-black border border-zinc-800 hover:border-zinc-600 transition-all duration-300 ${style.shadow}`}
>
{/* Holographic Border Highlight */}
<div className={`absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
<div className={`absolute bottom-0 right-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
<div
className={`absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
></div>
<div
className={`absolute bottom-0 right-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
></div>
{/* Category Side Strip */}
<div className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}></div>
<div
className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}
></div>
<div className="p-6 relative">
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div className={`p-2 rounded-none border ${style.border} ${style.bg}`}>
<div
className={`p-2 rounded-none border ${style.border} ${style.bg}`}
>
<Icon className={`w-5 h-5 ${style.color}`} />
</div>
<div className="text-[10px] font-mono">
@@ -332,7 +384,9 @@ export function StrategyMarketPage() {
</div>
{/* Name and Description */}
<h3 className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}>
<h3
className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}
>
{strategy.name}
<span className="absolute -bottom-1 left-0 w-8 h-[2px] bg-zinc-800 group-hover:bg-nofx-gold transition-colors"></span>
</h3>
@@ -343,12 +397,22 @@ export function StrategyMarketPage() {
{/* Meta Data */}
<div className="grid grid-cols-2 gap-y-2 mb-6 text-[10px] font-mono text-zinc-600">
<div className="flex flex-col">
<span className="text-zinc-700 uppercase">{tr('author')}</span>
<span className="text-zinc-400 group-hover:text-white transition-colors">@{strategy.author_email?.split('@')[0] || 'UNKNOWN'}</span>
<span className="text-zinc-700 uppercase">
{tr('author')}
</span>
<span className="text-zinc-400 group-hover:text-white transition-colors">
@
{strategy.author_email?.split('@')[0] ||
'UNKNOWN'}
</span>
</div>
<div className="flex flex-col text-right">
<span className="text-zinc-700 uppercase">{tr('createdAt')}</span>
<span className="text-zinc-400">{formatDate(strategy.created_at)}</span>
<span className="text-zinc-700 uppercase">
{tr('createdAt')}
</span>
<span className="text-zinc-400">
{formatDate(strategy.created_at)}
</span>
</div>
</div>
@@ -358,14 +422,20 @@ export function StrategyMarketPage() {
<div className="space-y-3">
{/* Indicators */}
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pb-1">
{indicators.length > 0 ? indicators.map((ind) => (
{indicators.length > 0 ? (
indicators.map((ind) => (
<span
key={ind}
className="px-1.5 py-0.5 border border-zinc-700 bg-zinc-800 text-[9px] text-zinc-300 font-mono whitespace-nowrap"
>
{ind}
</span>
)) : <span className="text-[9px] text-zinc-600">NO_INDICATORS</span>}
))
) : (
<span className="text-[9px] text-zinc-600">
NO_INDICATORS
</span>
)}
</div>
{/* Risk Control */}
@@ -373,22 +443,38 @@ export function StrategyMarketPage() {
<div className="flex justify-between items-center text-[10px]">
<div className="flex gap-3">
<div className="flex flex-col">
<span className="text-zinc-600 scale-90 origin-left">LEV</span>
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.btc_eth_max_leverage || '-'}x</span>
<span className="text-zinc-600 scale-90 origin-left">
LEV
</span>
<span className="text-zinc-300 font-bold">
{strategy.config.risk_control
.btc_eth_max_leverage || '-'}
x
</span>
</div>
<div className="flex flex-col">
<span className="text-zinc-600 scale-90 origin-left">POS</span>
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.max_positions || '-'}</span>
<span className="text-zinc-600 scale-90 origin-left">
POS
</span>
<span className="text-zinc-300 font-bold">
{strategy.config.risk_control
.max_positions || '-'}
</span>
</div>
</div>
<Activity size={12} className="text-zinc-700" />
<Activity
size={12}
className="text-zinc-700"
/>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-zinc-600">
<EyeOff size={16} className="mb-1 opacity-50" />
<span className="text-[9px] uppercase tracking-widest">{tr('configHiddenDesc')}</span>
<span className="text-[9px] uppercase tracking-widest">
{tr('configHiddenDesc')}
</span>
</div>
)}
</div>
@@ -403,7 +489,9 @@ export function StrategyMarketPage() {
{copiedId === strategy.id ? (
<>
<Check className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-500">{tr('copied')}</span>
<span className="text-emerald-500">
{tr('copied')}
</span>
</>
) : (
<>
@@ -413,13 +501,15 @@ export function StrategyMarketPage() {
)}
</button>
) : (
<button disabled className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-800 bg-black text-zinc-700 cursor-not-allowed flex items-center justify-center gap-2">
<button
disabled
className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-800 bg-black text-zinc-700 cursor-not-allowed flex items-center justify-center gap-2"
>
<Shield size={12} />
{tr('hideConfig')}
</button>
)}
</div>
</div>
</motion.div>
)
@@ -436,13 +526,23 @@ export function StrategyMarketPage() {
transition={{ delay: 0.3 }}
className="mt-16 mb-20 flex justify-center"
>
<div className="relative group cursor-pointer" onClick={() => window.location.href = '/strategy'}>
<div
className="relative group cursor-pointer"
onClick={() => navigate('/strategy')}
>
<div className="absolute -inset-1 bg-gradient-to-r from-nofx-gold to-yellow-600 rounded blur opacity-25 group-hover:opacity-75 transition duration-1000 group-hover:duration-200"></div>
<div className="relative px-8 py-4 bg-black border border-zinc-800 hover:border-nofx-gold/50 flex items-center gap-4 transition-all">
<Hexagon className="text-nofx-gold animate-spin-slow" size={24} />
<Hexagon
className="text-nofx-gold animate-spin-slow"
size={24}
/>
<div className="text-left">
<div className="text-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">{tr('shareYours')}</div>
<div className="text-[10px] text-zinc-500 font-mono">CONTRIBUTE TO THE GLOBAL DATABASE</div>
<div className="text-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">
{tr('shareYours')}
</div>
<div className="text-[10px] text-zinc-500 font-mono">
CONTRIBUTE TO THE GLOBAL DATABASE
</div>
</div>
<div className="w-[1px] h-8 bg-zinc-800 mx-2"></div>
<div className="text-xs font-mono text-zinc-400 group-hover:translate-x-1 transition-transform">
@@ -452,7 +552,6 @@ export function StrategyMarketPage() {
</div>
</motion.div>
)}
</div>
</div>
</DeepVoidBackground>
+541
View File
@@ -0,0 +1,541 @@
import { type ReactNode, useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import useSWR from 'swr'
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom'
import HeaderBar from '../components/common/HeaderBar'
import { SiteFooter } from '../components/common/SiteFooter'
import { LoginRequiredOverlay } from '../components/auth/LoginRequiredOverlay'
import { LoginPage } from '../components/auth/LoginPage'
import { RegisterPage } from '../components/auth/RegisterPage'
import { ResetPasswordPage } from '../components/auth/ResetPasswordPage'
import { SetupPage } from '../components/modals/SetupPage'
import { CompetitionPage } from '../components/trader/CompetitionPage'
import { AITradersPage } from '../components/trader/AITradersPage'
import { FAQPage } from '../pages/FAQPage'
import { LandingPage } from '../pages/LandingPage'
import { BeginnerOnboardingPage } from '../pages/BeginnerOnboardingPage'
import { DataPage } from '../pages/DataPage'
import { SettingsPage } from '../pages/SettingsPage'
import { StrategyMarketPage } from '../pages/StrategyMarketPage'
import { StrategyStudioPage } from '../pages/StrategyStudioPage'
import { TraderDashboardPage } from '../pages/TraderDashboardPage'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { useSystemConfig } from '../hooks/useSystemConfig'
import { t } from '../i18n/translations'
import { api } from '../lib/api'
import { getUserMode } from '../lib/onboarding'
import type {
AccountInfo,
DecisionRecord,
Exchange,
Position,
Statistics,
SystemStatus,
TraderInfo,
} from '../types'
import {
buildDashboardPath,
LEGACY_HASH_ROUTES,
ROUTES,
type Page,
} from './paths'
function getTraderSlug(trader: TraderInfo) {
const idPrefix = trader.trader_id.slice(0, 4)
return `${trader.trader_name}-${idPrefix}`
}
function findTraderBySlug(slug: string, traderList: TraderInfo[]) {
const lastDashIndex = slug.lastIndexOf('-')
if (lastDashIndex === -1) {
return traderList.find((trader) => trader.trader_name === slug)
}
const name = slug.slice(0, lastDashIndex)
const idPrefix = slug.slice(lastDashIndex + 1)
return traderList.find(
(trader) =>
trader.trader_name === name && trader.trader_id.startsWith(idPrefix)
)
}
function LoadingScreen() {
const { language } = useLanguage()
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: '#0B0E11' }}
>
<div className="text-center">
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 mx-auto mb-4 animate-pulse"
/>
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
</div>
</div>
)
}
function LegacyHashRedirect() {
const location = useLocation()
const navigate = useNavigate()
useEffect(() => {
const hashRoute = LEGACY_HASH_ROUTES[location.hash.slice(1)]
if (!hashRoute) {
return
}
if (hashRoute === location.pathname && location.hash === '') {
return
}
navigate(
{
pathname: hashRoute,
search: location.search,
},
{ replace: true }
)
}, [location.hash, location.pathname, location.search, navigate])
return null
}
interface AppChromeProps {
children: ReactNode
currentPage?: Page
showFooter?: boolean
wrapInMain?: boolean
animateContent?: boolean
extraContent?: ReactNode
}
function AppChrome({
children,
currentPage,
showFooter = true,
wrapInMain = true,
animateContent = false,
extraContent,
}: AppChromeProps) {
const location = useLocation()
const { language, setLanguage } = useLanguage()
const { user, logout } = useAuth()
const [loginOverlayOpen, setLoginOverlayOpen] = useState(false)
const [loginOverlayFeature, setLoginOverlayFeature] = useState('')
const handleLoginRequired = (featureName: string) => {
setLoginOverlayFeature(featureName)
setLoginOverlayOpen(true)
}
const content = animateContent ? (
<AnimatePresence mode="wait">
<motion.div
key={`${location.pathname}${location.search}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
{children}
</motion.div>
</AnimatePresence>
) : (
children
)
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage={currentPage}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
/>
{wrapInMain ? (
<main className="min-h-screen pt-16">{content}</main>
) : (
content
)}
{showFooter ? <SiteFooter language={language} /> : null}
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
{extraContent}
</div>
)
}
function TradersRoute({
showBeginnerOnboarding = false,
}: {
showBeginnerOnboarding?: boolean
}) {
const navigate = useNavigate()
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'traders-route' : null,
api.getTraders,
{
refreshInterval: 5000,
shouldRetryOnError: false,
}
)
return (
<AppChrome
currentPage="traders"
animateContent
extraContent={showBeginnerOnboarding ? <BeginnerOnboardingPage /> : null}
>
<AITradersPage
onTraderSelect={(traderId) => {
const trader = traders?.find((item) => item.trader_id === traderId)
navigate(
buildDashboardPath(trader ? getTraderSlug(trader) : undefined)
)
}}
/>
</AppChrome>
)
}
function DashboardRoute() {
const { language } = useLanguage()
const { user, token } = useAuth()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const selectedTraderSlug = searchParams.get('trader') || undefined
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>()
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
const [decisionsLimit, setDecisionsLimit] = useState(5)
const [accountPollOff, setAccountPollOff] = useState(false)
const [positionsPollOff, setPositionsPollOff] = useState(false)
const [decisionsPollOff, setDecisionsPollOff] = useState(false)
useEffect(() => {
setAccountPollOff(false)
setPositionsPollOff(false)
setDecisionsPollOff(false)
}, [selectedTraderId])
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
user && token ? 'traders-dashboard' : null,
() => api.getTraders(true),
{
refreshInterval: 10000,
shouldRetryOnError: false,
}
)
const { data: exchanges } = useSWR<Exchange[]>(
user && token ? 'exchanges-dashboard' : null,
api.getExchangeConfigs,
{
refreshInterval: 60000,
shouldRetryOnError: false,
}
)
useEffect(() => {
if (!traders || traders.length === 0) {
return
}
if (selectedTraderSlug) {
const trader = findTraderBySlug(selectedTraderSlug, traders)
const nextTraderId = trader?.trader_id || traders[0].trader_id
if (nextTraderId !== selectedTraderId) {
setSelectedTraderId(nextTraderId)
}
return
}
if (!selectedTraderId) {
setSelectedTraderId(traders[0].trader_id)
}
}, [selectedTraderId, selectedTraderSlug, traders])
const { data: status } = useSWR<SystemStatus>(
selectedTraderId ? `status-${selectedTraderId}` : null,
() => api.getStatus(selectedTraderId, true),
{
refreshInterval: 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
}
)
const { data: account } = useSWR<AccountInfo>(
selectedTraderId ? `account-${selectedTraderId}` : null,
() => api.getAccount(selectedTraderId, true),
{
refreshInterval: accountPollOff ? 0 : 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) {
setAccountPollOff(true)
return
}
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => {
if (accountPollOff) {
setAccountPollOff(false)
}
},
}
)
const { data: positions } = useSWR<Position[]>(
selectedTraderId ? `positions-${selectedTraderId}` : null,
() => api.getPositions(selectedTraderId, true),
{
refreshInterval: positionsPollOff ? 0 : 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) {
setPositionsPollOff(true)
return
}
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => {
if (positionsPollOff) {
setPositionsPollOff(false)
}
},
}
)
const { data: decisions } = useSWR<DecisionRecord[]>(
selectedTraderId
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
: null,
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
{
refreshInterval: decisionsPollOff ? 0 : 30000,
revalidateOnFocus: false,
dedupingInterval: 20000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) {
setDecisionsPollOff(true)
return
}
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => {
if (decisionsPollOff) {
setDecisionsPollOff(false)
}
},
}
)
const { data: stats } = useSWR<Statistics>(
selectedTraderId ? `statistics-${selectedTraderId}` : null,
() => api.getStatistics(selectedTraderId, true),
{
refreshInterval: 30000,
revalidateOnFocus: false,
dedupingInterval: 20000,
}
)
useEffect(() => {
if (account) {
setLastUpdate(new Date().toLocaleTimeString())
}
}, [account])
const selectedTrader = traders?.find(
(trader) => trader.trader_id === selectedTraderId
)
return (
<AppChrome currentPage="trader" animateContent>
<TraderDashboardPage
selectedTrader={selectedTrader}
status={status}
account={account}
accountFailed={accountPollOff}
positions={positions}
positionsFailed={positionsPollOff}
decisions={decisions}
decisionsFailed={decisionsPollOff}
decisionsLimit={decisionsLimit}
onDecisionsLimitChange={setDecisionsLimit}
stats={stats}
lastUpdate={lastUpdate}
language={language}
traders={traders}
tradersError={tradersError}
selectedTraderId={selectedTraderId}
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
const trader = traders?.find((item) => item.trader_id === traderId)
navigate(
buildDashboardPath(trader ? getTraderSlug(trader) : undefined),
{
replace: true,
}
)
}}
onNavigateToTraders={() => navigate(ROUTES.traders)}
exchanges={exchanges}
/>
</AppChrome>
)
}
export function AppRoutes() {
const { user, token, isLoading } = useAuth()
const { config: systemConfig, loading: configLoading } = useSystemConfig()
const isAuthenticated = !!user && !!token
if (isLoading || configLoading) {
return <LoadingScreen />
}
if (systemConfig && !systemConfig.initialized && !user) {
return <SetupPage />
}
return (
<>
<LegacyHashRedirect />
<Routes>
<Route path={ROUTES.home} element={<LandingPage />} />
<Route path={ROUTES.login} element={<LoginPage />} />
<Route path={ROUTES.register} element={<RegisterPage />} />
<Route path={ROUTES.resetPassword} element={<ResetPasswordPage />} />
<Route
path={ROUTES.setup}
element={
user ? (
<Navigate to={ROUTES.welcome} replace />
) : systemConfig?.initialized ? (
<Navigate to={ROUTES.login} replace />
) : (
<SetupPage />
)
}
/>
<Route
path={ROUTES.faq}
element={
<AppChrome currentPage="faq" showFooter={false} wrapInMain={false}>
<FAQPage />
</AppChrome>
}
/>
<Route
path={ROUTES.data}
element={
<AppChrome currentPage="data" showFooter={false}>
<DataPage />
</AppChrome>
}
/>
<Route
path={ROUTES.settings}
element={
isAuthenticated ? (
<AppChrome showFooter={false}>
<SettingsPage />
</AppChrome>
) : (
<Navigate to={ROUTES.login} replace />
)
}
/>
<Route
path={ROUTES.welcome}
element={
isAuthenticated ? (
getUserMode() === 'beginner' ? (
<TradersRoute showBeginnerOnboarding />
) : (
<Navigate to={ROUTES.traders} replace />
)
) : (
<Navigate to={ROUTES.login} replace />
)
}
/>
<Route
path={ROUTES.competition}
element={
isAuthenticated ? (
<AppChrome currentPage="competition" animateContent>
<CompetitionPage />
</AppChrome>
) : (
<LandingPage />
)
}
/>
<Route
path={ROUTES.strategyMarket}
element={
isAuthenticated ? (
<AppChrome currentPage="strategy-market" animateContent>
<StrategyMarketPage />
</AppChrome>
) : (
<LandingPage />
)
}
/>
<Route
path={ROUTES.traders}
element={isAuthenticated ? <TradersRoute /> : <LandingPage />}
/>
<Route
path={ROUTES.dashboard}
element={isAuthenticated ? <DashboardRoute /> : <LandingPage />}
/>
<Route
path={ROUTES.strategy}
element={
isAuthenticated ? (
<AppChrome currentPage="strategy" animateContent>
<StrategyStudioPage />
</AppChrome>
) : (
<LandingPage />
)
}
/>
<Route path="*" element={<Navigate to={ROUTES.home} replace />} />
</Routes>
</>
)
}
+32
View File
@@ -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)
})
})
+83
View File
@@ -0,0 +1,83 @@
export type Page =
| 'competition'
| 'traders'
| 'trader'
| 'strategy'
| 'strategy-market'
| 'data'
| 'faq'
| 'login'
| 'register'
export const ROUTES = {
home: '/',
login: '/login',
register: '/register',
setup: '/setup',
welcome: '/welcome',
faq: '/faq',
resetPassword: '/reset-password',
settings: '/settings',
data: '/data',
competition: '/competition',
traders: '/traders',
dashboard: '/dashboard',
strategy: '/strategy',
strategyMarket: '/strategy-market',
} as const
export const PAGE_PATHS: Record<Page, string> = {
competition: ROUTES.competition,
traders: ROUTES.traders,
trader: ROUTES.dashboard,
strategy: ROUTES.strategy,
'strategy-market': ROUTES.strategyMarket,
data: ROUTES.data,
faq: ROUTES.faq,
login: ROUTES.login,
register: ROUTES.register,
}
export const LEGACY_HASH_ROUTES: Record<string, string> = {
competition: ROUTES.competition,
traders: ROUTES.traders,
trader: ROUTES.dashboard,
details: ROUTES.dashboard,
strategy: ROUTES.strategy,
'strategy-market': ROUTES.strategyMarket,
data: ROUTES.data,
}
export function getCurrentPageForPath(pathname: string): Page | undefined {
switch (pathname) {
case ROUTES.welcome:
case ROUTES.traders:
return 'traders'
case ROUTES.dashboard:
return 'trader'
case ROUTES.strategy:
return 'strategy'
case ROUTES.strategyMarket:
return 'strategy-market'
case ROUTES.data:
return 'data'
case ROUTES.faq:
return 'faq'
case ROUTES.login:
return 'login'
case ROUTES.register:
return 'register'
case ROUTES.competition:
return 'competition'
default:
return undefined
}
}
export function buildDashboardPath(traderSlug?: string): string {
if (!traderSlug) {
return ROUTES.dashboard
}
return `${ROUTES.dashboard}?trader=${encodeURIComponent(traderSlug)}`
}