/* ============================================================================ MyTraderOS — Wave 3 pages (L0 completion) Auth forms · Onboarding · Connect wizard · Import & Sync · Settings · Insights · Unlock โหลดหลัง app.jsx (shared global scope · babel-standalone classic scripts) → อ้าง helper/state components/App จาก app.jsx ได้ตรง · render อยู่ท้ายไฟล์นี้ ทุกเลข/ลิสต์มาจาก mock API · client format/วาดเท่านั้น ============================================================================ */ /* ------------------------------------------------------ shared bits ------ */ function StepDots({ current, total }) { return
{Array.from({ length: total }).map((_, i) => )}
; } function FieldErr({ msg }) { return msg ?
{msg}
: null; } function Banner({ tone, children }) { const c = tone === "error" ? { bg: "#fdecef", bd: "var(--loss)", ink: "var(--loss)" } : tone === "warn" ? { bg: "rgba(200,163,90,.14)", bd: "#c8a35a", ink: "#8a6d2f" } : { bg: "var(--brand-soft)", bd: "var(--brand-edge)", ink: "var(--brand-strong)" }; return
{children}
; } function fmtPlanPrice(cents, currency) { const sym = currency === "THB" ? "฿" : "$"; return sym + (cents / 100).toLocaleString("en-US", { maximumFractionDigits: 0 }); } /* =========================================================================== AuthFormPage — /login + /register (mock · error รายฟิลด์ไทย · ?next=) =========================================================================== */ function AuthFormPage({ t, mode, onLogin }) { const isReg = mode === "register"; const params = new URLSearchParams(window.location.search); const next = params.get("next") || "/app"; const [f, setF] = useState({ email: "", password: "", confirm: "", invite: "", pdpa: false, marketing: false }); const [err, setErr] = useState({}); const [policyOpen, setPolicyOpen] = useState(false); const [forgotNote, setForgotNote] = useState(false); const [topErr, setTopErr] = useState(null); const INVITE_FLAG = true; // mock: beta invite เปิด const set = (k, v) => setF((o) => Object.assign({}, o, { [k]: v })); const validate = () => { const e = {}; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.email)) e.email = t("auth.err_email"); if (!f.password || f.password.length < 8) e.password = t("auth.err_password"); if (isReg) { if (f.confirm !== f.password) e.confirm = t("auth.err_confirm"); if (INVITE_FLAG && f.invite.trim().toUpperCase() !== "MTOS-BETA") e.invite = t("auth.err_invite"); if (!f.pdpa) e.pdpa = t("auth.err_pdpa"); } setErr(e); return Object.keys(e).length === 0; }; const submit = () => { setTopErr(null); if (!validate()) return; if (!isReg && f.password === "wrong") { setTopErr(t("auth.invalid_credentials")); return; } try { localStorage.setItem("mtos_user", JSON.stringify({ email: f.email })); } catch (x) {} onLogin(); navigate(isReg ? "/app/onboarding" : next, true); }; const inpErr = (k) => err[k] ? { borderColor: "var(--loss)" } : null; return

{t(isReg ? "auth.register_title" : "auth.login_title")}

{topErr &&
{topErr}
} set("email", e.target.value)} data-testid="email" /> set("password", e.target.value)} data-testid="password" /> {isReg && set("confirm", e.target.value)} data-testid="confirm" /> set("invite", e.target.value)} data-testid="invite" />
{t("auth.invite_hint")}
} {!isReg &&
{ e.preventDefault(); setForgotNote(true); }} style={{ fontSize: "var(--fs-caption)", color: "var(--ink-mute)" }}>{t("auth.forgot")} {forgotNote &&
{t("auth.forgot_note")}
}
}
{ e.preventDefault(); navigate((isReg ? "/login" : "/register") + (params.get("next") ? "?next=" + encodeURIComponent(params.get("next")) : "")); }} style={{ fontSize: "var(--fs-caption)" }}>{t(isReg ? "auth.to_login" : "auth.to_register")}
setPolicyOpen(false)}>
{t("auth.policy_body")}
; } /* =========================================================================== OnboardingPage — /app/onboarding (full-screen · L0 step C + completion) =========================================================================== */ function OnboardingPage({ t, lang, onSetLang }) { const [step, setStep] = useState(0); // 0 = step C profile · 1 = completion const [style, setStyle] = useState(null); const [markets, setMarkets] = useState({}); const [exp, setExp] = useState(null); const toggleMarket = (k) => setMarkets((o) => Object.assign({}, o, { [k]: !o[k] })); const skip = () => navigate("/app", true); const chip = (on, onClick, label, key) => ; return
{step === 0 &&

{t("onboarding.title_c")}

{t("onboarding.subtitle_c")}
{t("onboarding.style_label")}
{[["scalp", "style_scalp"], ["day", "style_day"], ["swing", "style_swing"], ["mixed", "style_mixed"]].map(([k, lk]) => chip(style === k, () => setStyle(k), t("onboarding." + lk), k))}
{t("onboarding.markets_label")}
{[["forex", "market_forex"], ["gold", "market_gold"], ["crypto", "market_crypto"], ["indices", "market_indices"], ["stocks", "market_stocks"]].map(([k, lk]) => chip(!!markets[k], () => toggleMarket(k), t("onboarding." + lk), k))}
{t("onboarding.exp_label")}
{[["new", "exp_new"], ["mid", "exp_mid"], ["pro", "exp_pro"]].map(([k, lk]) => chip(exp === k, () => setExp(k), t("onboarding." + lk), k))}
} {step === 1 &&
🚀

{t("onboarding.done_title")}

{t("onboarding.done_sub")}
{ e.preventDefault(); navigate("/app", true); }} style={{ fontSize: "var(--fs-caption)", color: "var(--ink-mute)" }}>{t("onboarding.finish")}
}
; } /* =========================================================================== ConnectPage — /app/connect (ConnectWizard 3 step · จุด Aha) =========================================================================== */ function BrokerLogo({ abbr, active }) { return
{abbr}
; } function TrustStrip({ text }) { return
{text}
; } function ConnectPage({ t, toast }) { const [brokers, setBrokers] = useState(null); const [step, setStep] = useState(0); const [broker, setBroker] = useState(null); const [platform, setPlatform] = useState("mt5"); const [f, setF] = useState({ server: "", login: "", investor: "", nickname: "" }); const [err, setErr] = useState({}); const [guideOpen, setGuideOpen] = useState(false); const [phase, setPhase] = useState("form"); // form | connecting | done const [connErr, setConnErr] = useState(null); const set = (k, v) => setF((o) => Object.assign({}, o, { [k]: v })); useEffect(() => { window.MTOS_API.get("/brokers").then((r) => setBrokers(r.data)).catch(() => setBrokers([])); }, []); const pickBroker = (b) => { setBroker(b); set("server", b.server_hint || ""); setStep(1); }; const validate = () => { const e = {}; if (!f.server.trim()) e.server = t("connect.err_server"); if (!f.login.trim()) e.login = t("connect.err_login"); if (!f.investor) e.investor = t("connect.err_investor"); setErr(e); return Object.keys(e).length === 0; }; const submit = () => { setConnErr(null); setPhase("connecting"); window.MTOS_API.post("/accounts", { broker_name: broker.name, platform: platform, server: f.server, login: f.login, investor_password: f.investor, nickname: f.nickname }) .then(() => { setPhase("done"); }) .catch((er) => { setPhase("form"); const code = er.envelope && er.envelope.error && er.envelope.error.code; if (code === "master_password_rejected") { setConnErr(t("connect.master_rejected")); set("investor", ""); setStep(1); } else if (code === "duplicate_account") { setConnErr(t("connect.dup_error")); setStep(1); } else { setConnErr(t("states.error_title")); } }); }; const isMt4 = platform === "mt4"; // completion (first sync) if (phase === "done") return
{t("connect.first_sync_started")}
; return

{t("connect." + (step === 0 ? "step1_title" : step === 1 ? "step2_title" : "step3_title"))}

{connErr &&
{connErr}
} {/* STEP 1 — broker */} {step === 0 &&
{!brokers && } {brokers &&
{brokers.map((b) => )}
}
} {/* STEP 2 — credentials / mt4 switch */} {step === 1 && broker &&
{broker.name}
{t("connect.platform_label")}
{["mt5", "mt4"].map((p) => )}
{isMt4 &&
{t("connect.mt4_notice")}
} {!isMt4 &&
set("server", e.target.value)} placeholder={t("connect.server_ph")} data-testid="conn-server" /> set("login", e.target.value)} placeholder={t("connect.login_ph")} inputMode="numeric" autoComplete="off" data-testid="conn-login" /> set("investor", e.target.value)} placeholder={t("connect.investor_ph")} data-testid="conn-investor" /> set("nickname", e.target.value)} placeholder={t("connect.nickname_ph")} autoComplete="off" data-testid="conn-nickname" />
}
} {/* STEP 3 — confirm */} {step === 2 && broker &&
{[[t("connect.confirm_broker"), broker.name], [t("connect.confirm_platform"), platform.toUpperCase()], [t("connect.confirm_server"), f.server], [t("connect.confirm_login"), "••" + (f.login.slice(-4) || f.login)], [t("connect.confirm_nickname"), f.nickname || "—"]].map((r, i) =>
{r[0]}{r[1]}
)}
{ e.preventDefault(); navigate("/app/import"); }} style={{ fontSize: "var(--fs-caption)", color: "var(--ink-mute)" }}>{t("connect.fallback_upload")}
} setGuideOpen(false)}>
{t("connect.guide_body")}
; } /* =========================================================================== Account card (shared: Import list + Settings list) =========================================================================== */ function platformBadge(t, platform) { const map = { mt5: ["badge_mt5"], mt4: ["badge_mt4"], manual: ["badge_manual"] }; const k = map[platform] ? map[platform][0] : "badge_manual"; return t("importsync." + k); } function statusChip(t, s) { const map = { ok: ["status_ok", "var(--profit)", "#e7f8f0"], pending: ["status_pending", "#8a6d2f", "rgba(200,163,90,.16)"], manual: ["status_manual", "var(--ink-mute)", "var(--zebra)"], error: ["status_error", "var(--loss)", "#fdecef"] }; const m = map[s] || map.manual; return {t("importsync." + m[0])}; } function AccountCard({ t, a, onManage }) { return
{a.nickname} {platformBadge(t, a.platform)} {statusChip(t, a.sync_status)}
{a.broker} · ••{a.last4} · {t("importsync.balance_label")} {fmtMoney(a.balance_cents, a.currency).replace("+", "")}
{onManage && }
; } /* =========================================================================== ImportPage — /app/import (Import & Sync · AccountList + upload wizard) =========================================================================== */ function ImportPage({ t, toast }) { const [accts, setAccts] = useState(null); const [wiz, setWiz] = useState(false); const load = useCallback(() => { setAccts(null); window.MTOS_API.get("/accounts").then((r) => setAccts(r.data)).catch(() => setAccts([])); }, []); useEffect(() => { load(); }, [load]); return
{!accts && } {accts && accts.length === 0 && navigate("/app/connect")} />} {accts && accts.length > 0 &&
{t("importsync.accounts_title")}
{accts.map((a) => )}
} setWiz(false)}> {wiz && setWiz(false)} />}
; } function UploadWizard({ t, toast, accounts, onClose }) { const [step, setStep] = useState(0); // 0 account · 1 file · 2 tz · 3 preview const [acct, setAcct] = useState(accounts[0] ? accounts[0].acc_id : ""); const [fileName, setFileName] = useState(""); const [tz, setTz] = useState("Asia/Bangkok"); const [err, setErr] = useState(null); const [preview, setPreview] = useState(null); const [busy, setBusy] = useState(false); const goPreview = () => { setBusy(true); window.MTOS_API.post("/imports", { account_id: acct, filename: fileName, tz: tz }).then((r) => { setPreview(r.data); setStep(3); setBusy(false); }).catch(() => { setBusy(false); setErr(t("states.error_title")); }); }; const commit = () => { setBusy(true); window.MTOS_API.post("/imports/" + preview.imp_id + "/commit").then((r) => { setBusy(false); onClose(); toast(t("importsync.committed_toast", { n: r.data.inserted })); }).catch(() => { setBusy(false); setErr(t("states.error_title")); }); }; const tzOpts = ["Asia/Bangkok", "UTC", "Europe/London", "America/New_York"]; return
{err &&
{err}
} {step === 0 &&
{t("importsync.w_pick_account")}
{accounts.map((a) => )}
} {step === 1 &&
{t("importsync.w_file_label")}
{t("importsync.w_file_hint")}
{fileName || t("importsync.w_file_none")}
{t("importsync.desktop_mapping_hint")}
} {step === 2 &&
{t("importsync.w_tz_label")}
{t("importsync.w_tz_hint")}
} {step === 3 && preview &&
{t("importsync.preview_title")}
{t("importsync.chip_ok", { n: preview.summary.ok })} {t("importsync.chip_fail", { n: preview.summary.fail })} {t("importsync.chip_skip", { n: preview.summary.skip })} {t("importsync.chip_dup", { n: preview.summary.dup })}
{preview.overlap_warning &&
{t("importsync.warn_overlap")}
}
{preview.rows.map((r) => )}
{t("importsync.col_time")}{t("importsync.col_symbol")}{t("importsync.col_dir")} {t("importsync.col_lots")}{t("importsync.col_pnl")}
{r.time ? fmtTime(r.time) : "—"} {r.symbol} {r.direction ? : "—"} {r.lots == null ? "—" : r.lots.toFixed(2)} {r.net_cents == null ? "—" : } {t("importsync.row_status_" + r.status)}
}
; } /* =========================================================================== SettingsPage — /app/settings =========================================================================== */ function Section({ title, children }) { return
{title}
{children}
; } function SettingsPage({ t, toast, onLogout, lang, onSetLang }) { const [me, setMe] = useState(null); const [name, setName] = useState(""); const [tzv, setTzv] = useState(""); const [marketing, setMarketing] = useState(false); const [pw, setPw] = useState({ cur: "", nw: "", cf: "" }); const [pwErr, setPwErr] = useState({}); const [accts, setAccts] = useState(null); const [sheetAcct, setSheetAcct] = useState(null); const [delOpen, setDelOpen] = useState(false); const [delText, setDelText] = useState(""); const [delErr, setDelErr] = useState(null); const loadMe = useCallback(() => { window.MTOS_API.get("/me").then((r) => { setMe(r.data); setName(r.data.display_name); setTzv(r.data.timezone); setMarketing(!!(r.data.consents && r.data.consents.marketing)); }).catch(() => {}); }, []); const loadAccts = useCallback(() => { window.MTOS_API.get("/accounts").then((r) => setAccts(r.data)).catch(() => setAccts([])); }, []); useEffect(() => { loadMe(); loadAccts(); }, [loadMe, loadAccts]); const saveProfile = () => { window.MTOS_API.patch("/me", { display_name: name, timezone: tzv, consents: { marketing: marketing } }) .then(() => toast(t("settings.profile_saved"))).catch(() => toast(t("states.error_title"))); }; const changePw = () => { const e = {}; if (!pw.cur) e.cur = t("settings.err_current"); if (!pw.nw || pw.nw.length < 8) e.nw = t("auth.err_password"); if (pw.cf !== pw.nw) e.cf = t("auth.err_confirm"); setPwErr(e); if (Object.keys(e).length) return; window.MTOS_API.post("/me/password", { current_password: pw.cur, new_password: pw.nw }).then(() => { setPw({ cur: "", nw: "", cf: "" }); toast(t("settings.password_changed")); }).catch(() => toast(t("states.error_title"))); }; const exportData = () => { window.MTOS_API.get("/me/export").then((r) => { try { const blob = new Blob([JSON.stringify(r.data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "mytraderos-export.json"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); } catch (x) {} toast(t("settings.exported")); }).catch(() => toast(t("states.error_title"))); }; const deleteAccount = () => { if (!me || delText.trim() !== me.email) { setDelErr(t("settings.confirm_type_mismatch")); return; } window.MTOS_API.del("/me").then(() => { toast(t("settings.account_deleted")); setTimeout(() => onLogout(), 600); }).catch(() => setDelErr(t("states.error_title"))); }; const moreLinks = [ ["/app/insights", "◎", t("app.nav.insights"), false], ["/app/import", "⇪", t("app.nav.import"), false], ["/app/connect", "◈", t("app.nav.connect"), false], ["/app/unlock", "★", t("app.nav.unlock"), true], ]; return
{/* more links — เข้าถึง Insights/Import/Connect/Unlock จากมือถือ (IA ทางเลือก B · P1-2) */}
{moreLinks.map(([to, icon, label, purple], i) => { e.preventDefault(); navigate(to); }} data-testid={"more-" + to.split("/").pop()} style={{ display: "flex", alignItems: "center", gap: 12, padding: "13px 2px", minHeight: 48, borderTop: i ? "1px solid var(--hairline)" : "none", color: purple ? "var(--brand-strong)" : "var(--ink)", fontWeight: 600, textDecoration: "none" }}> {icon} {label} {purple && {t("settings.more_unlock_hint")}} )}
{/* profile */}
{!me && } {me &&
setName(e.target.value)} data-testid="set-name" />
}
{/* password */}
setPw(Object.assign({}, pw, { cur: e.target.value }))} /> setPw(Object.assign({}, pw, { nw: e.target.value }))} /> setPw(Object.assign({}, pw, { cf: e.target.value }))} />
{/* trading accounts */}
{!accts && } {accts && accts.length === 0 &&
{t("importsync.empty_title")}
} {accts && accts.length > 0 &&
{accts.map((a) => setSheetAcct(a)} />)}
}
{/* PDPA / danger */}
{t("settings.export_hint")}
{t("settings.danger_zone")}
{t("settings.delete_account_warn")}
{/* logout */}
{/* account settings sheet */} setSheetAcct(null)}> {sheetAcct && setSheetAcct(null)} onChanged={() => { setSheetAcct(null); loadAccts(); }} />} {/* delete user confirm */} setDelOpen(false)}> {t("settings.delete_account_warn")} {me && setDelText(e.target.value)} placeholder={me.email} autoComplete="off" data-testid="del-user-input" /> } {delErr &&
}
; } function AccountSettingsSheet({ t, toast, acct, onClose, onChanged }) { const [nick, setNick] = useState(acct.nickname); const [mode, setMode] = useState("main"); // main | archive | delete const [delText, setDelText] = useState(""); const [err, setErr] = useState(null); const saveNick = () => { window.MTOS_API.patch("/accounts/" + acct.acc_id, { nickname: nick }).then(() => { toast(t("settings.acct_saved")); onChanged(); }).catch(() => toast(t("states.error_title"))); }; const archive = () => { window.MTOS_API.patch("/accounts/" + acct.acc_id, { archived: true }).then(() => { toast(t("settings.acct_archived")); onChanged(); }).catch(() => toast(t("states.error_title"))); }; const del = () => { if (delText.trim() !== acct.last4) { setErr(t("settings.confirm_type_mismatch")); return; } window.MTOS_API.del("/accounts/" + acct.acc_id).then(() => { toast(t("settings.acct_deleted")); onChanged(); }).catch(() => setErr(t("states.error_title"))); }; if (mode === "delete") return
{t("settings.acct_delete_warn")} setDelText(e.target.value)} placeholder={acct.last4} autoComplete="off" data-testid="del-acct-input" /> {err &&
}
; if (mode === "archive") return
{t("settings.acct_archive_confirm")}
; return
{acct.broker} ••{acct.last4}{statusChip(t, acct.sync_status)}
setNick(e.target.value)} data-testid="acct-nick" />
; } /* =========================================================================== InsightsPage — /app/insights (dimension chips → breakdown → bucket→trades) =========================================================================== */ function InsightsPage({ t, toast }) { const [phase, setPhase] = useState("loading"); // loading | ready | error | empty const [data, setData] = useState(null); const [errCode, setErrCode] = useState(null); const q = getQuery(); const dimension = q.get("dimension") || "symbol"; const acct = q.get("account_id") || ""; const range = q.get("range") || "30d"; const DIMS = [["symbol", "dim_symbol"], ["session", "dim_session"], ["weekday", "dim_weekday"]]; const load = useCallback(() => { setPhase("loading"); const sp = new URLSearchParams(analyticsQS(acct, range).replace(/^\?/, "")); sp.set("dimension", dimension); window.MTOS_API.get("/analytics/breakdown?" + sp.toString()) .then((r) => { setData(r.data); setPhase((r.data.buckets && r.data.buckets.length) ? "ready" : "empty"); }) .catch((e) => { setErrCode((e.envelope && e.envelope.error && e.envelope.error.code) || "error"); setPhase("error"); }); }, [dimension, acct, range]); useEffect(() => { load(); }, [load]); // ใช้ dimension ของ payload (กัน transient: URL เปลี่ยนก่อน data refetch → label ผิดชุด) const dataDim = data ? data.dimension : dimension; const labelOf = (b) => { if (dataDim === "session") return t("session." + b.key); if (dataDim === "weekday") { const w = t("insights.weekdays_full"); return w[parseInt(b.key, 10)] || b.key; } return b.key; }; const bucketLink = (b) => { if (dataDim === "symbol") return "/app/trades?symbol=" + encodeURIComponent(b.key); if (dataDim === "session") return "/app/trades?session=" + encodeURIComponent(b.key); return "/app/trades"; }; const maxAbs = data && data.buckets ? Math.max.apply(null, data.buckets.map((b) => Math.abs(b.net_pnl_cents)).concat([1])) : 1; return
{/* dimension chips scroll แนวนอน */}
{DIMS.map(([k, lk]) => )}
{phase === "loading" && } {phase === "error" && } {phase === "empty" && } {phase === "ready" && data &&
{data.buckets.map((b) => navigate(bucketLink(b))} data-testid={"bucket-" + b.key}> )}
{t("insights.col_bucket")}{t("insights.col_trades")} {t("insights.col_net")}{t("insights.col_winrate")}
{labelOf(b)}
= 0 ? "var(--profit)" : "var(--loss)" }} />
{b.trades} {fmtPct(b.win_rate)}
} {/* AI insight — เร็วๆ นี้ (Locked sample) */}
{t("insights.ai_card_title")}
{t("insights.ai_card_body")}
{t("insights.coming_soon")}
; } /* =========================================================================== UnlockPage — /app/unlock (pricing จาก mock · ปุ่มซื้อ disabled) =========================================================================== */ function UnlockPage({ t, toast }) { const [plan, setPlan] = useState(null); const [phase, setPhase] = useState("loading"); useEffect(() => { window.MTOS_API.get("/plans").then((r) => { setPlan(r.data[0]); setPhase("ready"); }).catch(() => setPhase("error")); }, []); const feats = t("unlock.features"); return

{t("unlock.headline")}

{t("unlock.sub")}
{phase === "loading" &&
} {phase === "error" && window.location.reload()} />} {phase === "ready" && plan &&
{t("unlock.founder_badge")}
{fmtPlanPrice(plan.price_cents, plan.currency)}
{t("unlock.price_lifetime")}
{t("unlock.seats_left", { n: plan.seats_left })}· {t("unlock.seats_note")}
{t("unlock.features_title")}
{feats.map((ft, i) =>
{ft}
)}
{t("unlock.beta_note")}
e.preventDefault()} style={{ fontSize: "var(--fs-micro)", color: "var(--ink-mute)" }}>{t("unlock.tos")}
}
; } /* =========================================================================== Render (ท้ายไฟล์ — หลัง app.jsx โหลดครบ · App + pages ทั้งหมดพร้อมแล้ว) =========================================================================== */ ReactDOM.createRoot(document.getElementById("root")).render();