/* ============================================================================ MyTraderOS — /app SPA shell (Wave 0 · L0 MVP) React inline + Babel standalone (no build step) · vendor local (ห้าม CDN) โครง: Router (History API) · SessionGuard (mock) · AppShell (TabBar+sidebar+header) · states กลาง reusable · i18n loader · เชื่อม mock API layer เนื้อหน้าจริงมาใน wave ถัดไป — ทุก route = placeholder ที่ใช้ states กลางได้จริง ============================================================================ */ const { useState, useEffect, useRef, useCallback, useLayoutEffect } = React; /* ---------------------------------------------------------------- i18n ---- */ function flatten(obj, prefix, out) { out = out || {}; prefix = prefix || ""; for (const k in obj) { const v = obj[k], key = prefix ? prefix + "." + k : k; if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); else out[key] = v; } return out; } function getLang() { try { return localStorage.getItem("mtos_lang") || "th"; } catch (e) { return "th"; } } async function loadCatalog(lang) { const res = await fetch("i18n/" + lang + ".json", { cache: "no-store" }); if (!res.ok) throw new Error("catalog " + lang + " " + res.status); return flatten(await res.json()); } /* --------------------------------------------------------- fmt helpers ---- */ /* แสดงผลเท่านั้น — ค่ามาจาก API (client ห้ามคำนวณเลขเทรด) */ function fmtMoney(cents, currency) { if (cents === null || cents === undefined) return "—"; const sym = currency === "THB" ? "฿" : "$"; const v = cents / 100; const sign = v > 0 ? "+" : v < 0 ? "−" : ""; const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return sign + sym + abs; } function fmtPct(x) { return x === null || x === undefined ? "—" : (x * 100).toFixed(1) + "%"; } function fmtNum(x, d) { return x === null || x === undefined ? "—" : Number(x).toFixed(d === undefined ? 2 : d); } function fmtTime(iso) { if (!iso) return "—"; try { return new Date(iso).toLocaleString("th-TH", { timeZone: "Asia/Bangkok", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); } catch (e) { return iso; } } /* ------------------------------------------------------------- router ---- */ /* [vision-demo] hash routing — mount ได้ทุก path (เสิร์ฟใต้ /share) · refresh ไม่ 404 · แก้เฉพาะ shim ไม่แตะ logic */ function __vPath() { var h = window.location.hash.replace(/^#/, ""); var p = h.split("?")[0]; return p && p.charAt(0) === "/" ? p : "/"; } function __vSearch() { var h = window.location.hash.replace(/^#/, ""); var i = h.indexOf("?"); return i === -1 ? "" : h.slice(i); } function navigate(to, replace) { if (replace) history.replaceState({}, "", "#" + to); else history.pushState({}, "", "#" + to); window.dispatchEvent(new Event("mtos:nav")); } function useRoute() { const [loc, setLoc] = useState(__vPath() + __vSearch()); useEffect(() => { const on = () => setLoc(__vPath() + __vSearch()); window.addEventListener("popstate", on); window.addEventListener("mtos:nav", on); return () => { window.removeEventListener("popstate", on); window.removeEventListener("mtos:nav", on); }; }, []); return loc; } /* breakpoint hook (desktop ≥1024px) — ใช้เลือก behavior calendar (day card vs sheet) */ function useIsDesktop() { const mq = () => (typeof window.matchMedia === "function" ? window.matchMedia("(min-width:1024px)").matches : window.innerWidth >= 1024); const [d, setD] = useState(mq()); useEffect(() => { const on = () => setD(mq()); let m; if (typeof window.matchMedia === "function") { m = window.matchMedia("(min-width:1024px)"); (m.addEventListener ? m.addEventListener("change", on) : m.addListener(on)); } window.addEventListener("resize", on); return () => { if (m) { m.removeEventListener ? m.removeEventListener("change", on) : m.removeListener(on); } window.removeEventListener("resize", on); }; }, []); return d; } /* ------------------------------------------------- query + date helpers -- */ function getQuery() { return new URLSearchParams(__vSearch()); } function patchQuery(patch, replace) { const sp = new URLSearchParams(__vSearch()); for (const k in patch) { const v = patch[k]; if (v === null || v === undefined || v === "") sp.delete(k); else sp.set(k, v); } const qs = sp.toString(); navigate(__vPath() + (qs ? "?" + qs : ""), replace); } function pad2(n) { return String(n).padStart(2, "0"); } function ymd(d) { return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()); } function ymNow() { const n = new Date(); return n.getFullYear() + "-" + pad2(n.getMonth() + 1); } function parseYMD(s) { if (!/^\d{4}-\d{2}-\d{2}$/.test(s || "")) return null; const p = s.split("-").map(Number); const d = new Date(p[0], p[1] - 1, p[2]); if (d.getFullYear() !== p[0] || d.getMonth() !== p[1] - 1 || d.getDate() !== p[2]) return null; // 02-30 ฯลฯ return d; } function parseYM(s) { if (!/^\d{4}-\d{2}$/.test(s || "")) return null; const p = s.split("-").map(Number); if (p[1] < 1 || p[1] > 12) return null; return { y: p[0], m: p[1] }; } /* เงินย่อ (heatmap/calendar cell) — แสดงผลเท่านั้น ค่าจาก API */ function fmtMoneyShort(cents, currency) { if (cents === null || cents === undefined) return "—"; const sym = currency === "THB" ? "฿" : "$"; const v = cents / 100, a = Math.abs(v); const sign = v > 0 ? "+" : v < 0 ? "−" : ""; const s = a >= 1000 ? (a / 1000).toFixed(a >= 10000 ? 0 : 1) + "k" : a.toFixed(0); return sign + sym + s; } const ROUTES = [ { re: /^\/$/, name: "landing", pub: true }, { re: /^\/login$/, name: "login", pub: true }, { re: /^\/register$/, name: "register", pub: true }, { re: /^\/app\/onboarding$/, name: "onboarding" }, { re: /^\/app$/, name: "dashboard" }, { re: /^\/app\/calendar$/, name: "calendar" }, { re: /^\/app\/trades$/, name: "trades" }, { re: /^\/app\/trades\/([^/]+)$/, name: "trade_detail", keys: ["id"] }, { re: /^\/app\/insights$/, name: "insights" }, { re: /^\/app\/import$/, name: "import" }, { re: /^\/app\/connect$/, name: "connect" }, { re: /^\/app\/unlock$/, name: "unlock" }, { re: /^\/app\/settings$/, name: "settings" }, { re: /^\/app\/vision\/([^/]+)$/, name: "vision", keys: ["key"] }, ]; function matchRoute(pathname) { for (const r of ROUTES) { const m = pathname.match(r.re); if (m) { const params = {}; (r.keys || []).forEach((k, i) => { params[k] = m[i + 1]; }); return { name: r.name, pub: !!r.pub, params: params }; } } return { name: "not_found", pub: false, params: {} }; } /* -------------------------------------------------------- mock session --- */ const Session = { backend() { try { return !!(window.MTOS_API && window.MTOS_API.config && window.MTOS_API.config.base); } catch (e) { return false; } }, active() { try { return localStorage.getItem("mtos_session") === "1"; } catch (e) { return false; } }, login() { try { localStorage.setItem("mtos_session", "1"); localStorage.setItem("mtos_token", "mock.jwt.token"); } catch (e) {} }, _store(d) { try { localStorage.setItem("mtos_session", "1"); if (d.access_token) localStorage.setItem("mtos_token", d.access_token); if (d.refresh_token) localStorage.setItem("mtos_refresh", d.refresh_token); if (d.user) localStorage.setItem("mtos_user", JSON.stringify(d.user)); } catch (e) {} }, _errCode(e) { return (e && e.envelope && e.envelope.error && e.envelope.error.code) || "error"; }, async loginWith(email, pw) { if (!this.backend()) { this.login(); return { ok: true }; } try { var r = await window.MTOS_API.post("/auth/login", { email: email, password: pw }); this._store(r.data); return { ok: true }; } catch (e) { return { ok: false, code: this._errCode(e) }; } }, async registerWith(body) { if (!this.backend()) { this.login(); return { ok: true }; } try { var r = await window.MTOS_API.post("/auth/register", body); this._store(r.data); return { ok: true }; } catch (e) { return { ok: false, code: this._errCode(e) }; } }, async startDemo() { if (!this.backend()) { try { localStorage.setItem("mtos_session", "1"); localStorage.setItem("mtos_demo", "1"); localStorage.setItem("mtos_token", "mock.jwt.token"); } catch (e) {} return { ok: true }; } try { var r = await window.MTOS_API.post("/auth/demo", {}); try { localStorage.setItem("mtos_demo", "1"); } catch (e) {} this._store(r.data); return { ok: true }; } catch (e) { return { ok: false, code: this._errCode(e) }; } }, isDemo() { try { return localStorage.getItem("mtos_demo") === "1"; } catch (e) { return false; } }, logout() { try { ["mtos_session", "mtos_token", "mtos_refresh", "mtos_demo", "mtos_user"].forEach(function (k) { localStorage.removeItem(k); }); } catch (e) {} }, }; /* dev-only UI toggle (state switcher ฯลฯ) — เปิดด้วย localStorage mtos_dev=1 */ function isDevMode() { try { return localStorage.getItem("mtos_dev") === "1"; } catch (e) { return false; } } /* =========================================================================== Central state components (reuse ทุกหน้า — ห้ามหน้าไหนประดิษฐ์เอง) =========================================================================== */ function Skeleton({ variant }) { const rows = variant === "tiles" ? 4 : variant === "list" ? 5 : 3; const box = { background: "linear-gradient(90deg,var(--surface-2),var(--surface-3),var(--surface-2))", backgroundSize: "200% 100%", animation: "mtos-shimmer 1.2s linear infinite", borderRadius: "var(--radius)" }; if (variant === "tiles") { return
| {t("trades.col_time")} | {t("trades.col_symbol")} | {t("trades.col_direction")} | {t("trades.col_pnl")} | {t("trades.col_r")} |
|---|---|---|---|---|
| {fmtTime(tr.closed_at)} | {tr.symbol} | {fmtR(tr.r_multiple)} |
{t("landing.hero_tagline")}
| {t("trades.col_symbol")} | {t("trades.col_direction")} | {t("trades.col_lots")} | {t("trades.col_pnl")} | {t("trades.col_r")} | {t("trades.col_time")} |
|---|---|---|---|---|---|
| {tr.symbol} |
{tr.lots.toFixed(2)} | {tr.status === "open" ? {t("trades.status_open")} : |
{fmtR(tr.r_multiple)} | {fmtTime(tr.closed_at || tr.opened_at)} |