Files
nofx/web/src/components/auth/LoginPage.tsx
tinkle-community 21407030ea fix: reconcile local positions against the live exchange book — stop lost PnL
Root cause of the dashboard under-reporting: any missed or unmatched fill
(position flip, sync gap, liquidation) left a 'zombie' OPEN row. Every later
close on that symbol landed as a partial close against the zombie, so the
row never reached CLOSED and its realized PnL never entered the closed-trade
statistics — Edge Profile, realized P/L, win rate, and the AI's own
track-record context all silently under-reported.

Fix: after each Hyperliquid order sync, reconcile local OPEN rows against the
exchange's live book (core perps + xyz dex), scoped by exchange account so
rows left by prior autopilot incarnations (each relaunch mints a fresh
trader_id sharing one exchange) are healed too. Rows the exchange no longer
holds are closed with their accumulated PnL; oversized rows are trimmed to
the live quantity. Live run confirmed: 9 zombie rows closed across
incarnations, book now matches the exchange exactly.

Also removes the header language switcher (desktop + mobile) and the
login/setup LanguageSwitcher — the product UI is English-only.

New store methods GetOpenPositionsByExchange + ReconcileOpenPositionsWithLive
with cross-incarnation test coverage.
2026-07-10 20:04:57 +09:00

299 lines
12 KiB
TypeScript

import React, { useState, useEffect } from 'react'
import { Eye, EyeOff, Loader2, ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
export function LoginPage() {
const { language } = useLanguage()
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(
null
)
useEffect(() => {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('user_id')
}, [])
useEffect(() => {
if (sessionStorage.getItem('from401') === 'true') {
const id = toast.warning(t('sessionExpired', language), {
duration: Infinity,
})
setExpiredToastId(id)
sessionStorage.removeItem('from401')
}
}, [language])
// Account wipe was removed from the public API (it was an unauthenticated
// destructive endpoint). It now runs as a local CLI command on the server,
// so we surface the instruction instead of calling an endpoint.
const handleResetAccount = () => {
toast(t('resetAccountCliIntro', language), {
description: 'nofx reset-account',
duration: 10000,
})
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await login(email, password)
setLoading(false)
if (result.success) {
if (expiredToastId) toast.dismiss(expiredToastId)
} else {
const msg = result.message || t('loginFailed', language)
setError(msg)
toast.error(msg)
}
}
return (
<DeepVoidBackground disableAnimation>
{/* Self-contained centering grid — works regardless of parent flex setup */}
<main className="flex-1 grid lg:grid-cols-2">
{/* ───────── LEFT: brand panel (desktop only) ───────── */}
<section className="hidden lg:flex flex-col justify-between p-12 xl:p-16 relative overflow-hidden">
{/* Ambient vermilion halo */}
<div className="absolute -left-32 top-1/3 w-[28rem] h-[28rem] bg-nofx-gold/[0.06] rounded-full blur-3xl pointer-events-none" />
<div className="absolute -right-16 bottom-0 w-72 h-72 bg-nofx-accent/[0.04] rounded-full blur-3xl pointer-events-none" />
{/* Brand mark */}
<div className="flex items-center gap-3 relative">
<img src="/icons/nofx.svg" alt="NOFX" className="w-9 h-9" />
<div className="font-mono font-bold text-xl tracking-tight text-nofx-text">
NOFX<span className="text-nofx-gold">.</span>
</div>
</div>
{/* Headline */}
<div className="relative max-w-lg">
<div className="inline-flex items-center gap-2 mb-7 px-3 py-1 rounded-full border border-nofx-success/25 bg-nofx-success/[0.06]">
<div className="w-1.5 h-1.5 rounded-full bg-nofx-success animate-pulse" />
<span className="text-[10.5px] font-mono tracking-[0.18em] text-nofx-success uppercase">
Terminal Online
</span>
</div>
<h2 className="text-4xl xl:text-5xl font-bold tracking-tight text-nofx-text leading-[1.05]">
{language === 'zh' ? (
<>
AI-Powered<br />
<span className="text-nofx-gold">
Multi-Market Trading Terminal
</span>
</>
) : language === 'id' ? (
<>
Terminal Trading<br />
<span className="text-nofx-gold">
Multi-Pasar AI
</span>
</>
) : (
<>
AI-Powered<br />
<span className="text-nofx-gold">
Trading Terminal
</span>
</>
)}
</h2>
<p className="mt-5 text-nofx-text-muted text-base leading-relaxed max-w-md">
{language === 'zh'
? 'Plug into 10+ exchanges including Hyperliquid, OKX, Aster, and 7 LLM models. Deploy 24/7 automated strategies with natural language.'
: language === 'id'
? 'Hubungkan ke 10+ bursa termasuk Hyperliquid, OKX, Aster dan 7 model LLM. Terapkan strategi otomatis 24/7 dengan bahasa alami.'
: 'Plug into 10+ exchanges including Hyperliquid, OKX, Aster, and 7 LLM models. Deploy 24/7 automated strategies with natural language.'}
</p>
</div>
{/* Stats strip */}
<div className="relative grid grid-cols-3 gap-8 max-w-md">
<Stat
value="10+"
label={
language === 'zh'
? 'Exchanges'
: language === 'id'
? 'Bursa'
: 'Exchanges'
}
/>
<Stat
value="7"
label={
language === 'zh'
? 'AI Models'
: language === 'id'
? 'Model AI'
: 'AI Models'
}
/>
<Stat
value="24/7"
label={
language === 'zh'
? 'Always On'
: language === 'id'
? 'Sepanjang Waktu'
: 'Always On'
}
/>
</div>
</section>
{/* ───────── RIGHT: form panel ───────── */}
<section className="flex items-center justify-center p-6 sm:p-12 relative">
<div className="w-full max-w-sm">
{/* Mobile brand */}
<div className="lg:hidden flex flex-col items-center gap-3 mb-10">
<img src="/icons/nofx.svg" alt="NOFX" className="w-12 h-12" />
<div className="font-mono font-bold text-lg tracking-tight text-nofx-text">
NOFX<span className="text-nofx-gold">.</span>
</div>
</div>
{/* Form header */}
<div className="mb-7">
<h1 className="text-[26px] sm:text-3xl font-bold tracking-tight text-nofx-text">
{t('signIn', language)}
</h1>
<p className="mt-1.5 text-sm text-nofx-text-muted">
{language === 'zh'
? 'Continue with your email'
: language === 'id'
? 'Lanjutkan dengan email Anda'
: 'Continue with your email'}
</p>
</div>
{/* Form */}
<form onSubmit={handleLogin} className="space-y-4">
{/* Email */}
<div>
<label className="block text-[10.5px] font-medium uppercase tracking-[0.14em] text-nofx-text-muted mb-2">
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-nofx-bg-lighter border border-[rgba(26,24,19,0.14)] rounded-lg px-4 py-[11px] text-[14px] text-nofx-text placeholder-nofx-text-muted focus:outline-none focus:border-nofx-gold/50 focus:bg-nofx-bg-lighter focus:ring-2 focus:ring-nofx-gold/20 transition-all"
placeholder="you@example.com"
required
autoFocus
autoComplete="email"
/>
</div>
{/* Password */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[10.5px] font-medium uppercase tracking-[0.14em] text-nofx-text-muted">
{t('password', language)}
</label>
<button
type="button"
onClick={() => navigate('/reset-password')}
className="text-xs text-nofx-text-muted hover:text-nofx-gold transition-colors"
>
{t('forgotPassword', language)}
</button>
</div>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-nofx-bg-lighter border border-[rgba(26,24,19,0.14)] rounded-lg px-4 py-[11px] pr-11 text-[14px] text-nofx-text placeholder-nofx-text-muted focus:outline-none focus:border-nofx-gold/50 focus:bg-nofx-bg-lighter focus:ring-2 focus:ring-nofx-gold/20 transition-all"
placeholder="••••••••"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-nofx-text-muted hover:text-nofx-text transition-colors"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Error banner */}
{error && (
<div className="flex items-start gap-2 rounded-lg border border-nofx-danger/25 bg-nofx-danger/[0.08] px-3 py-2.5 text-xs text-nofx-danger">
<span className="text-nofx-danger font-bold mt-px">!</span>
<span className="leading-relaxed">{error}</span>
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={loading}
className="group mt-2 flex w-full items-center justify-center gap-2 rounded-lg bg-nofx-gold py-[11px] text-sm font-semibold text-nofx-bg transition-all hover:bg-nofx-gold-highlight active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? (
<>
<Loader2 size={16} className="animate-spin" />
{t('loggingIn', language) || 'Signing in...'}
</>
) : (
<>
{t('signIn', language)}
<ArrowRight
size={16}
className="transition-transform group-hover:translate-x-0.5"
/>
</>
)}
</button>
</form>
{/* Footer */}
<div className="mt-8 pt-5 border-t border-[rgba(26,24,19,0.14)] flex items-center justify-between text-[11px]">
<span className="font-mono text-nofx-text-muted">v1.0</span>
<button
type="button"
onClick={handleResetAccount}
className="text-nofx-text-muted transition-colors hover:text-nofx-danger"
>
{t('forgotAccount', language)}
</button>
</div>
</div>
</section>
</main>
</DeepVoidBackground>
)
}
function Stat({ value, label }: { value: string; label: string }) {
return (
<div>
<div className="font-mono text-2xl xl:text-3xl font-bold text-nofx-text">
{value}
</div>
<div className="mt-1 text-[10.5px] uppercase tracking-[0.14em] text-nofx-text-muted">
{label}
</div>
</div>
)
}