Files
nofx/web/src/pages/BeginnerOnboardingPage.tsx
Zavier b9a33ce809 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>
2026-04-04 23:39:05 +08:00

265 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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