mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 17:34:39 +08:00
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.
This commit is contained in:
@@ -347,6 +347,27 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er
|
|||||||
return positions, nil
|
return positions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOpenPositionsByExchange returns every OPEN row on an exchange account,
|
||||||
|
// across all trader IDs. An exchange account is shared by every "NOFX
|
||||||
|
// Autopilot" relaunch (each relaunch mints a fresh trader_id), so reconciling
|
||||||
|
// must be scoped to the exchange — not the current trader_id — or rows left by
|
||||||
|
// prior incarnations become permanent orphans that never close.
|
||||||
|
func (s *PositionStore) GetOpenPositionsByExchange(exchangeID string) ([]*TraderPosition, error) {
|
||||||
|
var positions []*TraderPosition
|
||||||
|
err := s.db.Where("exchange_id = ? AND status = ?", exchangeID, "OPEN").
|
||||||
|
Order("entry_time DESC").
|
||||||
|
Find(&positions).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query open positions by exchange: %w", err)
|
||||||
|
}
|
||||||
|
for _, pos := range positions {
|
||||||
|
if pos.EntryQuantity == 0 {
|
||||||
|
pos.EntryQuantity = pos.Quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positions, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetOpenPositionBySymbol gets open position for specified symbol and direction
|
// GetOpenPositionBySymbol gets open position for specified symbol and direction
|
||||||
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
||||||
var pos TraderPosition
|
var pos TraderPosition
|
||||||
|
|||||||
111
store/position_reconcile.go
Normal file
111
store/position_reconcile.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nofx/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const reconcileQuantityTolerance = 0.0001
|
||||||
|
|
||||||
|
// LivePositionKey builds the map key used by ReconcileOpenPositionsWithLive.
|
||||||
|
func LivePositionKey(symbol, side string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(symbol)) + "|" + strings.ToUpper(strings.TrimSpace(side))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileOpenPositionsWithLive force-closes local OPEN position rows that the
|
||||||
|
// exchange no longer holds, and trims rows whose quantity exceeds what is live.
|
||||||
|
//
|
||||||
|
// Why this exists: missed or unmatched fills (position flips, liquidations,
|
||||||
|
// sync gaps) leave "zombie" OPEN rows behind. Every later close on the same
|
||||||
|
// symbol+side then lands as a partial close against the zombie, so the row
|
||||||
|
// never reaches CLOSED and its realized PnL never enters the closed-trade
|
||||||
|
// statistics — the dashboard, the Edge Profile and the AI's own track-record
|
||||||
|
// context all silently under-report. Reconciling against the exchange's live
|
||||||
|
// book is the self-healing fix: local OPEN rows must always be a subset of
|
||||||
|
// what the exchange actually holds.
|
||||||
|
//
|
||||||
|
// liveQty maps LivePositionKey(symbol, side) → live quantity on the exchange.
|
||||||
|
// Rows are matched newest-first so the freshest row survives as the live
|
||||||
|
// position's bookkeeping and older duplicates get closed.
|
||||||
|
//
|
||||||
|
// Scope is by exchange account (all trader IDs), so rows left by prior
|
||||||
|
// autopilot incarnations on the same exchange are reconciled too.
|
||||||
|
func (s *PositionStore) ReconcileOpenPositionsWithLive(exchangeID string, liveQty map[string]float64) (int, error) {
|
||||||
|
openRows, err := s.GetOpenPositionsByExchange(exchangeID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to list open positions: %w", err)
|
||||||
|
}
|
||||||
|
if len(openRows) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy so we can consume quantities without mutating the caller's map.
|
||||||
|
remaining := make(map[string]float64, len(liveQty))
|
||||||
|
for k, v := range liveQty {
|
||||||
|
remaining[strings.ToUpper(k)] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first: the most recent row keeps representing the live position.
|
||||||
|
sort.Slice(openRows, func(i, j int) bool {
|
||||||
|
return openRows[i].EntryTime > openRows[j].EntryTime
|
||||||
|
})
|
||||||
|
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
|
closed := 0
|
||||||
|
|
||||||
|
for _, row := range openRows {
|
||||||
|
key := LivePositionKey(row.Symbol, row.Side)
|
||||||
|
live := remaining[key]
|
||||||
|
|
||||||
|
if live > reconcileQuantityTolerance {
|
||||||
|
// The exchange still holds (part of) this key — this row survives.
|
||||||
|
if row.Quantity > live+reconcileQuantityTolerance {
|
||||||
|
// Trim the row down to what is actually live so the next real
|
||||||
|
// close matches sizes and can fully close it. No PnL is
|
||||||
|
// fabricated: the trimmed residue is stale bookkeeping, not a
|
||||||
|
// real fill.
|
||||||
|
trim := row.Quantity - live
|
||||||
|
exitPrice := row.ExitPrice
|
||||||
|
if exitPrice <= 0 {
|
||||||
|
exitPrice = row.EntryPrice
|
||||||
|
}
|
||||||
|
if err := s.ReducePositionQuantity(row.ID, trim, exitPrice, 0, 0); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Reconcile: failed to trim position %d (%s %s): %v", row.ID, row.Symbol, row.Side, err)
|
||||||
|
} else {
|
||||||
|
logger.Infof(" 🧹 Reconcile: trimmed %s %s row %d by %.6f to match live %.6f", row.Symbol, row.Side, row.ID, trim, live)
|
||||||
|
}
|
||||||
|
remaining[key] = 0
|
||||||
|
} else {
|
||||||
|
remaining[key] = live - row.Quantity
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing (left) on the exchange for this key — the row is a zombie.
|
||||||
|
// Close it with whatever it accumulated; exit info falls back to the
|
||||||
|
// last known bookkeeping on the row.
|
||||||
|
exitPrice := row.ExitPrice
|
||||||
|
if exitPrice <= 0 {
|
||||||
|
exitPrice = row.EntryPrice
|
||||||
|
}
|
||||||
|
exitTime := row.UpdatedAt
|
||||||
|
if exitTime <= 0 {
|
||||||
|
exitTime = nowMs
|
||||||
|
}
|
||||||
|
if err := s.ClosePositionFully(row.ID, exitPrice, row.ExitOrderID, exitTime, row.RealizedPnL, row.Fee, "reconcile"); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Reconcile: failed to close zombie position %d (%s %s): %v", row.ID, row.Symbol, row.Side, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
closed++
|
||||||
|
logger.Infof(" 🧹 Reconcile: closed zombie %s %s row %d (qty %.6f, accumulated PnL %.2f) — not held on exchange", row.Symbol, row.Side, row.ID, row.Quantity, row.RealizedPnL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if closed > 0 {
|
||||||
|
logger.Infof("✅ Position reconcile: closed %d zombie row(s) on exchange %s", closed, exchangeID)
|
||||||
|
}
|
||||||
|
return closed, nil
|
||||||
|
}
|
||||||
113
store/position_reconcile_test.go
Normal file
113
store/position_reconcile_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newReconcileTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := New(t.TempDir() + "/nofx.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.New failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func openRow(t *testing.T, st *Store, traderID, exchangeID, symbol, side string, qty, pnl float64, entryMs int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
pos := &TraderPosition{
|
||||||
|
TraderID: traderID,
|
||||||
|
ExchangeID: exchangeID,
|
||||||
|
ExchangeType: "hyperliquid",
|
||||||
|
Symbol: symbol,
|
||||||
|
Side: side,
|
||||||
|
Quantity: qty,
|
||||||
|
EntryQuantity: qty,
|
||||||
|
EntryPrice: 100,
|
||||||
|
EntryTime: entryMs,
|
||||||
|
RealizedPnL: pnl,
|
||||||
|
Status: "OPEN",
|
||||||
|
Source: "sync",
|
||||||
|
CreatedAt: entryMs,
|
||||||
|
UpdatedAt: entryMs,
|
||||||
|
}
|
||||||
|
if err := st.Position().CreateOpenPosition(pos); err != nil {
|
||||||
|
t.Fatalf("create open position: %v", err)
|
||||||
|
}
|
||||||
|
return pos.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileClosesZombiesKeepsLiveAndTrims(t *testing.T) {
|
||||||
|
st := newReconcileTestStore(t)
|
||||||
|
const exch = "ex-hl"
|
||||||
|
base := time.Now().Add(-48 * time.Hour).UnixMilli()
|
||||||
|
|
||||||
|
// Zombie under the CURRENT trader id: exchange holds nothing for DRAM.
|
||||||
|
zombieID := openRow(t, st, "trader-now", exch, "xyz:DRAM", "LONG", 6.8, -20.34, base)
|
||||||
|
|
||||||
|
// Zombie left by a PRIOR autopilot incarnation on the SAME exchange —
|
||||||
|
// this is the case a per-trader-id reconcile would miss.
|
||||||
|
legacyID := openRow(t, st, "trader-old", exch, "SOLUSDT", "SHORT", 6.94, -3.5, base+500)
|
||||||
|
|
||||||
|
// Duplicates for SP500 (different incarnations): newest survives trimmed,
|
||||||
|
// older closes.
|
||||||
|
oldSP := openRow(t, st, "trader-old", exch, "xyz:SP500", "LONG", 0.07, -1.5, base+1000)
|
||||||
|
newSP := openRow(t, st, "trader-now", exch, "xyz:SP500", "LONG", 0.124, 2.5, base+2000)
|
||||||
|
|
||||||
|
// Healthy row exactly matching live — untouched.
|
||||||
|
healthy := openRow(t, st, "trader-now", exch, "BTCUSDT", "LONG", 0.01, 0, base+3000)
|
||||||
|
|
||||||
|
// Row on a DIFFERENT exchange account — must be out of scope.
|
||||||
|
otherExch := openRow(t, st, "trader-now", "ex-other", "ETHUSDT", "LONG", 2.0, 0, base+4000)
|
||||||
|
|
||||||
|
live := map[string]float64{
|
||||||
|
LivePositionKey("xyz:SP500", "long"): 0.057,
|
||||||
|
LivePositionKey("BTCUSDT", "long"): 0.01,
|
||||||
|
}
|
||||||
|
|
||||||
|
closed, err := st.Position().ReconcileOpenPositionsWithLive(exch, live)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcile failed: %v", err)
|
||||||
|
}
|
||||||
|
if closed != 3 {
|
||||||
|
t.Fatalf("expected 3 zombies closed (DRAM + legacy SOL + old SP500), got %d", closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
get := func(id int64) *TraderPosition {
|
||||||
|
t.Helper()
|
||||||
|
var pos TraderPosition
|
||||||
|
if err := st.Position().db.First(&pos, id).Error; err != nil {
|
||||||
|
t.Fatalf("load position %d: %v", id, err)
|
||||||
|
}
|
||||||
|
return &pos
|
||||||
|
}
|
||||||
|
|
||||||
|
if dram := get(zombieID); dram.Status != "CLOSED" || dram.RealizedPnL != -20.34 || dram.CloseReason != "reconcile" {
|
||||||
|
t.Fatalf("DRAM zombie should close via reconcile keeping PnL, got %+v", dram)
|
||||||
|
}
|
||||||
|
if sol := get(legacyID); sol.Status != "CLOSED" {
|
||||||
|
t.Fatalf("legacy-incarnation SOL zombie on same exchange should close, got %+v", sol)
|
||||||
|
}
|
||||||
|
if sp := get(oldSP); sp.Status != "CLOSED" {
|
||||||
|
t.Fatalf("older duplicate SP500 row should close, got %+v", sp)
|
||||||
|
}
|
||||||
|
if sp := get(newSP); sp.Status != "OPEN" || sp.Quantity > 0.0571 || sp.Quantity < 0.0569 {
|
||||||
|
t.Fatalf("newest SP500 row should stay open trimmed to live 0.057, got status=%s qty=%v", sp.Status, sp.Quantity)
|
||||||
|
}
|
||||||
|
if btc := get(healthy); btc.Status != "OPEN" || btc.Quantity != 0.01 {
|
||||||
|
t.Fatalf("healthy row must be untouched, got %+v", btc)
|
||||||
|
}
|
||||||
|
if eth := get(otherExch); eth.Status != "OPEN" {
|
||||||
|
t.Fatalf("row on a different exchange must be out of scope, got %+v", eth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileNoOpenRowsIsNoop(t *testing.T) {
|
||||||
|
st := newReconcileTestStore(t)
|
||||||
|
closed, err := st.Position().ReconcileOpenPositionsWithLive("ex-empty", map[string]float64{})
|
||||||
|
if err != nil || closed != 0 {
|
||||||
|
t.Fatalf("expected clean noop, got closed=%d err=%v", closed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,9 +133,43 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("✅ Order sync completed: %d new trades synced", syncedCount)
|
logger.Infof("✅ Order sync completed: %d new trades synced", syncedCount)
|
||||||
|
|
||||||
|
// Reconcile local OPEN rows against the exchange's live book. Without
|
||||||
|
// this, any missed/unmatched fill leaves a zombie OPEN row that swallows
|
||||||
|
// every later close as a "partial close" — its realized PnL then never
|
||||||
|
// reaches the closed-trade statistics. Scoped by exchange account so rows
|
||||||
|
// left by prior autopilot incarnations are healed too.
|
||||||
|
if err := t.reconcilePositions(exchangeID, positionStore); err != nil {
|
||||||
|
logger.Infof("⚠️ Position reconcile skipped: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcilePositions builds the live (symbol, side) → quantity map from the
|
||||||
|
// exchange (core perps + xyz dex) and lets the store close/trim any local
|
||||||
|
// OPEN rows on this exchange account the exchange no longer backs.
|
||||||
|
func (t *HyperliquidTrader) reconcilePositions(exchangeID string, positionStore *store.PositionStore) error {
|
||||||
|
livePositions, err := t.GetPositions()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get live positions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
liveQty := make(map[string]float64, len(livePositions))
|
||||||
|
for _, pos := range livePositions {
|
||||||
|
symbol, _ := pos["symbol"].(string)
|
||||||
|
side, _ := pos["side"].(string)
|
||||||
|
qty, _ := pos["positionAmt"].(float64)
|
||||||
|
if symbol == "" || qty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
liveQty[store.LivePositionKey(market.Normalize(symbol), side)] += qty
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = positionStore.ReconcileOpenPositionsWithLive(exchangeID, liveQty)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// StartOrderSync starts background order sync task
|
// StartOrderSync starts background order sync task
|
||||||
func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration, stop <-chan struct{}) {
|
func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration, stop <-chan struct{}) {
|
||||||
syncloop.Run(stop, interval, "Hyperliquid", func() error {
|
syncloop.Run(stop, interval, "Hyperliquid", func() error {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useAuth } from '../../contexts/AuthContext'
|
|||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
@@ -64,7 +63,6 @@ export function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DeepVoidBackground disableAnimation>
|
<DeepVoidBackground disableAnimation>
|
||||||
<LanguageSwitcher />
|
|
||||||
|
|
||||||
{/* Self-contained centering grid — works regardless of parent flex setup */}
|
{/* Self-contained centering grid — works regardless of parent flex setup */}
|
||||||
<main className="flex-1 grid lg:grid-cols-2">
|
<main className="flex-1 grid lg:grid-cols-2">
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export default function HeaderBar({
|
|||||||
isLoggedIn = false,
|
isLoggedIn = false,
|
||||||
isHomePage = false,
|
isHomePage = false,
|
||||||
currentPage,
|
currentPage,
|
||||||
language = 'zh' as Language,
|
language = 'en' as Language,
|
||||||
onLanguageChange,
|
|
||||||
user,
|
user,
|
||||||
onLogout,
|
onLogout,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
@@ -40,12 +39,10 @@ export default function HeaderBar({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
|
||||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||||
const [userMode, setUserModeState] = useState<UserMode>(
|
const [userMode, setUserModeState] = useState<UserMode>(
|
||||||
() => getUserMode() ?? 'advanced'
|
() => getUserMode() ?? 'advanced'
|
||||||
)
|
)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const resolvedCurrentPage =
|
const resolvedCurrentPage =
|
||||||
currentPage ?? getCurrentPageForPath(location.pathname)
|
currentPage ?? getCurrentPageForPath(location.pathname)
|
||||||
@@ -63,12 +60,6 @@ export default function HeaderBar({
|
|||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setLanguageDropdownOpen(false)
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
userDropdownRef.current &&
|
userDropdownRef.current &&
|
||||||
!userDropdownRef.current.contains(event.target as Node)
|
!userDropdownRef.current.contains(event.target as Node)
|
||||||
@@ -357,56 +348,7 @@ export default function HeaderBar({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Language Toggle - Always at the rightmost */}
|
{/* Language switcher removed — the product UI is English-only. */}
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors text-nofx-text-muted hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<span className="text-lg">
|
|
||||||
{language === 'zh' ? '🇨🇳' : language === 'id' ? '🇮🇩' : '🇺🇸'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{languageDropdownOpen && (
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50 bg-nofx-bg-lighter border border-nofx-gold/20">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.('zh')
|
|
||||||
setLanguageDropdownOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-nofx-text
|
|
||||||
${language === 'zh' ? 'bg-nofx-gold/10' : 'hover:bg-[rgba(26,24,19,0.06)]'}`}
|
|
||||||
>
|
|
||||||
<span className="text-base">🇨🇳</span>
|
|
||||||
<span className="text-sm">Chinese</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.('en')
|
|
||||||
setLanguageDropdownOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-nofx-text
|
|
||||||
${language === 'en' ? 'bg-nofx-gold/10' : 'hover:bg-[rgba(26,24,19,0.06)]'}`}
|
|
||||||
>
|
|
||||||
<span className="text-base">🇺🇸</span>
|
|
||||||
<span className="text-sm">English</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.('id')
|
|
||||||
setLanguageDropdownOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-nofx-text
|
|
||||||
${language === 'id' ? 'bg-nofx-gold/10' : 'hover:bg-[rgba(26,24,19,0.06)]'}`}
|
|
||||||
>
|
|
||||||
<span className="text-base">🇮🇩</span>
|
|
||||||
<span className="text-sm">Bahasa</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -619,28 +561,8 @@ export default function HeaderBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account / Lang */}
|
{/* Account (language switcher removed — English-only UI) */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{/* Lang Switcher */}
|
|
||||||
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
|
|
||||||
{['zh', 'en', 'id'].map((lang) => (
|
|
||||||
<button
|
|
||||||
key={lang}
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.(lang as Language)
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${
|
|
||||||
language === lang
|
|
||||||
? 'bg-zinc-800 text-white shadow-sm'
|
|
||||||
: 'text-zinc-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auth Actions */}
|
{/* Auth Actions */}
|
||||||
{isLoggedIn && user ? (
|
{isLoggedIn && user ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { invalidateSystemConfig } from '../../lib/config'
|
|||||||
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
|
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
|
||||||
import type { UserMode } from '../../lib/onboarding'
|
import type { UserMode } from '../../lib/onboarding'
|
||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
zh: {
|
zh: {
|
||||||
@@ -127,7 +126,6 @@ export function SetupPage() {
|
|||||||
{/* Blur overlay */}
|
{/* Blur overlay */}
|
||||||
<div className="absolute inset-0 backdrop-blur-md bg-nofx-bg/60" />
|
<div className="absolute inset-0 backdrop-blur-md bg-nofx-bg/60" />
|
||||||
|
|
||||||
<LanguageSwitcher />
|
|
||||||
|
|
||||||
{/* Modal card */}
|
{/* Modal card */}
|
||||||
<div className="relative z-10 flex min-h-screen items-center justify-center px-4 py-16">
|
<div className="relative z-10 flex min-h-screen items-center justify-center px-4 py-16">
|
||||||
|
|||||||
Reference in New Issue
Block a user