mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
feat: improve user onboarding and setup UX (#1436)
* feat: add beginner onboarding and mode switching flow * chore: ignore local gh auth config * fix: restore kline fallback and align onboarding language --------- Co-authored-by: zavier-bin <zhaobbbhhh@gmail.com>
This commit is contained in:
@@ -27,6 +27,8 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
*.backup
|
||||
.cache/
|
||||
.gh-config/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
|
||||
+17
-1
@@ -10,6 +10,7 @@ import (
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -31,6 +32,8 @@ type SafeModelConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
||||
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
||||
WalletAddress string `json:"walletAddress,omitempty"`
|
||||
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
@@ -75,7 +78,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
// Convert to safe response structure, remove sensitive information
|
||||
safeModels := make([]SafeModelConfig, len(models))
|
||||
for i, model := range models {
|
||||
safeModels[i] = SafeModelConfig{
|
||||
safeModel := SafeModelConfig{
|
||||
ID: model.ID,
|
||||
Name: model.Name,
|
||||
Provider: model.Provider,
|
||||
@@ -83,6 +86,19 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
CustomAPIURL: model.CustomAPIURL,
|
||||
CustomModelName: model.CustomModelName,
|
||||
}
|
||||
|
||||
if model.Provider == "claw402" {
|
||||
if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" {
|
||||
if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil {
|
||||
safeModel.WalletAddress = walletAddress
|
||||
safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
safeModels[i] = safeModel
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, safeModels)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/wallet"
|
||||
|
||||
gethcrypto "github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type beginnerOnboardingResponse struct {
|
||||
Address string `json:"address"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Chain string `json:"chain"`
|
||||
Asset string `json:"asset"`
|
||||
Provider string `json:"provider"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
ConfiguredModelID string `json:"configured_model_id"`
|
||||
BalanceUSDC string `json:"balance_usdc"`
|
||||
EnvSaved bool `json:"env_saved"`
|
||||
EnvPath string `json:"env_path,omitempty"`
|
||||
ReusedExisting bool `json:"reused_existing"`
|
||||
EnvWarning string `json:"env_warning,omitempty"`
|
||||
}
|
||||
|
||||
type currentBeginnerWalletResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Address string `json:"address,omitempty"`
|
||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Claw402Status string `json:"claw402_status"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
|
||||
return
|
||||
}
|
||||
|
||||
privateKey, address, configuredModelID, reusedExisting, err := s.resolveBeginnerWallet(userID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to resolve beginner wallet for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare beginner wallet"})
|
||||
return
|
||||
}
|
||||
|
||||
if !reusedExisting {
|
||||
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", "deepseek"); err != nil {
|
||||
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
|
||||
return
|
||||
}
|
||||
|
||||
configuredModelID, err = s.findConfiguredClaw402ModelID(userID)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
os.Setenv("CLAW402_WALLET_KEY", privateKey)
|
||||
os.Setenv("CLAW402_WALLET_ADDRESS", address)
|
||||
os.Setenv("CLAW402_DEFAULT_MODEL", "deepseek")
|
||||
|
||||
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
|
||||
resp := beginnerOnboardingResponse{
|
||||
Address: address,
|
||||
PrivateKey: privateKey,
|
||||
Chain: "base",
|
||||
Asset: "USDC",
|
||||
Provider: "claw402",
|
||||
DefaultModel: "deepseek",
|
||||
ConfiguredModelID: configuredModelID,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
EnvSaved: envSaved,
|
||||
EnvPath: envPath,
|
||||
ReusedExisting: reusedExisting,
|
||||
}
|
||||
if envErr != nil {
|
||||
resp.EnvWarning = envErr.Error()
|
||||
logger.Warnf("Beginner wallet env persistence warning for user %s: %v", userID, envErr)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
|
||||
return
|
||||
}
|
||||
claw402Status := checkClaw402Health()
|
||||
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to load current beginner wallet for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load current wallet"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model == nil || model.Provider != "claw402" {
|
||||
continue
|
||||
}
|
||||
|
||||
privateKey := strings.TrimSpace(model.APIKey.String())
|
||||
if privateKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
address, addrErr := walletAddressFromPrivateKey(privateKey)
|
||||
if addrErr != nil {
|
||||
logger.Warnf("Failed to derive current beginner wallet for user %s: %v", userID, addrErr)
|
||||
continue
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
Source: "model",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
|
||||
if address != "" {
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
Source: "env",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: false,
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) {
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
return "", "", "", false, err
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model == nil || model.Provider != "claw402" {
|
||||
continue
|
||||
}
|
||||
existingKey := strings.TrimSpace(model.APIKey.String())
|
||||
if existingKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
addr, addrErr := walletAddressFromPrivateKey(existingKey)
|
||||
if addrErr != nil {
|
||||
logger.Warnf("Existing claw402 key for user %s is invalid, regenerating: %v", userID, addrErr)
|
||||
break
|
||||
}
|
||||
|
||||
return existingKey, addr, model.ID, true, nil
|
||||
}
|
||||
|
||||
privateKeyObj, genErr := gethcrypto.GenerateKey()
|
||||
if genErr != nil {
|
||||
return "", "", "", false, genErr
|
||||
}
|
||||
|
||||
addr := gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey)
|
||||
keyHex := "0x" + hex.EncodeToString(gethcrypto.FromECDSA(privateKeyObj))
|
||||
return keyHex, addr.Hex(), "", false, nil
|
||||
}
|
||||
|
||||
func (s *Server) findConfiguredClaw402ModelID(userID string) (string, error) {
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model != nil && model.Provider == "claw402" {
|
||||
return model.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("claw402 model not found")
|
||||
}
|
||||
|
||||
func walletAddressFromPrivateKey(privateKey string) (string, error) {
|
||||
key := strings.TrimSpace(privateKey)
|
||||
if !strings.HasPrefix(key, "0x") {
|
||||
return "", fmt.Errorf("private key must start with 0x")
|
||||
}
|
||||
if len(key) != 66 {
|
||||
return "", fmt.Errorf("private key must be 66 characters")
|
||||
}
|
||||
|
||||
privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil
|
||||
}
|
||||
|
||||
func persistBeginnerWalletEnv(privateKey string, address string) (bool, string, error) {
|
||||
paths := uniqueEnvPaths([]string{
|
||||
".env",
|
||||
filepath.Join(".", ".env"),
|
||||
"/app/.env",
|
||||
})
|
||||
|
||||
var lastErr error
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := upsertEnvFile(path, map[string]string{
|
||||
"CLAW402_WALLET_KEY": privateKey,
|
||||
"CLAW402_WALLET_ADDRESS": address,
|
||||
"CLAW402_DEFAULT_MODEL": "deepseek",
|
||||
}); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
return true, path, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no writable .env path found")
|
||||
}
|
||||
return false, "", lastErr
|
||||
}
|
||||
|
||||
func uniqueEnvPaths(paths []string) []string {
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
result := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
clean := filepath.Clean(path)
|
||||
if _, ok := seen[clean]; ok {
|
||||
continue
|
||||
}
|
||||
seen[clean] = struct{}{}
|
||||
result = append(result, clean)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func upsertEnvFile(path string, values map[string]string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingLines := make([]string, 0)
|
||||
if file, err := os.Open(path); err == nil {
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
existingLines = append(existingLines, scanner.Text())
|
||||
}
|
||||
file.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
remaining := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
remaining[key] = value
|
||||
}
|
||||
|
||||
updatedLines := make([]string, 0, len(existingLines)+len(values))
|
||||
for _, line := range existingLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") {
|
||||
updatedLines = append(updatedLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value, ok := remaining[key]
|
||||
if !ok {
|
||||
updatedLines = append(updatedLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
|
||||
delete(remaining, key)
|
||||
}
|
||||
|
||||
for key, value := range remaining {
|
||||
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
content := strings.Join(updatedLines, "\n")
|
||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -122,6 +122,8 @@ func (s *Server) setupRoutes() {
|
||||
{
|
||||
// Logout (add to blacklist)
|
||||
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
|
||||
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
|
||||
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
|
||||
|
||||
// User account management
|
||||
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
- "6060:6060" # pprof profiling
|
||||
volumes:
|
||||
- ./.env:/app/.env
|
||||
- ./data:/app/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
|
||||
+16
-1
@@ -15,6 +15,7 @@ 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'
|
||||
@@ -22,6 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
|
||||
import { t } from './i18n/translations'
|
||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||
import { getUserMode } from './lib/onboarding'
|
||||
|
||||
import { OFFICIAL_LINKS } from './constants/branding'
|
||||
import type {
|
||||
@@ -132,6 +134,8 @@ function App() {
|
||||
}
|
||||
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
|
||||
const hasPersistedAuth =
|
||||
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
|
||||
|
||||
// 监听URL变化,同步页面状态
|
||||
useEffect(() => {
|
||||
@@ -347,6 +351,17 @@ function App() {
|
||||
}
|
||||
return <SetupPage />
|
||||
}
|
||||
if (route === '/welcome') {
|
||||
if ((!user || !token) && !hasPersistedAuth) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
if (getUserMode() !== 'beginner') {
|
||||
window.location.href = '/traders'
|
||||
return null
|
||||
}
|
||||
return <BeginnerOnboardingPage />
|
||||
}
|
||||
if (route === '/faq') {
|
||||
return (
|
||||
<div
|
||||
@@ -376,7 +391,7 @@ function App() {
|
||||
return <ResetPasswordPage />
|
||||
}
|
||||
if (route === '/settings') {
|
||||
if (!user || !token) {
|
||||
if ((!user || !token) && !hasPersistedAuth) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { OnboardingModeSelector } from './OnboardingModeSelector'
|
||||
import type { UserMode } from '../../lib/onboarding'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -15,6 +17,7 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
||||
const [mode, setMode] = useState<UserMode>('beginner')
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem('from401') === 'true') {
|
||||
@@ -28,7 +31,7 @@ export function LoginPage() {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const result = await login(email, password)
|
||||
const result = await login(email, password, mode)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
if (expiredToastId) toast.dismiss(expiredToastId)
|
||||
@@ -109,6 +112,12 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingModeSelector
|
||||
language={language}
|
||||
mode={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { UserMode } from '../../lib/onboarding'
|
||||
|
||||
interface OnboardingModeSelectorProps {
|
||||
language: string
|
||||
mode: UserMode
|
||||
onChange: (mode: UserMode) => void
|
||||
}
|
||||
|
||||
export function OnboardingModeSelector({
|
||||
language,
|
||||
mode,
|
||||
onChange,
|
||||
}: OnboardingModeSelectorProps) {
|
||||
const isZh = language === 'zh'
|
||||
|
||||
const options: Array<{
|
||||
id: UserMode
|
||||
title: string
|
||||
badge?: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
id: 'beginner',
|
||||
title: isZh ? '新手模式' : 'Beginner Mode',
|
||||
badge: isZh ? '推荐' : 'Recommended',
|
||||
description: isZh
|
||||
? '自动生成 Base 钱包,默认接入 Claw402 + DeepSeek,最快完成首次启动。'
|
||||
: 'Generate a Base wallet automatically and start with Claw402 + DeepSeek by default.',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: isZh ? '老手模式' : 'Advanced Mode',
|
||||
description: isZh
|
||||
? '保持现在的完整配置流程,你自己决定模型、钱包和交易所。'
|
||||
: 'Keep the full manual flow and configure models, wallets, and exchanges yourself.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-zinc-400">
|
||||
{isZh ? '使用模式' : 'Experience'}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{options.map((option) => {
|
||||
const selected = option.id === mode
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-left transition-all ${
|
||||
selected
|
||||
? 'border-nofx-gold/60 bg-nofx-gold/10 shadow-[0_0_0_1px_rgba(240,185,11,0.15)]'
|
||||
: 'border-zinc-800 bg-zinc-950/60 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<span>{option.title}</span>
|
||||
{option.badge ? (
|
||||
<span className="rounded-full bg-nofx-gold px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-black">
|
||||
{option.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-400">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { OFFICIAL_LINKS } from '../../constants/branding'
|
||||
import {
|
||||
getPostAuthPath,
|
||||
getUserMode,
|
||||
setUserMode,
|
||||
type UserMode,
|
||||
} from '../../lib/onboarding'
|
||||
|
||||
type Page =
|
||||
| 'competition'
|
||||
@@ -44,8 +50,21 @@ export default function HeaderBar({
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const navigateInApp = (path: string) => {
|
||||
navigate(path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleSwitchMode = (nextMode: UserMode) => {
|
||||
setUserMode(nextMode)
|
||||
setUserModeState(nextMode)
|
||||
setUserDropdownOpen(false)
|
||||
navigateInApp(getPostAuthPath(nextMode))
|
||||
}
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -216,6 +235,15 @@ export default function HeaderBar({
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
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'}
|
||||
</button>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -3,14 +3,19 @@ import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { invalidateSystemConfig } from '../../lib/config'
|
||||
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
|
||||
import type { UserMode } from '../../lib/onboarding'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
|
||||
export function SetupPage() {
|
||||
const { language } = useLanguage()
|
||||
const { register } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mode, setMode] = useState<UserMode>('beginner')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -20,11 +25,10 @@ export function SetupPage() {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const result = await register(email, password)
|
||||
const result = await register(email, password, undefined, mode)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
invalidateSystemConfig()
|
||||
window.location.href = '/traders'
|
||||
} else {
|
||||
setError(result.message || 'Setup failed, please try again')
|
||||
}
|
||||
@@ -87,6 +91,12 @@ export function SetupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingModeSelector
|
||||
language={language}
|
||||
mode={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TelegramConfigModal } from './TelegramConfigModal'
|
||||
import { ModelConfigModal } from './ModelConfigModal'
|
||||
import { ConfigStatusGrid } from './ConfigStatusGrid'
|
||||
import { TradersList } from './TradersList'
|
||||
import { BeginnerGuideCards } from './BeginnerGuideCards'
|
||||
import {
|
||||
Bot,
|
||||
Plus,
|
||||
@@ -25,6 +26,12 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { confirmToast } from '../../lib/notify'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
getBeginnerWalletAddress,
|
||||
getUserMode,
|
||||
setBeginnerWalletAddress as persistBeginnerWalletAddress,
|
||||
} from '../../lib/onboarding'
|
||||
import type { Strategy } from '../../types'
|
||||
|
||||
interface AITradersPageProps {
|
||||
onTraderSelect?: (traderId: string) => void
|
||||
@@ -48,6 +55,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
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 isBeginnerMode = getUserMode() === 'beginner'
|
||||
|
||||
const navigateInApp = (path: string) => {
|
||||
navigate(path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
// Toggle wallet address visibility for a trader
|
||||
const toggleTraderAddressVisibility = (traderId: string) => {
|
||||
@@ -91,6 +106,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
api.getTraders,
|
||||
{ refreshInterval: 5000 }
|
||||
)
|
||||
const { data: strategies } = useSWR<Strategy[]>(
|
||||
user && token ? 'strategies' : null,
|
||||
api.getStrategies,
|
||||
{ refreshInterval: 30000 }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
@@ -115,6 +135,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
api.getSupportedModels(),
|
||||
])
|
||||
setAllModels(modelConfigs)
|
||||
const clawWalletAddress =
|
||||
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
|
||||
if (clawWalletAddress) {
|
||||
setBeginnerWalletAddress(clawWalletAddress)
|
||||
persistBeginnerWalletAddress(clawWalletAddress)
|
||||
}
|
||||
setAllExchanges(exchangeConfigs)
|
||||
setSupportedModels(models)
|
||||
} catch (error) {
|
||||
@@ -616,6 +642,36 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
setShowExchangeModal(true)
|
||||
}
|
||||
|
||||
const handleQuickSetupClaw402 = async () => {
|
||||
if (quickSetupLoading) return
|
||||
|
||||
try {
|
||||
setQuickSetupLoading(true)
|
||||
const result = await api.prepareBeginnerOnboarding()
|
||||
setBeginnerWalletAddress(result.address)
|
||||
const refreshedModels = await api.getModelConfigs()
|
||||
setAllModels(refreshedModels)
|
||||
toast.success(
|
||||
language === 'zh'
|
||||
? 'Claw402 已默认配置为 DeepSeek'
|
||||
: 'Claw402 is configured with DeepSeek by default'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to quick setup claw402:', error)
|
||||
toast.error(
|
||||
language === 'zh'
|
||||
? '一键配置 Claw402 失败'
|
||||
: 'Failed to quick setup Claw402'
|
||||
)
|
||||
} finally {
|
||||
setQuickSetupLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
|
||||
const hasStrategies = (strategies?.length || 0) > 0
|
||||
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
|
||||
@@ -687,6 +743,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBeginnerMode ? (
|
||||
<BeginnerGuideCards
|
||||
language={language}
|
||||
claw402Ready={claw402Configured}
|
||||
exchangeReady={configuredExchanges.length > 0}
|
||||
strategyReady={hasStrategies}
|
||||
canCreateTrader={canCreateTrader}
|
||||
walletAddress={beginnerWalletAddress}
|
||||
onQuickSetupClaw402={handleQuickSetupClaw402}
|
||||
onOpenExchange={handleAddExchange}
|
||||
onOpenStrategy={() => navigateInApp('/strategy')}
|
||||
onCreateTrader={() => setShowCreateModal(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Configuration Status Grid */}
|
||||
<ConfigStatusGrid
|
||||
configuredModels={configuredModels}
|
||||
@@ -715,7 +786,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
copiedId={copiedId}
|
||||
language={language}
|
||||
onTraderSelect={onTraderSelect}
|
||||
onNavigate={(path) => navigate(path)}
|
||||
onNavigate={navigateInApp}
|
||||
onEditTrader={handleEditTrader}
|
||||
onToggleTrader={handleToggleTrader}
|
||||
onToggleCompetition={handleToggleCompetition}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Brain, Landmark, Rocket, Sparkles } from 'lucide-react'
|
||||
|
||||
interface BeginnerGuideCardsProps {
|
||||
language: string
|
||||
claw402Ready: boolean
|
||||
exchangeReady: boolean
|
||||
strategyReady: boolean
|
||||
canCreateTrader: boolean
|
||||
walletAddress?: string | null
|
||||
onQuickSetupClaw402: () => void
|
||||
onOpenExchange: () => void
|
||||
onOpenStrategy: () => void
|
||||
onCreateTrader: () => void
|
||||
}
|
||||
|
||||
function truncateAddress(address: string) {
|
||||
if (address.length <= 12) return address
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`
|
||||
}
|
||||
|
||||
export function BeginnerGuideCards({
|
||||
language,
|
||||
claw402Ready,
|
||||
exchangeReady,
|
||||
strategyReady,
|
||||
canCreateTrader,
|
||||
walletAddress,
|
||||
onQuickSetupClaw402,
|
||||
onOpenExchange,
|
||||
onOpenStrategy,
|
||||
onCreateTrader,
|
||||
}: BeginnerGuideCardsProps) {
|
||||
const isZh = language === 'zh'
|
||||
|
||||
const cards = [
|
||||
{
|
||||
key: 'model',
|
||||
icon: Brain,
|
||||
title: isZh ? '1. 极速模型' : '1. Fast AI',
|
||||
desc: isZh
|
||||
? '默认就是 Claw402 + DeepSeek。第一次不用挑模型,先跑起来。'
|
||||
: 'Start with Claw402 + DeepSeek. No model picking needed for the first run.',
|
||||
meta: walletAddress
|
||||
? isZh
|
||||
? `钱包 ${truncateAddress(walletAddress)}`
|
||||
: `Wallet ${truncateAddress(walletAddress)}`
|
||||
: isZh
|
||||
? 'Base 链 USDC 按次付费'
|
||||
: 'Pay per call with Base USDC',
|
||||
ready: claw402Ready,
|
||||
actionLabel: claw402Ready
|
||||
? isZh ? '已配置' : 'Configured'
|
||||
: isZh ? '一键配置' : 'One-click setup',
|
||||
onAction: onQuickSetupClaw402,
|
||||
disabled: claw402Ready,
|
||||
},
|
||||
{
|
||||
key: 'exchange',
|
||||
icon: Landmark,
|
||||
title: isZh ? '2. 连接交易所' : '2. Add Exchange',
|
||||
desc: isZh
|
||||
? '交易所接好以后,AI 才能真正下单。'
|
||||
: 'Connect an exchange so the AI can actually place trades.',
|
||||
meta: exchangeReady
|
||||
? isZh ? '已准备好' : 'Ready'
|
||||
: isZh ? 'Binance / OKX / Bybit / Hyperliquid' : 'Binance / OKX / Bybit / Hyperliquid',
|
||||
ready: exchangeReady,
|
||||
actionLabel: exchangeReady
|
||||
? isZh ? '继续管理' : 'Manage'
|
||||
: isZh ? '去配置' : 'Configure',
|
||||
onAction: onOpenExchange,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: 'strategy',
|
||||
icon: Sparkles,
|
||||
title: isZh ? '3. 选择策略' : '3. Pick Strategy',
|
||||
desc: isZh
|
||||
? '先用默认策略也可以,后面再慢慢细调。'
|
||||
: 'You can start with a default strategy and fine-tune later.',
|
||||
meta: strategyReady
|
||||
? isZh ? '已有策略可用' : 'Strategy ready'
|
||||
: isZh ? '可选,但建议提前看一眼' : 'Optional, but worth a quick look',
|
||||
ready: strategyReady,
|
||||
actionLabel: isZh ? '打开策略页' : 'Open strategy',
|
||||
onAction: onOpenStrategy,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: 'trader',
|
||||
icon: Rocket,
|
||||
title: isZh ? '4. 创建 Trader' : '4. Create Trader',
|
||||
desc: isZh
|
||||
? '最后一步,把模型和交易所绑在一起,就能开始运行。'
|
||||
: 'Last step: bind your model and exchange, then start running.',
|
||||
meta: canCreateTrader
|
||||
? isZh ? '已经可以创建' : 'Ready to create'
|
||||
: isZh ? '先完成前两步' : 'Finish the first two steps first',
|
||||
ready: canCreateTrader,
|
||||
actionLabel: isZh ? '立即创建' : 'Create now',
|
||||
onAction: onCreateTrader,
|
||||
disabled: !canCreateTrader,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-[28px] border border-white/10 bg-zinc-950/60 p-5 backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-nofx-gold/80">
|
||||
{isZh ? '新手引导' : 'Quickstart'}
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-bold text-white">
|
||||
{isZh ? '先按这 4 步走,最快上手' : 'Follow these 4 steps to get started fast'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-zinc-400">
|
||||
{isZh ? '老手模式不会看到这块' : 'Hidden in advanced mode'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<div
|
||||
key={card.key}
|
||||
className="rounded-[22px] border border-white/8 bg-black/25 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/6 text-nofx-gold">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.22em] ${
|
||||
card.ready
|
||||
? 'bg-emerald-500/15 text-emerald-300'
|
||||
: 'bg-zinc-800 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{card.ready ? (isZh ? '已就绪' : 'Ready') : (isZh ? '待完成' : 'Pending')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-4 text-base font-semibold text-white">{card.title}</h3>
|
||||
<p className="mt-2 min-h-[72px] text-sm leading-6 text-zinc-400">
|
||||
{card.desc}
|
||||
</p>
|
||||
<div className="mt-3 text-xs text-zinc-500">{card.meta}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={card.onAction}
|
||||
disabled={card.disabled}
|
||||
className={`mt-5 w-full rounded-2xl px-4 py-3 text-sm font-semibold transition ${
|
||||
card.disabled
|
||||
? 'cursor-not-allowed bg-zinc-900 text-zinc-500'
|
||||
: 'bg-nofx-gold text-black hover:bg-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{card.actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -92,6 +92,20 @@ export function ConfigStatusGrid({
|
||||
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
||||
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
|
||||
</div>
|
||||
{model.provider === 'claw402' && (model.balanceUsdc || model.walletAddress) ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] font-mono">
|
||||
{model.balanceUsdc ? (
|
||||
<span className="rounded border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-400">
|
||||
{model.balanceUsdc} USDC
|
||||
</span>
|
||||
) : null}
|
||||
{model.walletAddress ? (
|
||||
<span className="rounded border border-sky-500/20 bg-sky-500/10 px-1.5 py-0.5 text-sky-400">
|
||||
{truncateAddress(model.walletAddress)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
|
||||
import type { AIModel } from '../../types'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { api } from '../../lib/api'
|
||||
import { getModelIcon } from '../common/ModelIcons'
|
||||
import { ModelStepIndicator } from './ModelStepIndicator'
|
||||
import { ModelCard } from './ModelCard'
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
AI_PROVIDER_CONFIG,
|
||||
getShortName,
|
||||
} from './model-constants'
|
||||
import { getBeginnerWalletAddress } from '../../lib/onboarding'
|
||||
|
||||
interface ModelConfigModalProps {
|
||||
allModels: AIModel[]
|
||||
@@ -42,20 +44,22 @@ export function ModelConfigModal({
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [modelName, setModelName] = useState('')
|
||||
const configuredModel =
|
||||
configuredModels?.find((model) => model.id === selectedModelId) || null
|
||||
|
||||
// Always prefer allModels (supportedModels) for provider/id lookup;
|
||||
// fall back to configuredModels for edit mode details (apiKey etc.)
|
||||
const selectedModel =
|
||||
allModels?.find((m) => m.id === selectedModelId) ||
|
||||
configuredModels?.find((m) => m.id === selectedModelId)
|
||||
allModels?.find((m) => m.id === selectedModelId) || configuredModel
|
||||
|
||||
useEffect(() => {
|
||||
if (editingModelId && selectedModel) {
|
||||
setApiKey(selectedModel.apiKey || '')
|
||||
setBaseUrl(selectedModel.customApiUrl || '')
|
||||
setModelName(selectedModel.customModelName || '')
|
||||
const modelDetails = configuredModel || selectedModel
|
||||
if (editingModelId && modelDetails) {
|
||||
setApiKey(modelDetails.apiKey || '')
|
||||
setBaseUrl(modelDetails.customApiUrl || '')
|
||||
setModelName(modelDetails.customModelName || '')
|
||||
}
|
||||
}, [editingModelId, selectedModel])
|
||||
}, [editingModelId, configuredModel, selectedModel])
|
||||
|
||||
const handleSelectModel = (modelId: string) => {
|
||||
setSelectedModelId(modelId)
|
||||
@@ -79,7 +83,18 @@ export function ModelConfigModal({
|
||||
|
||||
const availableModels = allModels || []
|
||||
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
|
||||
const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)]
|
||||
const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402'
|
||||
const stepLabels = [
|
||||
t('modelConfig.selectModel', language),
|
||||
t(
|
||||
!selectedModel
|
||||
? 'modelConfig.configure'
|
||||
: isClaw402Selected
|
||||
? 'modelConfig.configureWallet'
|
||||
: 'modelConfig.configure',
|
||||
language
|
||||
),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
|
||||
@@ -143,6 +158,7 @@ export function ModelConfigModal({
|
||||
<Claw402ConfigForm
|
||||
apiKey={apiKey}
|
||||
modelName={modelName}
|
||||
configuredModel={configuredModel}
|
||||
editingModelId={editingModelId}
|
||||
onApiKeyChange={setApiKey}
|
||||
onModelNameChange={setModelName}
|
||||
@@ -189,6 +205,10 @@ function ModelSelectionStep({
|
||||
onSelectModel: (modelId: string) => void
|
||||
language: Language
|
||||
}) {
|
||||
const [showOtherProviders, setShowOtherProviders] = useState(false)
|
||||
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
|
||||
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
@@ -196,12 +216,11 @@ function ModelSelectionStep({
|
||||
</div>
|
||||
|
||||
{/* Claw402 Featured Card */}
|
||||
{availableModels.some(m => m.provider === 'claw402') && (
|
||||
{claw402Model && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const claw = availableModels.find(m => m.provider === 'claw402')
|
||||
if (claw) onSelectModel(claw.id)
|
||||
onSelectModel(claw402Model.id)
|
||||
}}
|
||||
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
|
||||
@@ -222,7 +241,7 @@ function ModelSelectionStep({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
|
||||
{configuredIds.has(claw402Model.id) && (
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
|
||||
)}
|
||||
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
|
||||
@@ -235,23 +254,57 @@ function ModelSelectionStep({
|
||||
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
|
||||
{t('modelConfig.claw402EntryDesc', language)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{availableModels.filter(m => m.provider !== 'claw402').map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={selectedModelId === model.id}
|
||||
onClick={() => onSelectModel(model.id)}
|
||||
configured={configuredIds.has(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
|
||||
{t('modelConfig.modelsConfigured', language)}
|
||||
</div>
|
||||
{otherProviders.length > 0 && (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOtherProviders((prev) => !prev)}
|
||||
className="w-full flex items-center justify-between px-4 py-4 text-left transition-all hover:bg-white/5"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{t('modelConfig.otherApiEntry', language)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('modelConfig.otherApiEntryDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em]" style={{ color: '#A0AEC0' }}>
|
||||
{otherProviders.length} API
|
||||
</span>
|
||||
<span className="text-sm" style={{ color: '#60A5FA' }}>
|
||||
{showOtherProviders ? '−' : '+'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showOtherProviders && (
|
||||
<div className="border-t border-white/5 px-4 py-4">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{otherProviders.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={selectedModelId === model.id}
|
||||
onClick={() => onSelectModel(model.id)}
|
||||
configured={configuredIds.has(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-center pt-3" style={{ color: '#848E9C' }}>
|
||||
{t('modelConfig.modelsConfigured', language)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -259,6 +312,7 @@ function ModelSelectionStep({
|
||||
function Claw402ConfigForm({
|
||||
apiKey,
|
||||
modelName,
|
||||
configuredModel,
|
||||
editingModelId,
|
||||
onApiKeyChange,
|
||||
onModelNameChange,
|
||||
@@ -268,6 +322,7 @@ function Claw402ConfigForm({
|
||||
}: {
|
||||
apiKey: string
|
||||
modelName: string
|
||||
configuredModel: AIModel | null
|
||||
editingModelId: string | null
|
||||
onApiKeyChange: (value: string) => void
|
||||
onModelNameChange: (value: string) => void
|
||||
@@ -278,14 +333,21 @@ function Claw402ConfigForm({
|
||||
const [walletAddress, setWalletAddress] = useState('')
|
||||
const [copiedAddr, setCopiedAddr] = useState(false)
|
||||
const [showDeposit, setShowDeposit] = useState(false)
|
||||
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
|
||||
const [newWalletKey, setNewWalletKey] = useState('')
|
||||
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
||||
const [keyError, setKeyError] = useState('')
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [claw402Status, setClaw402Status] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [serverWalletAddress, setServerWalletAddress] = useState('')
|
||||
const [serverWalletBalance, setServerWalletBalance] = useState<string | null>(null)
|
||||
const localWalletAddress = getBeginnerWalletAddress()?.trim() || ''
|
||||
const configuredWalletAddress =
|
||||
configuredModel?.walletAddress?.trim() || localWalletAddress || serverWalletAddress
|
||||
const resolvedWalletAddress = walletAddress || configuredWalletAddress
|
||||
const resolvedUsdcBalance =
|
||||
usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null
|
||||
const hasExistingWallet = Boolean(configuredWalletAddress)
|
||||
|
||||
// Client-side validation helper
|
||||
const getClientError = (key: string): string => {
|
||||
@@ -298,8 +360,36 @@ function Claw402ConfigForm({
|
||||
|
||||
const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey)
|
||||
|
||||
// Truncate address for display
|
||||
useEffect(() => {
|
||||
if (hasExistingWallet) {
|
||||
setShowDeposit(true)
|
||||
}
|
||||
}, [hasExistingWallet])
|
||||
|
||||
useEffect(() => {
|
||||
if (configuredModel?.walletAddress || localWalletAddress || serverWalletAddress) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void api
|
||||
.getCurrentBeginnerWallet()
|
||||
.then((result) => {
|
||||
setClaw402Status(result.claw402_status || 'unknown')
|
||||
if (cancelled || !result.found || !result.address) {
|
||||
return
|
||||
}
|
||||
setServerWalletAddress(result.address)
|
||||
setServerWalletBalance(result.balance_usdc || null)
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore silently: this is a best-effort fallback for showing the current wallet.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress])
|
||||
|
||||
// Debounced validation when apiKey changes
|
||||
useEffect(() => {
|
||||
@@ -347,6 +437,23 @@ function Claw402ConfigForm({
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
if (!apiKey && hasExistingWallet) {
|
||||
const result = await api.getCurrentBeginnerWallet()
|
||||
setClaw402Status(result.claw402_status || 'unknown')
|
||||
if (result.found && result.address) {
|
||||
setWalletAddress(result.address)
|
||||
setUsdcBalance(result.balance_usdc || '0.00')
|
||||
setShowDeposit(true)
|
||||
}
|
||||
setTestResult({
|
||||
status: result.claw402_status === 'ok' ? 'ok' : 'error',
|
||||
message: result.claw402_status === 'ok'
|
||||
? t('modelConfig.claw402Connected', language)
|
||||
: t('modelConfig.claw402Unreachable', language),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch('/api/wallet/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -374,7 +481,7 @@ function Claw402ConfigForm({
|
||||
}
|
||||
}
|
||||
|
||||
const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
|
||||
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
@@ -396,6 +503,25 @@ function Claw402ConfigForm({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || (!hasExistingWallet && !isKeyValid)}
|
||||
className="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-semibold transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
|
||||
>
|
||||
<span>🔗</span>
|
||||
{testing ? t('modelConfig.testingConnection', language) : t('modelConfig.testConnection', language)}
|
||||
</button>
|
||||
{claw402Status ? (
|
||||
<div className="text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#F59E0B' }}>
|
||||
{claw402Status === 'ok'
|
||||
? t('modelConfig.claw402Connected', language)
|
||||
: t('modelConfig.claw402Unreachable', language)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Select AI Model */}
|
||||
@@ -467,6 +593,33 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasExistingWallet && (
|
||||
<div className="p-3 rounded-xl" style={{ background: 'rgba(0, 224, 150, 0.05)', border: '1px solid rgba(0, 224, 150, 0.18)' }}>
|
||||
<div className="text-xs font-semibold mb-1.5" style={{ color: '#00E096' }}>
|
||||
{language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'}
|
||||
</div>
|
||||
<div className="text-[11px] leading-5" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh'
|
||||
? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。'
|
||||
: 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'}
|
||||
</div>
|
||||
{!configuredModel?.walletAddress && localWalletAddress ? (
|
||||
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '当前地址来自本地已保存的新手钱包。'
|
||||
: 'This address comes from the locally saved beginner wallet.'}
|
||||
</div>
|
||||
) : null}
|
||||
{!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? (
|
||||
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '当前地址来自后端保存的钱包配置。'
|
||||
: 'This address comes from the wallet saved on the server.'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
||||
{t('modelConfig.walletPrivateKey', language)}
|
||||
@@ -476,72 +629,30 @@ function Claw402ConfigForm({
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
placeholder="0x..."
|
||||
placeholder={
|
||||
hasExistingWallet
|
||||
? language === 'zh'
|
||||
? '如需切换钱包,请手动输入新的私钥'
|
||||
: 'Enter a new private key only if you want to switch wallets'
|
||||
: '0x...'
|
||||
}
|
||||
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
required={!hasExistingWallet}
|
||||
/>
|
||||
{!apiKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/wallet/generate', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.private_key) {
|
||||
onApiKeyChange(data.private_key)
|
||||
setShowNewWalletBackup(true)
|
||||
setNewWalletKey(data.private_key)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="shrink-0 px-3 py-3 rounded-xl text-xs font-semibold transition-all hover:scale-[1.02]"
|
||||
style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New wallet backup warning */}
|
||||
{showNewWalletBackup && newWalletKey && (
|
||||
<div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||
<div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
|
||||
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
|
||||
</div>
|
||||
<div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
|
||||
{language === 'zh'
|
||||
? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。'
|
||||
: 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<code className="text-[10px] font-mono break-all select-all flex-1 p-2 rounded" style={{ background: '#0B0E11', color: '#F87171' }}>
|
||||
{newWalletKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newWalletKey)
|
||||
setCopiedAddr(true)
|
||||
setTimeout(() => setCopiedAddr(false), 2000)
|
||||
}}
|
||||
className="shrink-0 text-[10px] px-2 py-1 rounded"
|
||||
style={{ background: 'rgba(239,68,68,0.15)', color: '#F87171', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{copiedAddr ? '✅ Copied' : '📋 Copy Key'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>✅ {language === 'zh' ? '建议保存到密码管理器(1Password / Bitwarden)' : 'Save to a password manager (1Password / Bitwarden)'}</div>
|
||||
<div>✅ {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}</div>
|
||||
<div>❌ {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}</div>
|
||||
</div>
|
||||
{hasExistingWallet && !apiKey ? (
|
||||
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。'
|
||||
: 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
||||
<span className="mt-px">🔒</span>
|
||||
@@ -552,7 +663,7 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
|
||||
{/* Wallet Validation Results */}
|
||||
{apiKey && (
|
||||
{(apiKey || hasExistingWallet) && (
|
||||
<div className="space-y-2 pl-1">
|
||||
{/* Validating spinner */}
|
||||
{validating && (
|
||||
@@ -571,7 +682,7 @@ function Claw402ConfigForm({
|
||||
)}
|
||||
|
||||
{/* Success: address + balance + status */}
|
||||
{walletAddress && !validating && !keyError && (
|
||||
{resolvedWalletAddress && !validating && !keyError && (
|
||||
<>
|
||||
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@@ -581,7 +692,7 @@ function Claw402ConfigForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(walletAddress)
|
||||
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||
setCopiedAddr(true)
|
||||
setTimeout(() => setCopiedAddr(false), 2000)
|
||||
}}
|
||||
@@ -591,16 +702,16 @@ function Claw402ConfigForm({
|
||||
{copiedAddr ? '✅' : '📋'}
|
||||
</button>
|
||||
</div>
|
||||
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{walletAddress}</code>
|
||||
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
|
||||
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
|
||||
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
|
||||
</div>
|
||||
</div>
|
||||
{usdcBalance !== null && (
|
||||
{resolvedUsdcBalance !== null && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>💰</span>
|
||||
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
|
||||
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
|
||||
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -621,17 +732,17 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
<div className="flex gap-3 items-start mb-3">
|
||||
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
|
||||
<QRCodeSVG value={walletAddress} size={80} level="M" />
|
||||
<QRCodeSVG value={resolvedWalletAddress} size={80} level="M" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
|
||||
</div>
|
||||
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{walletAddress}</code>
|
||||
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(walletAddress)
|
||||
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||
setCopiedAddr(true)
|
||||
setTimeout(() => setCopiedAddr(false), 2000)
|
||||
}}
|
||||
@@ -650,6 +761,13 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!apiKey && hasExistingWallet && (
|
||||
<div className="text-[11px]" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。'
|
||||
: 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'}
|
||||
</div>
|
||||
)}
|
||||
{claw402Status && (
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
|
||||
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
|
||||
@@ -662,11 +780,11 @@ function Claw402ConfigForm({
|
||||
)}
|
||||
|
||||
{/* Test Connection button */}
|
||||
{isKeyValid && !validating && (
|
||||
{(isKeyValid || hasExistingWallet) && !validating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing}
|
||||
disabled={testing || (!hasExistingWallet && !isKeyValid)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50"
|
||||
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { getSystemConfig } from '../lib/config'
|
||||
import { reset401Flag, httpClient } from '../lib/httpClient'
|
||||
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
@@ -12,7 +14,8 @@ interface AuthContextType {
|
||||
token: string | null
|
||||
login: (
|
||||
email: string,
|
||||
password: string
|
||||
password: string,
|
||||
mode?: UserMode
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
@@ -24,7 +27,8 @@ interface AuthContextType {
|
||||
register: (
|
||||
email: string,
|
||||
password: string,
|
||||
betaCode?: string
|
||||
betaCode?: string,
|
||||
mode?: UserMode
|
||||
) => Promise<{ success: boolean; message?: string }>
|
||||
resetPassword: (
|
||||
email: string,
|
||||
@@ -89,7 +93,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const handlePostAuthSuccess = (
|
||||
authToken: string,
|
||||
userInfo: User,
|
||||
mode?: UserMode
|
||||
) => {
|
||||
reset401Flag()
|
||||
|
||||
if (mode) {
|
||||
setUserMode(mode)
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', authToken)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
localStorage.setItem('user_id', userInfo.id)
|
||||
flushSync(() => {
|
||||
setToken(authToken)
|
||||
setUser(userInfo)
|
||||
})
|
||||
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
const nextPath = returnUrl || getPostAuthPath(mode)
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', nextPath)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const login = async (email: string, password: string, mode?: UserMode) => {
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
@@ -103,26 +136,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (response.ok) {
|
||||
if (data.token) {
|
||||
// Reset 401 flag on successful login
|
||||
reset401Flag()
|
||||
|
||||
const userInfo = { id: data.user_id, email: data.email }
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
// Redirect to config page
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
handlePostAuthSuccess(data.token, userInfo, mode)
|
||||
|
||||
return { success: true, message: data.message }
|
||||
}
|
||||
@@ -156,10 +171,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
id: data.user_id || 'admin',
|
||||
email: data.email || 'admin@localhost',
|
||||
}
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
flushSync(() => {
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
})
|
||||
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
@@ -184,7 +201,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const register = async (
|
||||
email: string,
|
||||
password: string,
|
||||
betaCode?: string
|
||||
betaCode?: string,
|
||||
mode?: UserMode
|
||||
) => {
|
||||
const requestBody: {
|
||||
email: string
|
||||
@@ -204,26 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}>('/api/register', requestBody)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Reset 401 flag on successful login
|
||||
reset401Flag()
|
||||
|
||||
const userInfo = { id: result.data.user_id, email: result.data.email }
|
||||
setToken(result.data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', result.data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
// Redirect to config page
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
handlePostAuthSuccess(result.data.token, userInfo, mode)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1210,8 +1210,13 @@ export const translations = {
|
||||
// ModelConfigModal
|
||||
modelConfig: {
|
||||
selectModel: 'Select Model',
|
||||
configure: 'Configure',
|
||||
configureApi: 'Configure API',
|
||||
configureWallet: 'Configure Wallet',
|
||||
chooseProvider: 'Choose Your AI Provider',
|
||||
claw402EntryDesc: 'Recommended default path. Use Base USDC pay-per-call instead of managing API keys.',
|
||||
otherApiEntry: 'Other API Providers',
|
||||
otherApiEntryDesc: 'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.',
|
||||
payPerCall: 'Pay-per-call USDC · All AI Models · No API Key',
|
||||
recommended: 'Best',
|
||||
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
|
||||
@@ -2503,8 +2508,13 @@ export const translations = {
|
||||
|
||||
modelConfig: {
|
||||
selectModel: '选择模型',
|
||||
configure: '配置',
|
||||
configureApi: '配置 API',
|
||||
configureWallet: '配置钱包',
|
||||
chooseProvider: '选择 AI 模型提供商',
|
||||
claw402EntryDesc: '默认推荐走这条路。直接用 Base USDC 按次付费,不需要自己管理 API Key。',
|
||||
otherApiEntry: '其他 API 模型',
|
||||
otherApiEntryDesc: '如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key,再从这里进入。',
|
||||
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
|
||||
recommended: '推荐',
|
||||
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
|
||||
@@ -3601,8 +3611,13 @@ export const translations = {
|
||||
|
||||
modelConfig: {
|
||||
selectModel: 'Pilih Model',
|
||||
configure: 'Konfigurasi',
|
||||
configureApi: 'Konfigurasi API',
|
||||
configureWallet: 'Konfigurasi Wallet',
|
||||
chooseProvider: 'Pilih Penyedia AI Anda',
|
||||
claw402EntryDesc: 'Jalur default yang direkomendasikan. Gunakan Base USDC bayar per panggilan tanpa mengelola API key.',
|
||||
otherApiEntry: 'Penyedia API Lain',
|
||||
otherApiEntryDesc: 'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.',
|
||||
payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key',
|
||||
recommended: 'Terbaik',
|
||||
allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
UpdateModelConfigRequest,
|
||||
UpdateExchangeConfigRequest,
|
||||
CreateExchangeRequest,
|
||||
BeginnerOnboardingResponse,
|
||||
CurrentBeginnerWalletResponse,
|
||||
} from '../../types'
|
||||
import { API_BASE, httpClient, CryptoService } from './helpers'
|
||||
|
||||
@@ -183,4 +185,24 @@ export const configApi = {
|
||||
if (!result.success) throw new Error('Failed to fetch server IP')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async prepareBeginnerOnboarding(): Promise<BeginnerOnboardingResponse> {
|
||||
const result = await httpClient.post<BeginnerOnboardingResponse>(
|
||||
`${API_BASE}/onboarding/beginner`
|
||||
)
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || 'Failed to prepare beginner onboarding')
|
||||
}
|
||||
return result.data
|
||||
},
|
||||
|
||||
async getCurrentBeginnerWallet(): Promise<CurrentBeginnerWalletResponse> {
|
||||
const result = await httpClient.get<CurrentBeginnerWalletResponse>(
|
||||
`${API_BASE}/onboarding/beginner/current`
|
||||
)
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || 'Failed to fetch current beginner wallet')
|
||||
}
|
||||
return result.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type UserMode = 'beginner' | 'advanced'
|
||||
|
||||
const USER_MODE_KEY = 'nofx_user_mode'
|
||||
const BEGINNER_WALLET_ADDRESS_KEY = 'nofx_beginner_wallet_address'
|
||||
|
||||
export function getUserMode(): UserMode | null {
|
||||
const value = localStorage.getItem(USER_MODE_KEY)
|
||||
if (value === 'beginner' || value === 'advanced') {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function setUserMode(mode: UserMode) {
|
||||
localStorage.setItem(USER_MODE_KEY, mode)
|
||||
}
|
||||
|
||||
export function getPostAuthPath(mode: UserMode | null | undefined): string {
|
||||
return mode === 'beginner' ? '/welcome' : '/traders'
|
||||
}
|
||||
|
||||
export function setBeginnerWalletAddress(address: string) {
|
||||
localStorage.setItem(BEGINNER_WALLET_ADDRESS_KEY, address)
|
||||
}
|
||||
|
||||
export function getBeginnerWalletAddress(): string | null {
|
||||
return localStorage.getItem(BEGINNER_WALLET_ADDRESS_KEY)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Copy, Eye, EyeOff, RefreshCw, Shield, Wallet, Sparkles } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { toast } from 'sonner'
|
||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import type { BeginnerOnboardingResponse } from '../types'
|
||||
import { setBeginnerWalletAddress } from '../lib/onboarding'
|
||||
|
||||
export function BeginnerOnboardingPage() {
|
||||
const { language } = useLanguage()
|
||||
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showPrivateKey, setShowPrivateKey] = useState(false)
|
||||
const [refreshingBalance, setRefreshingBalance] = useState(false)
|
||||
const hasRequestedRef = useRef(false)
|
||||
const isZh = language === 'zh'
|
||||
|
||||
const loadOnboarding = async (showLoading: boolean) => {
|
||||
if (showLoading) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setRefreshingBalance(true)
|
||||
}
|
||||
|
||||
setError('')
|
||||
try {
|
||||
const result = await api.prepareBeginnerOnboarding()
|
||||
setData(result)
|
||||
setBeginnerWalletAddress(result.address)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: isZh
|
||||
? '新手钱包准备失败'
|
||||
: 'Failed to prepare beginner wallet'
|
||||
)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setLoading(false)
|
||||
} else {
|
||||
setRefreshingBalance(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRequestedRef.current) {
|
||||
return
|
||||
}
|
||||
hasRequestedRef.current = true
|
||||
void loadOnboarding(true)
|
||||
}, [])
|
||||
|
||||
const hints = useMemo(
|
||||
() =>
|
||||
isZh
|
||||
? [
|
||||
'这是你的专属 Base 钱包,只用于后续调用大模型。',
|
||||
'请保存私钥。丢失后无法恢复。',
|
||||
'只往这个地址充值 Base 链 USDC,不要充到别的链。',
|
||||
]
|
||||
: [
|
||||
'This dedicated Base wallet is only used to pay for model calls.',
|
||||
'Save the private key now. It cannot be recovered later.',
|
||||
'Deposit USDC on Base only. Do not send funds from another chain.',
|
||||
],
|
||||
[isZh]
|
||||
)
|
||||
|
||||
const copyText = async (value: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.success(isZh ? `${label}已复制` : `${label} copied`)
|
||||
} catch {
|
||||
toast.error(isZh ? '复制失败' : 'Copy failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<div className="mx-auto flex min-h-screen max-w-5xl items-center px-4 py-12">
|
||||
<div className="grid w-full gap-8 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-zinc-950/70 p-8 shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-nofx-gold/15 text-nofx-gold">
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.28em] text-nofx-gold/80">
|
||||
{isZh ? '新手保护' : 'Beginner Guard'}
|
||||
</div>
|
||||
<h1 className="mt-1 text-3xl font-bold text-white">
|
||||
{isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="max-w-xl text-sm leading-7 text-zinc-300">
|
||||
{isZh
|
||||
? '我们已经为你生成了一个专属钱包,并默认接入 Claw402 + DeepSeek。你现在只需要保存私钥,然后往这个地址充值 Base 链 USDC,后面调用大模型时会自动从这里扣费。'
|
||||
: 'We generated a dedicated wallet for you and preconfigured Claw402 + DeepSeek. Save the private key, then deposit Base USDC to this address so future model calls can be paid automatically.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
{hints.map((hint) => (
|
||||
<div
|
||||
key={hint}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-3"
|
||||
>
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-nofx-gold" />
|
||||
<div className="text-sm leading-6 text-zinc-300">{hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[24px] border border-sky-500/20 bg-sky-500/5 p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-sky-300">
|
||||
<Wallet className="h-4 w-4" />
|
||||
<span>{isZh ? '为什么要充值?' : 'Why fund this wallet?'}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
{isZh
|
||||
? '这里只负责大模型调用费用,不会自动替你充值交易所。先充少量 USDC 就够了,通常 $5-$10 可以用很久。'
|
||||
: 'This wallet only covers LLM usage costs. It does not fund your exchange automatically. A small amount of USDC is enough to get started, usually $5-$10.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-black/60 p-8 shadow-2xl backdrop-blur-xl">
|
||||
{loading ? (
|
||||
<div className="flex min-h-[420px] items-center justify-center text-sm text-zinc-400">
|
||||
{isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-zinc-500">
|
||||
{isZh ? '默认模型' : 'Default Model'}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-white">Claw402 + DeepSeek</div>
|
||||
<div className="mt-2 text-sm text-zinc-400">
|
||||
{isZh ? '按次付费,无需 API Key' : 'Pay per call, no API key needed'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white p-5 text-center">
|
||||
<div className="inline-flex rounded-2xl bg-white p-3">
|
||||
<QRCodeSVG value={data.address} size={180} level="M" />
|
||||
</div>
|
||||
<div className="mt-4 text-sm font-semibold text-zinc-900">
|
||||
{isZh ? '充值地址(Base 链 USDC)' : 'Deposit Address (Base USDC)'}
|
||||
</div>
|
||||
<div className="mt-2 break-all rounded-2xl bg-zinc-100 px-3 py-3 font-mono text-xs text-zinc-700">
|
||||
{data.address}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.address, isZh ? '地址' : 'Address')}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-zinc-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-zinc-800"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
{isZh ? '复制地址' : 'Copy address'}
|
||||
</button>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-left">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-700">
|
||||
{isZh ? '当前余额' : 'Current Balance'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-emerald-900">
|
||||
{data.balance_usdc} USDC
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-emerald-700/80">
|
||||
{isZh ? 'Base 链钱包余额' : 'Base wallet balance'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadOnboarding(false)}
|
||||
disabled={refreshingBalance}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-emerald-300 bg-white px-3 py-2 text-xs font-semibold text-emerald-800 transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshingBalance ? 'animate-spin' : ''}`} />
|
||||
{isZh ? '刷新余额' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-500/20 bg-amber-500/8 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-200">
|
||||
{isZh ? '钱包私钥' : 'Wallet Private Key'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-amber-100/75">
|
||||
{isZh ? '请先备份,再进入下一步。' : 'Back this up before you continue.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPrivateKey((prev) => !prev)}
|
||||
className="rounded-xl border border-amber-400/20 px-3 py-2 text-amber-200 transition hover:bg-amber-400/10"
|
||||
>
|
||||
{showPrivateKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 break-all rounded-2xl bg-black/25 px-3 py-3 font-mono text-xs text-amber-50">
|
||||
{showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.private_key, isZh ? '私钥' : 'Private key')}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-xl border border-amber-300/20 px-4 py-2 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/10"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
{isZh ? '复制私钥' : 'Copy private key'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4 text-xs leading-6 text-zinc-400">
|
||||
<div>
|
||||
{data.env_saved
|
||||
? isZh
|
||||
? `已同步保存到环境文件:${data.env_path || '.env'}`
|
||||
: `Also saved to env: ${data.env_path || '.env'}`
|
||||
: isZh
|
||||
? '当前运行环境没有成功写回 .env,但产品已完成默认配置。'
|
||||
: 'The app is configured, but this runtime could not write back to .env.'}
|
||||
</div>
|
||||
{data.env_warning ? <div className="mt-2 text-amber-300">{data.env_warning}</div> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className="w-full rounded-2xl bg-nofx-gold px-5 py-4 text-sm font-bold text-black transition hover:bg-yellow-400"
|
||||
>
|
||||
{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
getPostAuthPath,
|
||||
getUserMode,
|
||||
setUserMode,
|
||||
type UserMode,
|
||||
} from '../lib/onboarding'
|
||||
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
||||
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
||||
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
||||
@@ -15,6 +21,7 @@ export function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const { language } = useLanguage()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('account')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
|
||||
// Account state
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@@ -81,6 +88,26 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchMode = (nextMode: UserMode) => {
|
||||
if (nextMode === userMode) {
|
||||
return
|
||||
}
|
||||
|
||||
setUserMode(nextMode)
|
||||
setUserModeState(nextMode)
|
||||
toast.success(
|
||||
language === 'zh'
|
||||
? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}`
|
||||
: nextMode === 'beginner'
|
||||
? 'Switched to beginner mode'
|
||||
: 'Switched to advanced mode'
|
||||
)
|
||||
|
||||
const nextPath = getPostAuthPath(nextMode)
|
||||
window.history.pushState({}, '', nextPath)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleSaveModel = async (
|
||||
modelId: string,
|
||||
apiKey: string,
|
||||
@@ -281,6 +308,66 @@ export function SettingsPage() {
|
||||
<p className="text-sm text-white font-medium">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '使用模式' : 'Usage Mode'}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。'
|
||||
: 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'}
|
||||
</p>
|
||||
</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'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitchMode('beginner')}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
|
||||
userMode === 'beginner'
|
||||
? 'border-nofx-gold bg-nofx-gold/10'
|
||||
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '新手模式' : 'Beginner Mode'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '更简单,优先显示钱包、充值和快速上手引导。'
|
||||
: 'Simpler flow with wallet, funding, and quickstart guidance first.'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitchMode('advanced')}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
|
||||
userMode === 'advanced'
|
||||
? 'border-nofx-gold bg-nofx-gold/10'
|
||||
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '老手模式' : 'Advanced Mode'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '保持原来的配置与交易流程,不展示新手引导。'
|
||||
: 'Keeps the original configuration and trading workflow without beginner hints.'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Download,
|
||||
Upload,
|
||||
Globe,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||
import { confirmToast, notify } from '../lib/notify'
|
||||
@@ -39,10 +38,8 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
||||
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
||||
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
|
||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||
import { t } from '../i18n/translations'
|
||||
import { NofxSelect } from '../components/ui/select'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
@@ -55,7 +52,6 @@ export function StrategyStudioPage() {
|
||||
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [tokenOverflow, setTokenOverflow] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
@@ -382,10 +378,6 @@ export function StrategyStudioPage() {
|
||||
// Save strategy
|
||||
const handleSaveStrategy = async () => {
|
||||
if (!token || !selectedStrategy || !editingConfig) return
|
||||
if (tokenOverflow && currentStrategyType === 'ai_trading') {
|
||||
notify.error(tr('tokenExceedWarning'))
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// Always sync the config language with the current interface language
|
||||
@@ -413,17 +405,7 @@ export function StrategyStudioPage() {
|
||||
if (!response.ok) throw new Error('Failed to save strategy')
|
||||
setHasChanges(false)
|
||||
notify.success(tr('strategySaved'))
|
||||
const savedId = selectedStrategy.id
|
||||
await fetchStrategies()
|
||||
// Stay on the strategy we just saved instead of jumping to active
|
||||
setStrategies(prev => {
|
||||
const saved = prev.find(s => s.id === savedId)
|
||||
if (saved) {
|
||||
setSelectedStrategy(saved)
|
||||
setEditingConfig(saved.config)
|
||||
}
|
||||
return prev
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
@@ -659,7 +641,7 @@ export function StrategyStudioPage() {
|
||||
<Sparkles className="w-5 h-5 text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-nofx-text">{tr('title')}</h1>
|
||||
<h1 className="text-lg font-bold text-nofx-text">{tr('strategyStudio')}</h1>
|
||||
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -774,24 +756,34 @@ export function StrategyStudioPage() {
|
||||
{selectedStrategy && editingConfig ? (
|
||||
<div className="p-4">
|
||||
{/* Strategy Name & Actions */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.name}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted"
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-nofx-gold whitespace-nowrap">● {tr('unsaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.name}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.description || ''}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
placeholder={tr('addDescription')}
|
||||
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-nofx-gold">● {tr('unsaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!selectedStrategy.is_active && (
|
||||
<button
|
||||
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
||||
@@ -801,22 +793,10 @@ export function StrategyStudioPage() {
|
||||
{tr('activate')}
|
||||
</button>
|
||||
)}
|
||||
{!selectedStrategy.is_default && hasChanges && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConfig(selectedStrategy.config)
|
||||
setHasChanges(false)
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors text-nofx-text-muted hover:text-nofx-text hover:bg-nofx-bg-lighter border border-nofx-border"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{tr('discardChanges')}
|
||||
</button>
|
||||
)}
|
||||
{!selectedStrategy.is_default && (
|
||||
<button
|
||||
onClick={handleSaveStrategy}
|
||||
disabled={isSaving || !hasChanges || (tokenOverflow && currentStrategyType === 'ai_trading')}
|
||||
disabled={isSaving || !hasChanges}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
|
||||
>
|
||||
@@ -825,27 +805,8 @@ export function StrategyStudioPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.description || ''}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
placeholder={tr('addDescription')}
|
||||
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Estimate Bar */}
|
||||
{currentStrategyType === 'ai_trading' && (
|
||||
<div className="mb-4">
|
||||
<TokenEstimateBar config={editingConfig} language={language} onOverflowChange={setTokenOverflow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strategy Type Selector */}
|
||||
{editingConfig && (
|
||||
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||
@@ -857,12 +818,9 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedStrategy?.is_default) {
|
||||
setEditingConfig(prev => prev ? {
|
||||
...prev,
|
||||
strategy_type: 'ai_trading',
|
||||
grid_config: undefined,
|
||||
} : prev)
|
||||
setHasChanges(true)
|
||||
updateConfig('strategy_type', 'ai_trading')
|
||||
// Clear grid config when switching to AI trading
|
||||
updateConfig('grid_config', undefined)
|
||||
}
|
||||
}}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
@@ -881,12 +839,11 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedStrategy?.is_default) {
|
||||
setEditingConfig(prev => prev ? {
|
||||
...prev,
|
||||
strategy_type: 'grid_trading',
|
||||
grid_config: prev.grid_config || defaultGridConfig,
|
||||
} : prev)
|
||||
setHasChanges(true)
|
||||
updateConfig('strategy_type', 'grid_trading')
|
||||
// Initialize grid config if not exists
|
||||
if (!editingConfig.grid_config) {
|
||||
updateConfig('grid_config', defaultGridConfig)
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
@@ -977,16 +934,15 @@ export function StrategyStudioPage() {
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<NofxSelect
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(val) => setSelectedVariant(val)}
|
||||
options={[
|
||||
{ value: 'balanced', label: tr('balanced') },
|
||||
{ value: 'aggressive', label: tr('aggressive') },
|
||||
{ value: 'conservative', label: tr('conservative') },
|
||||
]}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text outline-none focus:border-nofx-gold"
|
||||
>
|
||||
<option value="balanced">{tr('balanced')}</option>
|
||||
<option value="aggressive">{tr('aggressive')}</option>
|
||||
<option value="conservative">{tr('conservative')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchPromptPreview}
|
||||
disabled={isLoadingPrompt || !editingConfig}
|
||||
@@ -1051,15 +1007,17 @@ export function StrategyStudioPage() {
|
||||
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
|
||||
</div>
|
||||
{aiModels.length > 0 ? (
|
||||
<NofxSelect
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(val) => setSelectedModelId(val)}
|
||||
options={aiModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: `${model.name} (${model.provider})`,
|
||||
}))}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
>
|
||||
{aiModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name} ({model.provider})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
|
||||
{tr('noModel')}
|
||||
@@ -1067,16 +1025,15 @@ export function StrategyStudioPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NofxSelect
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(val) => setSelectedVariant(val)}
|
||||
options={[
|
||||
{ value: 'balanced', label: tr('balanced') },
|
||||
{ value: 'aggressive', label: tr('aggressive') },
|
||||
{ value: 'conservative', label: tr('conservative') },
|
||||
]}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
>
|
||||
<option value="balanced">{tr('balanced')}</option>
|
||||
<option value="aggressive">{tr('aggressive')}</option>
|
||||
<option value="conservative">{tr('conservative')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={runAiTest}
|
||||
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface AIModel {
|
||||
apiKey?: string
|
||||
customApiUrl?: string
|
||||
customModelName?: string
|
||||
walletAddress?: string
|
||||
balanceUsdc?: string
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
@@ -110,3 +112,26 @@ export interface UpdateExchangeConfigRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface BeginnerOnboardingResponse {
|
||||
address: string
|
||||
private_key: string
|
||||
chain: string
|
||||
asset: string
|
||||
provider: string
|
||||
default_model: string
|
||||
configured_model_id: string
|
||||
balance_usdc: string
|
||||
env_saved: boolean
|
||||
env_path?: string
|
||||
reused_existing: boolean
|
||||
env_warning?: string
|
||||
}
|
||||
|
||||
export interface CurrentBeginnerWalletResponse {
|
||||
found: boolean
|
||||
address?: string
|
||||
balance_usdc?: string
|
||||
source?: string
|
||||
claw402_status?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user