// 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)}
Try again `;
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
Account created You're signed in as ${esc(user.email)}
Deploy a paper strategy Algo Studio → Library → Deploy on live Binance prices, zero risk.
First closed trade Worker runs every ~5 min; check Forward Test for evidence. ${workerNote}
`;
}
// ---- 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).
Upgrade, $19/mo founding `;
} 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.
Go Pro, $19/mo `;
}
}
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 + Deploy
${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 Deviation 0.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 Factor 0.00
⚡
Quick Bracket Order
Execute a manual trade with strict risk management. Take-profit and stop-loss are enforced instantly.
Market & Direction
BTC/USDT
ETH/USDT
LONG
SHORT
Position Size (USD)
Risk/Reward Preset
1:2 (Tight)
1:3 (Standard)
Execute Market Bracket
Current Entry: $65,200.00
`;
}
// ---- 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.
Open Algo Studio `;
const emptyDeployCta = (title, blurb) => {
const deployed = state.deployments.some((d) => d.status === "running");
const proBtn = deployed && !isPro(tier)
? `Pro $19/mo, unlimited `
: "";
return ``;
};
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 →
`;
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.length > 100 ? `` : ""}
` : 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.
Deploy `}
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
${isDeployed ? "Stop" : "Deploy to paper"}
`;
}
// ---- 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.length > 200 ? `` : ""}
` : 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.
Disconnect
`
: !isPro(tier)
? `
Connecting a real exchange account to place live orders is a Pro feature. Free is strictly paper.
Unlock Pro
`
: ``;
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) ? "" : `Upgrade `}
Mode
Paper Default
Real orders only on connected exchange
Exchange Connection
${exchangeBody}
Danger Zone
End your current session
Sign out of this browser session.
Sign out
`;
$("#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}
Monthly
Annual 2 months free
${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.
≥ 30 closed paper trades
Net P&L after costs > 0
Profit factor ≥ 1.1
Positive expectancy in ≥ 2 market regimes
Pro or Elite tier when live rail ships
* 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)}
${p.features.map(f => `✓ ${esc(f)} `).join("")}
${cta}
`;
}
// ---------------------------------------------------------------- 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"); };
}