// zengtrade, the post-login product. A small hash-routed SPA over Supabase (RLS-isolated data): // Dashboard · Strategies · Activity · Account, plus first-run onboarding. Every render is defensive // (loading / empty / error states), every action gives feedback, all user data is escaped. import { requireAuth, signOut, sb, niceError } from "./auth.js"; import { maybeWorkerBanner, isWorkerAlive } from "./worker-status.js"; import { getTier, isPro, openCheckout, checkoutReady, PLANS, FREE_DEPLOY_LIMIT } from "./billing.js"; import { STRATEGIES, byKey, nameOf } from "./strategies.js"; import { esc, money, pct, num, timeAgo, tone, toast, skeletonRows, equityCurve } from "./ui.js"; import { gaEvent } from "./ga.js"; import { getExchangeStatus, connectExchange, disconnectExchange } from "./exchange.js"; const $ = s => document.querySelector(s); const app = $("#view"); let user = null, tier = "free", billCycle = "month"; let state = { deployments: [], trades: [], loading: true, error: null, workerAlive: true, exchange: { connected: false } }; let currentMode = "algo"; const ROUTES = ["dashboard", "strategies", "forward", "accuracy", "analytics", "activity", "account"]; // /account is a real, standalone clean URL (not a hash route) - same app.html/app.js serve both, // build.py just also copies app.html to dist/account/index.html. On that path there's no shared // tab nav (Account isn't really part of the "your trading performance" tab set) and the route is // always forced to account regardless of any hash, so a stray #foo in the URL can't show through. const ACCOUNT_ONLY = /\/account\/?$/.test(location.pathname); const route = () => (ACCOUNT_ONLY ? "account" : (location.hash.replace("#", "") || "dashboard")); // ---------------------------------------------------------------- boot (async function boot() { user = await requireAuth(); // redirects to /login if signed out if (!user) return; try { await sb.from("event").insert({ name: "pageview", path: (ACCOUNT_ONLY ? "/account" : ("/app" + location.hash)).slice(0, 290), ref: (document.referrer || "").slice(0, 290), }); } catch { /* funnel */ } $("#userEmail").textContent = user.email; $("#signout").onclick = () => signOut(); $("#upgradeBtn").onclick = () => ACCOUNT_ONLY ? (location.href = "/app#pricing") : (location.hash = "pricing"); document.querySelectorAll(".mode-btn").forEach(b => { b.onclick = (e) => { const mode = e.currentTarget.dataset.mode; if (mode === "investing") location.href = "/dashboard?mode=investing"; else if (mode === "trading") location.href = "/dashboard?mode=trading"; else if (mode === "algo") location.href = "/dashboard?mode=algo"; }; }); window.addEventListener("hashchange", render); window.addEventListener("resize", debounce(() => { if (route() === "dashboard" && currentMode === "algo") drawCurve(); }, 150)); buildNav(); render(); // paint shell immediately (loading state) await load(); // then hydrate await maybeOnboard(); await maybeWorkerBanner(); render(); if (new URLSearchParams(location.search).get("paid") === "1") maybePaidReturn(); else maybeIntentUpgrade(); // a "Get Pro/Elite" signup lands on the plan chooser })(); // A visitor who clicked "Get Pro/Elite" on the pricing page arrives here after signup with an intent // stashed by login.html. Route them to the plan chooser instead of a generic dashboard. function maybeIntentUpgrade() { let p = null; try { p = localStorage.getItem("zt_intent_plan"); } catch { return; } if (!p) return; try { localStorage.removeItem("zt_intent_plan"); } catch { /* ignore */ } if (isPro(tier)) return; // already on a paid plan, nothing to upsell location.hash = "pricing"; toast(`You're all set. Choose ${p === "elite" ? "Elite" : "Pro"} below to go live.`, "info"); } // ---------------------------------------------------------------- return from checkout (?paid=1) // The success_url lands the user here the instant they finish the hosted invoice, but the on-chain // confirmation + IPN webhook (which actually grants the tier) can take a few minutes. So we show a // reassuring "confirming" banner and poll profile.tier until it flips, then swap to a success state. async function maybePaidReturn() { const q = new URLSearchParams(location.search); if (q.get("paid") !== "1") return; history.replaceState({}, "", location.pathname + location.hash); // don't replay on refresh if (isPro(tier)) { toast(`You're on ${tier === "elite" ? "Elite" : "Pro"}, thank you!`, "ok"); return; } if (!document.getElementById("ztSpinKf")) { const s = document.createElement("style"); s.id = "ztSpinKf"; s.textContent = "@keyframes ztspin{to{transform:rotate(360deg)}}"; document.head.appendChild(s); } const bar = document.createElement("div"); bar.id = "paidBanner"; bar.setAttribute("role", "status"); bar.style.cssText = "position:sticky;top:0;z-index:70;display:flex;gap:10px;align-items:center;" + "justify-content:center;padding:12px 18px;background:var(--green-soft,#e7f8ef);" + "color:var(--green-d,#067a3a);border-bottom:1px solid var(--green-line,#bfe9cf);" + "font:600 13.5px/1.45 var(--sans,system-ui,sans-serif);text-align:center"; bar.innerHTML = '' + ' Payment received. Your upgrade activates the moment the network confirms it (usually a few minutes). This page updates automatically.'; document.body.prepend(bar); const started = Date.now(); const poll = async () => { let t = "free"; try { t = await getTier(user.id); } catch { /* transient; keep polling */ } if (isPro(t)) { tier = t; bar.style.background = "var(--green,#00ab4e)"; bar.style.color = "#04140a"; bar.innerHTML = `✓ You're on ${t === "elite" ? "Elite" : "Pro"} now. Enjoy the full library.` + ` Verify in /admin`; $("#upgradeBtn").style.display = "none"; await load(); render(); setTimeout(() => bar.remove(), 12000); return; } if (Date.now() - started > 240000) { // give up after ~4 min bar.querySelector("span")?.remove(); bar.textContent = "Still confirming on-chain. Some coins take a few minutes, refresh this page once your wallet shows the payment sent, or we'll update it automatically."; return; } setTimeout(poll, 10000); // re-check every 10s }; setTimeout(poll, 6000); // first check after 6s } function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; } // ---------------------------------------------------------------- data async function load() { state.loading = true; state.error = null; if (window.__ZT_MOCK_USER) { tier = window.__ZT_MOCK_USER.tier || "free"; state.loading = false; $("#upgradeBtn").style.display = isPro(tier) ? "none" : "inline-flex"; return; } try { const [dep, tr, prof] = await Promise.all([ sb.from("deployment").select("*").order("deployed_at", { ascending: false }), sb.from("trade").select("strategy_key,symbol,pnl,cost,entry,exit,closed_at").not("closed_at", "is", null).order("closed_at", { ascending: true }), sb.from("profile").select("tier").eq("id", user.id).maybeSingle(), ]); if (dep.error) throw dep.error; if (tr.error) throw tr.error; state.deployments = dep.data || []; state.trades = tr.data || []; tier = isPro(prof.data?.tier) ? prof.data.tier : "free"; // keep the real tier (pro OR elite) state.workerAlive = await isWorkerAlive(); state.exchange = await getExchangeStatus(); } catch (e) { state.error = niceError(e); toast(state.error, "error"); } finally { state.loading = false; } $("#upgradeBtn").style.display = isPro(tier) ? "none" : "inline-flex"; } // ---------------------------------------------------------------- derived metrics function metrics() { const t = state.trades, n = t.length; const net = t.reduce((a, x) => a + Number(x.pnl || 0), 0); const wins = t.filter(x => Number(x.pnl) > 0).length; const curve = []; let eq = 0; for (const x of t) { eq += Number(x.pnl || 0); curve.push(eq); } const grossW = t.filter(x => x.pnl > 0).reduce((a, x) => a + x.pnl, 0); const grossL = Math.abs(t.filter(x => x.pnl <= 0).reduce((a, x) => a + x.pnl, 0)); return { n, net, win: n ? (100 * wins / n) : 0, curve, pf: grossL ? grossW / grossL : (grossW ? 99 : 0) }; } function perStrategy() { const map = {}; for (const d of state.deployments) map[d.strategy_key] = { key: d.strategy_key, dep: d, net: 0, n: 0, wins: 0 }; for (const t of state.trades) { const m = map[t.strategy_key] || (map[t.strategy_key] = { key: t.strategy_key, dep: null, net: 0, n: 0, wins: 0 }); m.net += Number(t.pnl || 0); m.n++; if (t.pnl > 0) m.wins++; } return Object.values(map); } // ---------------------------------------------------------------- router / render function buildNav() { if (ACCOUNT_ONLY) { const t = document.querySelector("nav.tabs"); if (t) t.hidden = true; return; } // Account has its own standalone page now (see ACCOUNT_ONLY above) - it's dropped from the tab // bar itself, not just hidden when viewed, so there's exactly one way to reach it, not two. $("#nav").innerHTML = ROUTES.filter(r => r !== "account").map(r => `${r[0].toUpperCase() + r.slice(1)}`).join(""); } function render() { const nav = $("#nav"); if (currentMode === "investing") { location.href = "/dashboard?mode=investing"; return; } else if (currentMode === "trading") { location.href = "/dashboard?mode=trading"; return; } if (nav) nav.parentElement.style.display = ""; if (route() === "pricing") { document.querySelectorAll("#nav a").forEach(a=>a.classList.remove("on")); return renderPricing(); } const r = ROUTES.includes(route()) ? route() : "dashboard"; document.querySelectorAll("#nav a").forEach(a => a.classList.toggle("on", a.dataset.r === r)); if (state.error && !state.loading) { app.innerHTML = errorState(); $("#retry").onclick = async () => { render(); await load(); render(); }; return; } ({ dashboard: renderDashboard, strategies: renderStrategies, forward: renderForward, accuracy: renderAccuracy, analytics: renderAnalytics, activity: renderActivity, account: renderAccount }[r])(); } const errorState = () => `

Couldn't load your data

${esc(state.error)}

`; function activationChecklist() { const deployed = state.deployments.some(d => d.status === "running"); const traded = metrics().n > 0; if (deployed && traded) return ""; const s1 = "done", s2 = deployed ? "done" : "on", s3 = traded ? "done" : (deployed ? "on" : ""); const workerNote = deployed && !traded && !state.workerAlive ? `

Paper worker is offline: deploys are saved but trades pause until the worker is running. View evidence · Worker status · E2E status · How paper trading works

` : deployed && !traded && state.workerAlive ? `

Worker is running: first closed trade usually within 5–15 min. Open Forward Test.

` : ""; return `

Activation checklist

signup → deploy → trades
  1. Account created
    You're signed in as ${esc(user.email)}
  2. Deploy a paper strategy
    Algo Studio → Library → Deploy on live Binance prices, zero risk.
  3. First closed trade
    Worker runs every ~5 min; check Forward Test for evidence.${workerNote}
Open Algo Studio Deploy from Strategies tab
`; } // ---- Dashboard ---- function renderDashboard() { if (state.loading) { app.innerHTML = `
${skeletonRows(4)}
${skeletonRows(1)}
`; return; } const m = metrics(), ps = perStrategy(); const deployed = state.deployments.some(d => d.status === "running"); const traded = m.n > 0; let upsell = ""; if (!isPro(tier)) { if (state.deployments.length >= FREE_DEPLOY_LIMIT) { upsell = `
You're on Free, ${FREE_DEPLOY_LIMIT} strategy.Upgrade to Pro for unlimited paper strategies; live execution unlocks per go-live bar (coming soon).
`; } else if (deployed && traded) { upsell = `
Forward evidence is building.Pro unlocks unlimited paper strategies, founding $19/mo while live execution rolls out per go-live bar.
`; } } app.innerHTML = `
your paper book

Evidence & billing

Deploy strategies in Algo Studio. This page tracks your forward paper results, accuracy, and Pro plan.

${activationChecklist()} ${upsell}
Net P&L · after costs${money(m.net)}
Closed trades${num(m.n)}
Win rate${m.n ? pct(m.win) : "-"}
Plan${isPro(tier) ? "Pro" : "Free"}
↝

Equity curve

paper · your deployed strategies
≡

Your strategies

${ps.length ? ps.map(stratRow).join("") : emptyStrategies()}

🔒 Your data is yours alone, isolated per account and enforced at the database level (row-level security).

`; drawCurve(); $("#goStrat") && ($("#goStrat").onclick = goAlgoStudio); $("#up2") && ($("#up2").onclick = () => location.hash = "pricing"); $("#firstDeploy") && ($("#firstDeploy").onclick = goAlgoStudio); } // ---- Investing Mode Dashboard ---- function renderInvestingDashboard() { app.innerHTML = `

Investing Mode

Automate your long-term DCA into high-conviction assets. Systematically buy dips and accumulate based on risk regimes.

Active DCA Schedules 0
Total Accumulated$0.00
Avg Entry Deviation0.00%
$

Automated DCA

Configure your schedule. The engine will dollar-cost average into your chosen asset automatically.

Projected 1Y Accumulation

$18,250 Total
`; // Delay drawing the canvas until the DOM updates setTimeout(() => { const cvs = document.getElementById("dcaCurve"); if (cvs) { // Simulate an upward smooth DCA accumulation curve const proj = []; let acc = 0; for (let i=0; i<50; i++) { acc += 50 * (1 + Math.random() * 0.1); proj.push(acc); } // Draw using our new smooth spline function! equityCurve(cvs, proj); // Force it to use the blue color for Investing mode cvs.style.filter = "hue-rotate(200deg)"; } }, 10); } // ---- Trading Mode Dashboard ---- function renderTradingDashboard() { app.innerHTML = `

Trading Mode

Execute precision manual trades with strict TP/SL brackets. Track your discretionary execution performance.

Active Limit Orders 0
Discretionary Win Rate-
Profit Factor0.00
⚡

Quick Bracket Order

Execute a manual trade with strict risk management. Take-profit and stop-loss are enforced instantly.

Take Profit
$68,450.00
Current Entry: $65,200.00
$63,575.00
Stop Loss
`; } // ---- deploy routing (Algo Studio is primary surface) ---- function goAlgoStudio() { if (!state.deployments.length) { try { localStorage.setItem("zt_fresh_signup", "1"); } catch { /* ignore */ } } location.href = "/dashboard"; } function stratRow(s) { const meta = byKey[s.key] || { name: s.key, style: "" }; const w = s.n ? (100 * s.wins / s.n) : 0; return `
${esc(meta.name)}${esc(meta.style || "paper")}
${num(s.n)}trades
${s.n ? pct(w) : "-"}win
${money(s.net)}net
`; } const emptyStrategies = () => `

No strategies deployed yet.

Deploy one to start paper-trading on live crypto prices, zero money at risk.

`; const emptyDeployCta = (title, blurb) => { const deployed = state.deployments.some((d) => d.status === "running"); const proBtn = deployed && !isPro(tier) ? `` : ""; return `

${title}

${blurb}

Open Algo Studio${proBtn}
`; }; function wireEmptyDeploy() { const b = $("#ev-deploy"); if (b) b.onclick = goAlgoStudio; const pro = $("#ev-pro"); if (pro) pro.onclick = () => { markCheckoutRef("forward_empty_pro"); location.hash = "pricing"; }; } function showPostDeployHint() { if (document.getElementById("ztPostDeployHint")) return; const el = document.createElement("div"); el.id = "ztPostDeployHint"; el.setAttribute("role", "status"); el.style.cssText = "position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:90;" + "max-width:min(520px,calc(100% - 32px));padding:20px;border-radius:20px;" + "background:rgba(255,255,255,0.95);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);" + "border:1px solid rgba(0,171,78,0.3);box-shadow:0 20px 40px -10px rgba(0,0,0,0.15), 0 0 0 4px rgba(0,171,78,0.05);" + "font:600 14px/1.5 var(--sans,system-ui);display:flex;gap:16px;align-items:center;flex-wrap:wrap;animation:riseIn 0.4s cubic-bezier(0.16, 1, 0.3, 1)"; el.innerHTML = `
Strategy deployed to paper
While this builds its track record, upgrade to Pro to ensure you can toggle Live Execution the moment it clears the go-live bar. Explore coin strategies →
Unlock Pro
`; el.querySelector("button").onclick = () => { el.style.animation = "none"; el.style.transform = "translate(-50%, 20px)"; el.style.opacity = "0"; el.style.transition = "all 0.2s"; setTimeout(() => el.remove(), 200); }; const proBtn = el.querySelector("[data-zt-pro-upgrade]"); if (proBtn) { proBtn.onmouseover = () => proBtn.style.transform = "scale(1.03)"; proBtn.onmouseout = () => proBtn.style.transform = "scale(1)"; proBtn.onclick = (e) => { markCheckoutRef("deploy_success_pro"); }; } document.body.appendChild(el); } function forwardEmptyBlurb() { const deployed = state.deployments.some(d => d.status === "running"); if (deployed && !state.workerAlive) { return "Deploy saved in Algo Studio. Paper worker is offline. Trades appear when the worker is back (~every 5 min when live). Worker status · E2E status"; } if (deployed && !isPro(tier)) { return "Waiting for closed trades on your forward book. Unlimited strategies on Pro ($19/mo founding)."; } if (deployed && state.workerAlive) { return "Worker is running: first closed trade usually within 5–15 min on live Binance prices."; } return "Deploy a strategy: the worker fills this in as trades close on live prices."; } // ---- Forward Test (closed-trade evidence) ---- function renderForward() { if (state.loading) { app.innerHTML = `
${skeletonRows(6)}
`; return; } const rows = [...state.trades].reverse(); const m = metrics(); app.innerHTML = `
out-of-sample evidence

Forward Test

Your out-of-sample paper track record: every closed trade on live crypto prices, net of costs. This is the evidence that earns go-live consideration.

Closed trades${num(m.n)}
Win rate${m.n ? pct(m.win) : "-"}
Net P&L${money(m.net)}
Profit factor${m.n ? (m.pf >= 99 ? "∞" : m.pf.toFixed(2)) : "-"}
${rows.length ? `
${rows.slice(0, 100).map(t => ``).join("")}
WhenStrategySymbolP&LCost
${timeAgo(t.closed_at)}${esc(nameOf(t.strategy_key))} ${esc(t.symbol)} ${money(t.pnl, 2)} ${money(t.cost, 2)}
${rows.length > 100 ? `
showing latest 100 of ${num(rows.length)}
` : ""} ` : emptyDeployCta("No closed trades yet.", forwardEmptyBlurb())}
`; wireEmptyDeploy(); } // ---- Accuracy (per-strategy forward stats) ---- function renderAccuracy() { if (state.loading) { app.innerHTML = `
${skeletonRows(4)}
`; return; } const ps = perStrategy(); app.innerHTML = `
go-live readiness

Accuracy

Per-strategy forward accuracy from your closed paper trades: win rate, net P&L, and trade count. Backtest figures are not shown here; only your live forward evidence counts.

${ps.length ? ps.map(s => { const w = s.n ? (100 * s.wins / s.n) : 0; const meta = byKey[s.key] || { name: s.key }; return `
${esc(meta.name)}forward
${num(s.n)}trades
${s.n ? pct(w) : "-"}win
${money(s.net)}net
`; }).join("") : emptyDeployCta("No forward data yet.", forwardEmptyBlurb())}
`; wireEmptyDeploy(); } // ---- Analytics (attribution) ---- function renderAnalytics() { if (state.loading) { app.innerHTML = `
${skeletonRows(4)}
`; return; } const m = metrics(); const bySym = {}; for (const t of state.trades) { const k = t.symbol || "-"; const row = bySym[k] || (bySym[k] = { sym: k, n: 0, net: 0, wins: 0 }); row.n++; row.net += Number(t.pnl || 0); if (t.pnl > 0) row.wins++; } const symRows = Object.values(bySym).sort((a, b) => b.net - a.net); const byStrat = perStrategy().sort((a, b) => b.net - a.net); app.innerHTML = `
P&L attribution

Analytics

P&L attribution across your paper book, by strategy and symbol. All figures are net of booked costs from closed trades.

Total net${money(m.net)}
Strategies${num(byStrat.length)}
Symbols traded${num(symRows.length)}
Expectancy / trade${m.n ? money(m.net / m.n, 2) : "-"}

By strategy

${byStrat.length ? byStrat.map(s => `
${esc((byKey[s.key] || {}).name || s.key)}
${money(s.net)}net
`).join("") : `

Deploy strategies to see attribution.

`}

By symbol

${symRows.length ? symRows.slice(0, 12).map(s => `
${esc(s.sym)}
${num(s.n)}trades
${money(s.net)}net
`).join("") : `

Symbol breakdown appears after your first closed trades.

`}
`; wireEmptyDeploy(); } // ---- Strategies (marketplace) ---- function renderStrategies() { if (state.loading) { app.innerHTML = `
${skeletonRows(3)}
`; return; } const deployed = new Set(state.deployments.map(d => d.strategy_key)); const workerNote = !state.workerAlive ? `

Paper worker is offline: deploys are saved but trades pause until it restarts. Worker status · E2E status · How paper trading works

` : ""; app.innerHTML = `
the library

Strategies

Deploy to paper-trade free. Backtest figures are ~2 years of real data, net of fees, backtest, not forward-proven. Watch each earn its track record in your own book before it ever runs live.

${workerNote}
${STRATEGIES.map(s => stratCard(s, deployed.has(s.key))).join("")}
`; STRATEGIES.forEach(s => { const b = document.getElementById(`act-${s.key}`); if (b) b.onclick = () => deployed.has(s.key) ? stop(s.key) : deploy(s.key); }); } function stratCard(s, isDeployed) { return `
${esc(s.name)}${esc(s.style)}

${esc(s.desc)}

${money(s.bt.net)}net 2yr
${pct(s.bt.win)}win
${s.bt.pf}PF
${s.bt.trades}trades
Backtest · net of fees · not forward-proven
`; } // ---- Activity ---- function renderActivity() { if (state.loading) { app.innerHTML = `
${skeletonRows(6)}
`; return; } const rows = [...state.trades].reverse(); // most recent first app.innerHTML = `
trade log

Activity

Every closed paper trade in your book, newest first. Cost is booked on each.

${rows.length ? `
${rows.slice(0, 200).map(tradeRow).join("")}
WhenStrategySymbolEntryExitCostP&L
${rows.length > 200 ? `
showing latest 200 of ${num(rows.length)}
` : ""} ` : emptyDeployCta("No trades yet.", forwardEmptyBlurb())}
`; wireEmptyDeploy(); } function tradeRow(t) { return ` ${timeAgo(t.closed_at)} ${esc(nameOf(t.strategy_key))} ${esc(t.symbol)} ${t.entry != null ? Number(t.entry).toLocaleString() : "-"} ${t.exit != null ? Number(t.exit).toLocaleString() : "-"} ${money(t.cost, 2)} ${money(t.pnl, 2)}`; } // ---- Account ---- function renderAccount() { const ex = state.exchange; // MONETIZATION FIX (2026-09-20): live trading is a Pro/Elite feature, enforced server-side in // exchange-connect/place-order (saas/supabase/functions/_shared/tier.mjs) regardless of what // this page shows. This branch is the UX complement on the /app entry point (the terminal's // header chip got the same treatment) - a free-tier user sees an upgrade prompt instead of // pasting real credentials only to hit the same 403 the old code already surfaced via toast. const exchangeBody = ex.connected ? `
Exchange Connected ✓ · ${esc(timeAgo(ex.connectedAt))}
Disconnecting removes this key from zengtrade only. It does not revoke it on Binance.
` : !isPro(tier) ? `

Connecting a real exchange account to place live orders is a Pro feature. Free is strictly paper.

` : `
Non-custodial Trade-only

API Management → Create API: check only "Enable Spot Trading". We never see your password.

Binance → Guide →
`; app.innerHTML = `
your profile

Account Settings

Manage your credentials, subscription plan, and exchange connectivity.

Account Details

Email ${esc(user.email)}
Plan
${isPro(tier) ? (tier === "elite" ? "Elite" : "Pro") : "Free"} ${isPro(tier) ? "" : ``}
Mode
Paper Default
Real orders only on connected exchange

Exchange Connection

${exchangeBody}

Danger Zone

End your current session Sign out of this browser session.
`; $("#accUp") && ($("#accUp").onclick = () => ACCOUNT_ONLY ? (location.href = "/app#pricing") : (location.hash = "pricing")); $("#exUpgrade") && ($("#exUpgrade").onclick = () => ACCOUNT_ONLY ? (location.href = "/app#pricing") : (location.hash = "pricing")); $("#accOut").onclick = (e) => { const b = e.currentTarget; b.disabled = true; b.textContent = "Signing out…"; signOut(); }; $("#exConnect") && ($("#exConnect").onclick = async (e) => { const btn = e.currentTarget; const apiKey = $("#exKey").value.trim(), apiSecret = $("#exSecret").value.trim(); if (!apiKey || !apiSecret) return toast("Enter both the API key and secret.", "info"); btn.disabled = true; btn.textContent = "Connecting…"; const r = await connectExchange(apiKey, apiSecret); if (r.error) { toast(r.error, "error"); btn.disabled = false; btn.textContent = "Connect Binance"; return; } toast("Binance connected.", "success"); state.exchange = await getExchangeStatus(); renderAccount(); }); $("#exDisconnect") && ($("#exDisconnect").onclick = async (e) => { const btn = e.currentTarget; btn.disabled = true; btn.textContent = "Disconnecting…"; const r = await disconnectExchange(); if (r.error) { toast(r.error, "error"); btn.disabled = false; btn.textContent = "Disconnect"; return; } toast("Binance disconnected.", "info"); state.exchange = { connected: false }; renderAccount(); }); } // ---------------------------------------------------------------- actions (optimistic + toast) function markCheckoutRef(ref) { try { sessionStorage.setItem("zt_checkout_ref", ref); localStorage.setItem("zt_checkout_ref", ref); } catch { /* ignore */ } } async function deploy(key) { const already = state.deployments.some(d => d.strategy_key === key); if (!isPro(tier) && !already && state.deployments.length >= FREE_DEPLOY_LIMIT) { toast(`Free includes ${FREE_DEPLOY_LIMIT} strategy, upgrade to Pro for unlimited.`, "info"); markCheckoutRef("free_limit_upgrade"); setTimeout(() => location.hash = "pricing", 500); return; } const { error } = await sb.from("deployment").upsert( { user_id: user.id, strategy_key: key, mode: "paper", status: "running" }, { onConflict: "user_id,strategy_key" }); if (error) { const m = niceError(error); if (/free_limit|more than one strategy/i.test(m)) { toast(`Free includes ${FREE_DEPLOY_LIMIT} strategy: upgrade to Pro for unlimited.`, "info"); markCheckoutRef("free_limit_upgrade"); setTimeout(() => { location.hash = "pricing"; }, 500); return; } return toast(m, "error"); } try { await sb.from("event").insert({ name: "deploy_click", path: (location.pathname + location.hash).slice(0, 290) }); await sb.from("event").insert({ name: "deploy_success", path: (location.pathname + location.hash).slice(0, 290) }); } catch { /* funnel */ } gaEvent("deploy_click", { strategy: key }); gaEvent("deploy_success", { strategy: key }); toast(`${nameOf(key)} deployed to paper.`, "success"); showPostDeployHint(); await load(); render(); } async function stop(key) { const dep = state.deployments.find(d => d.strategy_key === key); if (!dep) return; const { error } = await sb.from("deployment").delete().eq("id", dep.id); if (error) return toast(niceError(error), "error"); toast(`${nameOf(key)} stopped.`, "info"); await load(); render(); } // ---- Pricing ---- function renderPricing() { const ready = checkoutReady(); const soon = ready ? "" : `
${"⏳"}
Paid plans open in a few days.The checkout is going through payment-provider activation. Free is fully live now, start there, and you'll be able to upgrade in-place the moment it opens (no re-signup).
`; const workerNote = !state.workerAlive ? `

Paper deploy is live: forward evidence fills in when the worker runs. Open Algo Studio · Status

` : ""; const deployFirst = !state.deployments.length ? `

New here? Deploy a free paper strategy in Algo Studio first, watch forward evidence build, then upgrade when you're ready.

` : ""; app.innerHTML = `
simple · honest · cancel anytime

Simple, honest pricing

Start free. Upgrade when you want more, cancel anytime.

${workerNote} ${deployFirst}
${soon}
${PLANS.map(p => planCard(p, ready)).join("")}

Go-live bar

Read-only · live execution coming soon

Each strategy must clear these gates on your forward paper book before live is considered, with no backtest shortcuts.

* Live execution unlocks per strategy only after it clears the go-live bar in paper. Non-custodial, your keys, your coins. Not investment advice.

`; app.querySelectorAll(".cycle button").forEach(b => b.onclick = () => { billCycle = b.dataset.c; renderPricing(); }); PLANS.forEach(p => { const b = document.getElementById(`plan-${p.id}`); if (b) b.onclick = () => { if (p.id === "free") { location.href = "/dashboard"; return; } openCheckout(user, p.id, billCycle); }; }); } function planCard(p, ready = true) { const price = billCycle === "year" ? p.annual : p.monthly; const per = p.id === "free" ? "" : (billCycle === "year" ? "/yr" : "/mo"); const current = (p.id === "free" && !isPro(tier)) || (p.id === "pro" && isPro(tier)); const paidNotReady = p.id !== "free" && !ready; // checkout still activating const cta = current ? "Current plan" : p.id === "free" ? "Get started" : paidNotReady ? "Opening soon" : `Choose ${esc(p.name)}`; const planIcon = { free: "○", pro: "◈", elite: "✦" }[p.id] || "○"; const isProCard = p.featured; const cardStyle = isProCard ? `background: linear-gradient(180deg, var(--surface) 0%, rgba(0,171,78,0.03) 100%); border: 2px solid var(--green); box-shadow: 0 12px 40px -12px rgba(0,171,78,0.15), 0 0 0 1px var(--green) inset; transform: scale(1.02); z-index: 2;` : `border: 1px solid var(--line);`; const btnStyle = isProCard ? `background: linear-gradient(135deg, var(--green), var(--green-d)); color: white; border: none; box-shadow: 0 4px 12px rgba(0,171,78,0.3); transition: transform 0.2s;` : `transition: transform 0.2s;`; return `
${planIcon}
${esc(p.name)}
$${price}${per}
${esc(p.tagline)}
`; } // ---------------------------------------------------------------- onboarding (first run) async function maybeOnboard() { if (localStorage.getItem("zt_onboarded")) return; if (state.deployments.length > 0) { localStorage.setItem("zt_onboarded", "1"); return; } const dlg = $("#onboard"); if (!dlg) return; dlg.showModal(); $("#ob-deploy").onclick = async () => { dlg.close(); localStorage.setItem("zt_onboarded", "1"); try { localStorage.setItem("zt_fresh_signup", "1"); } catch { /* ignore */ } location.href = "/dashboard"; }; $("#ob-skip").onclick = () => { dlg.close(); localStorage.setItem("zt_onboarded", "1"); }; }