/* zengtrade Studio shim v2: the REAL terminal as a MULTI-USER crypto product.
*
* Loaded BEFORE app.js in the deployed /dashboard copy only (build.py injects it;
* the operator's local terminal never loads this file).
*
* Data plane (crypto-global, no operator-Mac dependency in steady state):
* shared : engine_state table in Supabase (regime, catalog, metrics),
* published server-side, read by every client (RLS: public read).
* Falls back to /dashboard/data/*.json static seeds if a key is missing.
* per-user: deployment / book_state / trade rows under RLS, each customer
* sees exactly their own book, overlaid onto the shared payloads.
* writes : Deploy/Pause/Stop buttons upsert the user's own deployment rows;
* the 24/7 worker picks them up next cycle. Live mode stays locked.
* prices : Binance public REST/WS run directly in the customer's browser.
*/
(function () {
"use strict";
/* OAuth callbacks must land on /login so Supabase can exchange ?code= before session checks. */
if (/[?&]code=/.test(location.search) || /access_token=/.test(location.hash)) {
location.replace("/login" + location.search + location.hash);
return;
}
var SUPA = "https://ponvarxeytfcntckczbn.supabase.co";
var ANON = "sb_publishable_w-pQMK0bj-91EPHXtA0sMQ__CTu_rf1";
function trackPageview() {
try {
fetch(SUPA + "/rest/v1/event", {
method: "POST",
headers: { apikey: ANON, "Content-Type": "application/json", Prefer: "return=minimal" },
body: JSON.stringify({
name: "pageview",
path: ("/dashboard" + location.hash).slice(0, 290),
ref: (document.referrer || "").slice(0, 290),
}),
keepalive: true,
}).catch(function () {});
} catch (e) {}
}
var LS_AUTH = "sb-ponvarxeytfcntckczbn-auth-token";
/* strategy_keys the cloud worker can actually run (mirror of worker REGISTRY).
* Everything else in the catalog is research/other-venue: visible, not deployable. */
var DEPLOYABLE = {};
["trend_follow","momo","momentum","bollinger","rsi2","macross","ema_cross","adx_trend",
"zscore","nr7","orb","vwap_rev","vwap_mom","ema_scalp","bb_breakout","supertrend",
"vwap_pull","rsi_intraday"].forEach(function (k) { DEPLOYABLE[k] = 1; });
/* ---- session ---- */
function session() {
try {
var s = JSON.parse(localStorage.getItem(LS_AUTH) || "null");
if (!s) return null;
var exp = s.expires_at || (s.session && s.session.expires_at) || 0;
if (exp * 1000 <= Date.now()) return null;
var tok = s.access_token || (s.session && s.session.access_token);
var uid = (s.user && s.user.id) || (s.session && s.session.user && s.session.user.id);
var email = (s.user && s.user.email) || (s.session && s.session.user && s.session.user.email) || "";
return tok && uid ? { tok: tok, uid: uid, email: email } : null;
} catch (e) { return null; }
}
var sess = session();
if (!sess) { location.replace("/login"); return; }
trackPageview();
// BUG FIX (2026-09-19): the header profile avatar was static markup ("SB", the founder's own
// initials left over from building this), shown to every real user regardless of who's
// actually signed in, and had zero click handler at all - a dead CTA. Wired to the real
// signed-in user's own initials.
// UX FIX (2026-09-19, founder): a full navigation away from wherever the user currently is
// (mid Algo Studio session) on a single avatar click was too heavy - a small dropdown with a
// couple of CTAs is the right pattern here, matching how this control works on any SaaS
// product. Menu items reuse the existing real destinations (/account) and the existing
// real sign-out mechanism instead of inventing new ones.
(function wireProfileAvatar() {
var el = document.querySelector(".profile");
if (!el) return;
var local = (sess.email || "").split("@")[0];
el.textContent = local ? local.slice(0, 2).toUpperCase() : "?";
el.setAttribute("title", sess.email || "Account");
el.style.cursor = "pointer";
el.style.position = "relative";
el.setAttribute("role", "button");
el.setAttribute("aria-haspopup", "true");
el.setAttribute("aria-expanded", "false");
el.setAttribute("tabindex", "0");
var menu = null;
function closeMenu() {
if (!menu) return;
menu.remove(); menu = null;
el.setAttribute("aria-expanded", "false");
document.removeEventListener("click", onOutsideClick, true);
document.removeEventListener("keydown", onKeydown, true);
}
function onOutsideClick(e) { if (menu && !menu.contains(e.target) && e.target !== el) closeMenu(); }
function onKeydown(e) { if (e.key === "Escape") closeMenu(); }
async function doSignOut(btn) {
if (btn) { btn.disabled = true; btn.textContent = "Signing out…"; }
try {
await fetch(SUPA + "/auth/v1/logout", { method: "POST", headers: sbHeaders() });
} catch (e) {}
try { localStorage.removeItem(LS_AUTH); } catch (e) {}
try { localStorage.removeItem("tradepro.terminal.v1"); } catch (e) {}
if (btn) btn.textContent = "Signed out ✓";
setTimeout(function () { location.href = "/login"; }, 250);
}
function openMenu() {
menu = document.createElement("div");
menu.setAttribute("role", "menu");
menu.style.cssText = "position:absolute;top:calc(100% + 8px);right:0;min-width:200px;"
+ "background:var(--surface,#fff);border:1px solid var(--line,#e3e8f0);border-radius:12px;"
+ "box-shadow:var(--shadow,0 8px 24px rgba(15,26,42,.14));padding:6px;z-index:200;"
+ "font-family:var(--sans);text-align:left";
menu.innerHTML =
'
'
+ (sess.email || "") + '
'
+ ''
+ ''
+ '';
el.appendChild(menu);
el.setAttribute("aria-expanded", "true");
menu.querySelector('[data-pm-item="evidence"]').onclick = function (e) { e.stopPropagation(); location.href = "/app"; };
menu.querySelector('[data-pm-item="account"]').onclick = function (e) { e.stopPropagation(); location.href = "/account"; };
menu.querySelector('[data-pm-item="signout"]').onclick = function (e) { e.stopPropagation(); doSignOut(e.currentTarget); };
menu.querySelectorAll("button").forEach(function (b) {
b.onmouseenter = function () { b.style.background = "var(--surface-2,#f4f6fa)"; };
b.onmouseleave = function () { b.style.background = "none"; };
});
setTimeout(function () {
document.addEventListener("click", onOutsideClick, true);
document.addEventListener("keydown", onKeydown, true);
}, 0);
}
el.onclick = function (e) { e.stopPropagation(); if (menu) closeMenu(); else openMenu(); };
el.onkeydown = function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); el.onclick(e); } };
})();
setTimeout(checkFirstClosedTrade, 2000);
setInterval(function () { if (document.visibilityState === "visible") checkFirstClosedTrade(); }, 30000);
function sbHeaders(extra) {
var h = { apikey: ANON, Authorization: "Bearer " + (session() || {}).tok,
"Content-Type": "application/json" };
if (extra) for (var k in extra) h[k] = extra[k];
return h;
}
function jresp(obj, code) {
return new Response(JSON.stringify(obj),
{ status: code || 200, headers: { "Content-Type": "application/json" } });
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
}
/* ---- real (non-custodial) order execution bridge ----
* assets/app.js (the terminal itself) has zero Supabase awareness - it only ever writes to
* localStorage. This is the ONLY place that talks to Supabase for the whole /dashboard page, so
* it's the natural, minimal-blast-radius integration point for real orders, same reasoning as
* every other real (non-paper) call already bridged from here. Trading mode feature-detects
* window.ztExchange before ever showing a Live toggle - on the untouched local terminal (no
* studio.js, no session) this global simply doesn't exist, so real orders are structurally
* unreachable outside the authenticated production surface, not just hidden by a UI check. */
window.ztExchange = {
status: function () {
return fetch(SUPA + "/rest/v1/exchange_connection?exchange=eq.binance&select=exchange,connected_at",
{ headers: sbHeaders() })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) { return (rows && rows[0]) ? { connected: true, connectedAt: rows[0].connected_at } : { connected: false }; })
.catch(function () { return { connected: false }; });
},
placeOrder: function (spec) {
return fetch(SUPA + "/functions/v1/place-order", { method: "POST", headers: sbHeaders(), body: JSON.stringify(spec) })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }).catch(function () { return { ok: false, data: { error: "unexpected response" } }; }); })
.catch(function () { return { ok: false, data: { error: "network error, please try again" } }; });
},
// same exchange-connect Edge Function saas/web/js/exchange.js's connectExchange() calls from
// the /app Account page - this is just a second caller, reachable from the header chip
// without leaving the terminal.
connect: function (apiKey, apiSecret) {
return fetch(SUPA + "/functions/v1/exchange-connect", { method: "POST", headers: sbHeaders(), body: JSON.stringify({ apiKey: apiKey, apiSecret: apiSecret }) })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }).catch(function () { return { ok: false, data: { error: "unexpected response" } }; }); })
.catch(function () { return { ok: false, data: { error: "network error, please try again" } }; });
},
// MONETIZATION FIX (2026-09-20): lets the terminal show a Pro/Elite upgrade prompt BEFORE a
// free-tier user pastes real exchange credentials, instead of only finding out via a 403 from
// exchange-connect at the very end. This is a UX convenience only - the real gate is server-side
// (_shared/tier.mjs, checked again independently by both exchange-connect and place-order), so
// there's no harm if this read is stale or skipped. RLS scopes the row to the caller already.
tier: function () {
return fetch(SUPA + "/rest/v1/profile?select=tier", { headers: sbHeaders() })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) { return (rows && rows[0] && rows[0].tier) || "free"; })
.catch(function () { return "free"; });
},
liveOrders: function () {
return fetch(SUPA + "/rest/v1/live_order?select=id,symbol,side,qty,avg_price,notional_usd,binance_order_id,status,created_at&order=created_at.desc&limit=50",
{ headers: sbHeaders() })
.then(function (r) { return r.ok ? r.json() : []; })
.catch(function () { return []; });
},
};
/* ---- toasts: reuse the terminal's own #toastWrap/.toast component (same one every other
* tab's confirmations use) instead of a bespoke floating card. Fixes two real bugs the ad hoc
* version had: (1) it rendered at document.body scale with up to 4 action links, so on shorter
* viewports it grew tall enough to cover the very form it was nudging the user to fill in;
* (2) it looked and behaved differently from every other notification in the product. */
function ztToast(o) {
var host = document.getElementById("toastWrap");
if (!host || (o.uniqueClass && host.querySelector("." + o.uniqueClass))) return null;
var t = document.createElement("div");
t.className = "toast" + (o.uniqueClass ? " " + o.uniqueClass : "");
t.setAttribute("role", "status");
var icoFn = typeof window.icon === "function" ? window.icon : function () { return ""; };
var acts = (o.actions || []).map(function (a) {
return '";
}).join("");
t.innerHTML = '
' + icoFn(o.icon, 22) + '
' +
'
' + esc(o.title) + "" + o.body + "
" +
'
' + acts + "
";
host.appendChild(t);
function dismissToast() {
if (typeof window.dismiss === "function") { window.dismiss(t); return; }
t.classList.add("out");
setTimeout(function () { t.remove(); }, 300);
}
(o.actions || []).forEach(function (a) {
var btn = t.querySelector('[data-zt-act="' + a.key + '"]');
if (btn) btn.onclick = function () { if (a.onClick) a.onClick(); dismissToast(); };
});
t._ztDismiss = dismissToast;
if (o.timeout) t._timer = setTimeout(function () { if (document.body.contains(t)) dismissToast(); }, o.timeout);
return t;
}
function trackEvent(name) {
try {
ORIG(SUPA + "/rest/v1/event", {
method: "POST",
headers: sbHeaders({ Prefer: "return=minimal" }),
body: JSON.stringify({ name: name, path: location.pathname.slice(0, 290) }),
keepalive: true,
}).catch(function () {});
} catch (e) {}
}
/* GA4 (zengtrade, web stream 15728393601): not an ES module here, so a plain defensive
* wrapper around window.gtag rather than importing js/ga.js. Fires alongside every
* trackEvent() call, a second destination, not a replacement for the Supabase funnel. */
function gaEvent(name, params) {
try {
if (typeof window.gtag === "function") window.gtag("event", name, params || {});
} catch (e) {}
}
function showForwardHint() {
try {
if (localStorage.getItem("zt_seen_forward")) return;
localStorage.setItem("zt_seen_forward", "1");
} catch (e) { return; }
function renderHint(workerUp) {
setTimeout(function () {
var sub = workerUp
? "Trades appear in Evidence as the worker runs on live prices (usually within 15 min)."
: "Deploy saved. Trades will run when the paper worker is back online. Check the banner above.";
/* the toast itself stays a clean 2-button confirmation (matching every other toast in the
* app); this inline link is a secondary funnel touchpoint, not a 3rd action button, kept
* as a small text link (.mon-acc-link, same class the Monitor row's own inline link uses)
* so it doesn't compete visually with "View evidence". */
var coinsLink = ' More coin strategies';
ztToast({
uniqueClass: "zt-forward-toast",
icon: "check",
title: "Strategy deployed",
body: sub + coinsLink,
timeout: 9000,
actions: [
{ key: "view", cls: "primary", label: "View evidence", onClick: function () { location.href = "/app#forward"; } },
{ key: "ok", cls: "ghost", label: "Dismiss" },
],
});
}, 800);
}
ORIG(SUPA + "/rest/v1/engine_state?key=eq._worker_heartbeat&select=updated_at", {
headers: sbHeaders(),
}).then(function (r) { return r.json(); }).then(function (rows) {
var ts = rows && rows[0] && rows[0].updated_at;
var up = ts && (Date.now() - new Date(ts).getTime() <= 12 * 60 * 1000);
renderHint(!!up);
}).catch(function () { renderHint(false); });
}
var FREE_DEPLOY_LIMIT = 1;
function markCheckoutRef(ref) {
try {
sessionStorage.setItem("zt_checkout_ref", ref);
localStorage.setItem("zt_checkout_ref", ref);
} catch (e) {}
}
function goUpgradeFromFreeLimit() {
markCheckoutRef("free_limit_upgrade");
location.href = "/app#pricing";
}
function maybeWorkerBanner() {
ORIG(SUPA + "/rest/v1/engine_state?key=eq._worker_heartbeat&select=updated_at", {
headers: sbHeaders(),
}).then(function (r) { return r.json(); }).then(function (rows) {
var ts = rows && rows[0] && rows[0].updated_at;
if (!ts) return showWorkerDown();
var age = Date.now() - new Date(ts).getTime();
if (age > 12 * 60 * 1000) showWorkerDown();
}).catch(function () {});
}
function showWorkerDown() {
if (document.getElementById("ztWorkerDown")) return;
var el = document.createElement("div");
el.id = "ztWorkerDown";
el.className = "zt-banner-warn";
el.setAttribute("role", "status");
el.innerHTML = "Paper worker offline: deploys save, but trades pause until the worker restarts. " +
'View evidence · ' +
'Worker status · ' +
'E2E status · ' +
'How paper trading works';
/* in-flow, not fixed: every other system banner in the terminal (rdy-banner, bot-banner,
* rg-banner) sits in the page instead of floating over it. A fixed top bar here used to
* render on top of the sticky .topbar (position:sticky;top:0;z-index:50) on every tab. */
document.body.insertBefore(el, document.body.firstChild);
}
/* ---- customers are pinned to the crypto book; persona (Investing/Trading/Algo Studio) is now
* a real, user-chosen header toggle, not an operator-only surface - do not touch it here. ---- */
try {
var K = "tradepro.terminal.v1";
var st0 = JSON.parse(localStorage.getItem(K) || "null") || {};
// SECURITY FIX (2026-09-20): this blob was never scoped to a signed-in user - on a shared
// device, sign-out only ever cleared the auth token (see doSignOut below), never this key, so
// whoever's orders/DCA plans/deployed strategies/layouts were here stayed visible to the next
// person who signed in. Tag the blob with the current user's id and wipe it on mismatch, so a
// different user always starts clean, without assets/app.js (which has no auth awareness of
// its own) needing to know anything about this.
if (st0._owner && st0._owner !== sess.uid) st0 = {};
st0._owner = sess.uid;
// BUG FIX (2026-09-19): this used to force st0.persona = "algo" unconditionally on every single
// page load, before crypto-only.js or app.js even ran - silently clobbering a real saved
// Investing/Trading choice back to Algo Studio on every reload. That was correct back when
// Trading/Investing were operator-only, but they are now a real customer-facing header toggle;
// this file must leave persona alone, same as the equivalent fix already made in crypto-only.js.
st0.algo = st0.algo || {}; st0.algo.market = "crypto"; st0.algo.view = st0.algo.view || "monitor";
localStorage.setItem(K, JSON.stringify(st0));
} catch (e) {}
var ORIG = window.fetch.bind(window);
// BUG FIX (2026-09-06): this used to be called right after its definition, BEFORE the
// `var ORIG = ...` line below: ORIG was still undefined at that point (var hoisting only
// hoists the declaration, not the assignment), so maybeWorkerBanner() threw "ORIG is not a
// function" on every single /dashboard/ page load. That uncaught error silently skipped this
// call, meaning the "paper worker is offline" banner never showed even while the worker was
// genuinely down. Now it only runs once ORIG actually holds the native fetch.
maybeWorkerBanner();
/* ---- shared payloads: engine_state row, else static seed ---- */
function engineGet(fileKey) {
return ORIG(SUPA + "/rest/v1/engine_state?key=eq." + fileKey + "&select=value",
{ headers: sbHeaders() })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) {
if (rows && rows.length) return rows[0].value;
return ORIG("/dashboard/data/" + fileKey + ".json")
.then(function (r) { return r.ok ? r.json() : {}; });
})
.catch(function () { return {}; });
}
/* ---- per-user rows (RLS scopes automatically to the JWT's user) ---- */
function mine(pathq) {
return ORIG(SUPA + "/rest/v1/" + pathq, { headers: sbHeaders() })
.then(function (r) { return r.ok ? r.json() : []; })
.catch(function () { return []; });
}
/* ---- overlay: shared crypto monitor + THIS user's deployments/book ---- */
function posList(b) {
var pos = (b && b.positions) || {};
return Object.keys(pos).map(function (sym) {
var p = pos[sym] || {};
return { sym: sym, qty: p.qty, entry: p.entry };
});
}
function cryptoMonitor() {
return Promise.all([
engineGet("api_crypto_monitor"),
mine("deployment?select=strategy_key,status,params&mode=eq.paper"),
mine("book_state?select=strategy_key,realised,positions"),
]).then(function (all) {
var shared = all[0] || {}, deps = all[1] || [], books = all[2] || [];
var running = {}, book = {};
deps.forEach(function (d) { if (d.status === "running") running[d.strategy_key] = 1; });
books.forEach(function (b) { book[b.strategy_key] = b; });
var out = JSON.parse(JSON.stringify(shared));
var totalReal = 0, totalOpen = 0;
(out.strategies || []).forEach(function (s) {
var b = book[s.id];
var pl = posList(b);
s.realisedPnl = b ? Number(b.realised || 0) : 0;
s.openPnl = 0; // client marks-to-market later; honest zero for now
s.paperPnl = s.realisedPnl;
s.positions = pl;
s.openPositions = pl.length;
s.deployed = !!running[s.id];
s.wired = !!DEPLOYABLE[s.id]; // only worker-runnable strategies are deployable
totalReal += s.realisedPnl; totalOpen += pl.length;
});
/* NOTE: custom (Builder) strategies are NOT injected into the Monitor here.
* The terminal renders Monitor rows from its fixed CRYPTO_STRATEGIES catalog and
* ignores unknown ids, so a pushed custom row would never render yet WOULD skew
* the header totals. Custom strategies live fully in the Builder tab (deploy,
* status, per-strategy P&L). Their realised P&L is intentionally excluded from
* the built-in Monitor totals to keep rows and totals consistent. */
out.totals = { realised: +totalReal.toFixed(4), unreal: 0,
pnl: +totalReal.toFixed(4), open: totalOpen };
/* the customer's "harness" is the cloud worker, and it is ALWAYS running -
* never show the operator's "start python3 ..." empty state to a customer */
out.running = true;
out.perUser = true;
return out;
});
}
/* ---- real per-user closed trades: the system of record (mirrors saas/web/js/app.js's
* metrics()/perStrategy() exactly, so /dashboard and /app never disagree on a number). ---- */
function myTrades() {
return mine("trade?select=strategy_key,symbol,pnl,cost,entry,exit,closed_at,regime" +
"&closed_at=not.is.null&order=closed_at.asc");
}
function tradeStats(trades) {
var n = trades.length, net = 0, wins = 0, grossW = 0, grossL = 0;
trades.forEach(function (t) {
var p = Number(t.pnl || 0);
net += p;
if (p > 0) { wins++; grossW += p; } else { grossL += -p; }
});
return { n: n, net: net, wins: wins, losses: n - wins,
winPct: n ? +(100 * wins / n).toFixed(1) : null,
profitFactor: grossL ? +(grossW / grossL).toFixed(2) : (grossW ? 99 : null),
expectancy: n ? net / n : null };
}
function groupBy(list, keyFn) {
var map = {};
list.forEach(function (x) { var k = keyFn(x); (map[k] = map[k] || []).push(x); });
return map;
}
/* ---- retention (R&D charter P0): the ONE moment worth interrupting for -----------------
* Before this, a user who deployed had zero signal anything happened unless they opened
* Forward Test themselves. This fires once, only for the very first closed trade an account
* ever gets (not every trade after - a strategy can close several a day and a toast per
* trade would turn into exactly the "toast-theater" pattern QA already flagged elsewhere).
* localStorage flag makes the check free after the first hit; myTrades() is the same
* RLS-scoped query the Forward/Accuracy tabs already trust. */
function checkFirstClosedTrade() {
try { if (localStorage.getItem("zt_seen_first_trade")) return; } catch (e) { return; }
myTrades().then(function (trades) {
if (!trades || !trades.length) return;
try { localStorage.setItem("zt_seen_first_trade", "1"); } catch (e) {}
var t = trades[0], pnl = Number(t.pnl || 0), win = pnl >= 0;
setTimeout(function () {
ztToast({
uniqueClass: "zt-first-trade-toast",
icon: win ? "check" : "activity",
title: "Your first paper trade just closed",
body: (t.symbol || t.strategy_key) + " closed " + (win ? "up " : "down ") +
(win ? "+$" : "-$") + Math.abs(pnl).toFixed(2) +
". Real evidence from live prices, not a backtest projection.",
timeout: 12000,
actions: [
{ key: "view", cls: "primary", label: "View evidence", onClick: function () { location.href = "/app#forward"; } },
{ key: "ok", cls: "ghost", label: "Dismiss" },
],
});
}, 800);
});
}
/* ---- pricing modal (founder, 2026-09-16): the dashboard's Pricing chip used to navigate away
* to /app#pricing for even just BROWSING plans - full page nav to compare three cards. This
* shows the same real plans in place instead; only the actual purchase (an external redirect to
* a NOWPayments-hosted invoice, unavoidable either way - see billing.js) leaves the page. There
* used to be an in-dashboard pricing modal before this session; it was removed because it showed
* fabricated pre-pivot India-equity prices in rupees with no relation to the real crypto billing.
* This one is real: PLANS below is a verbatim copy of saas/web/js/billing.js's PLANS (that file's
* own comment already calls it a mirror of the Edge Function's authoritative pricing - one more
* client-side copy of an already-accepted pattern, not a new one). Keep both in sync by hand if
* pricing ever changes; there's no shared bundle between /dashboard and /app to import across. */
// Session 2026-09-16 revision: trimmed to the 4 most decision-relevant features per plan (was
// 5) so the modal fits common desktop viewports without an internal scroll - nothing fabricated
// or hidden, the dropped line per plan (Accuracy & Analytics / Email & push alerts / Priority
// support & early access) is still real and still shown on the full comparison at /pricing/,
// linked in the footer. Prices/tiers themselves are unchanged and still mirror billing.js.
var PLANS = [
{ id: "free", name: "Free", monthly: 0, annual: 0, tagline: "Learn and paper-trade, free forever",
features: ["1 paper strategy", "Live crypto prices, 24/7", "Backtest + Forward Test", "Honest cost accounting"] },
{ id: "pro", name: "Pro", monthly: 19, annual: 190, featured: true, tagline: "Founding price · unlimited paper",
features: ["Everything in Free", "Unlimited paper strategies", "Live execution (coming soon)*", "Tick-level stops & kill-switch"] },
{ id: "elite", name: "Elite", monthly: 79, annual: 790, tagline: "Maximum firepower",
features: ["Everything in Pro", "Perps + options engines", "Multiple exchange accounts", "Custom risk parameters"] },
];
var PLAN_ICON = { free: "○", pro: "◈", elite: "✦" };
var pmCycle = "month";
function dashboardCheckout(plan, cycle) {
var sess = session();
if (!sess) { location.href = "/login/?mode=signup"; return; }
var btn = document.querySelector('.pm-cta[data-plan="' + plan + '"]');
if (btn) { btn.disabled = true; btn.textContent = "Starting checkout…"; }
try {
var planDef = PLANS.filter(function (p) { return p.id === plan; })[0];
var value = planDef ? (cycle === "year" ? planDef.annual : planDef.monthly) : 0;
if (window.gtag) window.gtag("event", "begin_checkout", { currency: "USD", value: value,
items: [{ item_id: plan, item_name: "zengtrade " + plan + " (" + cycle + ")", price: value, quantity: 1 }] });
} catch (e) {}
fetch(SUPA + "/functions/v1/nowpayments-create-invoice", {
method: "POST",
headers: sbHeaders(),
body: JSON.stringify({ plan: plan, cycle: cycle }),
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
if (res.ok && res.d && res.d.invoice_url) { window.location.href = res.d.invoice_url; return; }
if (btn) { btn.disabled = false; btn.textContent = "Choose " + esc((PLANS.filter(function (p) { return p.id === plan; })[0] || {}).name || plan); }
ztToast({ icon: "alert", title: "Could not start checkout", body: (res.d && res.d.error) || "Please try again.", timeout: 7000,
actions: [{ key: "ok", cls: "ghost", label: "Dismiss" }] });
})
.catch(function () {
if (btn) { btn.disabled = false; btn.textContent = "Choose " + esc((PLANS.filter(function (p) { return p.id === plan; })[0] || {}).name || plan); }
ztToast({ icon: "alert", title: "Could not start checkout", body: "Please try again.", timeout: 7000,
actions: [{ key: "ok", cls: "ghost", label: "Dismiss" }] });
});
}
function planCardHtml(p) {
var price = pmCycle === "year" ? p.annual : p.monthly;
var per = p.id === "free" ? "" : (pmCycle === "year" ? "/yr" : "/mo");
var cta = p.id === "free"
? ''
: '";
return '
' + icon("shield", 13) + 'The rails are not optional. Every custom strategy runs through the same engine as the built-ins: ATR stops, a cost-aware entry gate that refuses trades which only feed fees, an anti-churn cooldown, a breakeven-after-cost profit lock, and a $1,000 paper notional per position. Global cost model: 35bps round-trip.
' +
'' +
'' +
'
My strategies
Loading…
' +
"
";
}
function renderBuilder() {
var v = document.getElementById("algoView"); if (!v) return;
var head = v.querySelector(".av-head"); if (!head) return;
v.querySelectorAll(".av-tab").forEach(function (t) { t.classList.toggle("on", t.hasAttribute("data-ztbuilder")); });
while (head.nextSibling) head.parentNode.removeChild(head.nextSibling);
head.insertAdjacentHTML("afterend", builderPane());
v.querySelectorAll(".ztb-cond").forEach(syncCond);
myCustoms().then(function (rows) {
var el = document.getElementById("ztbList"); if (el) el.innerHTML = customList(rows);
});
}
function refreshList() {
myCustoms().then(function (rows) {
var el = document.getElementById("ztbList"); if (el) el.innerHTML = customList(rows);
});
}
/* create/remove, not hidden-attribute toggling, matches flowError()/clearFlowError() in
* app.js. (.flow-err sets display:flex, which as author CSS beats the UA [hidden] rule, so
* toggling .hidden on a pre-rendered .flow-err leaves an empty bar visible when "hidden".) */
function ztbShowError(msg) {
var host = document.getElementById("ztbErrHost"); if (!host) return;
host.innerHTML = '
' + icon("alert", 13) + "" + esc(msg) + "
";
}
function ztbClearError() {
var host = document.getElementById("ztbErrHost"); if (host) host.innerHTML = "";
}
function deployCustom() {
var root = document.querySelector(".ztb"); if (!root) return;
var name = (document.getElementById("ztbName").value || "").trim() || "My strategy";
var coins = [].slice.call(root.querySelectorAll(".ztb-chip.on[data-coin]")).map(function (c) { return c.dataset.coin; });
var seg = function (id) { var b = root.querySelector("#" + id + " .on"); return b ? b.dataset.v : null; };
var spec = { name: name, universe: coins.length ? coins : COINS,
interval: seg("ztbIvl") || "day", style: seg("ztbStyle") || "reversion",
entry: readCond(root, "entry"), exit: readCond(root, "exit") };
var s = session(); if (!s) { location.replace("/login"); return; }
ORIG(SUPA + "/rest/v1/deployment?on_conflict=user_id,strategy_key", {
method: "POST",
headers: sbHeaders({ Prefer: "resolution=merge-duplicates,return=minimal" }),
body: JSON.stringify({ user_id: s.uid, strategy_key: slug(name), mode: "paper",
status: "running", params: spec }),
}).then(function (r) {
if (r.ok) {
ztbClearError(); trackEvent("deploy_click"); trackEvent("deploy_success");
gaEvent("deploy_click", { strategy: slug(name), custom: true });
gaEvent("deploy_success", { strategy: slug(name), custom: true });
showForwardHint(); refreshList();
}
else r.json().then(function (e) {
var msg = (e && (e.message || e.details || e.hint)) || "Deploy failed - try again.";
if (/FREE_LIMIT/i.test(msg)) {
ztbShowError("Free includes 1 strategy: upgrade at /app#pricing");
setTimeout(goUpgradeFromFreeLimit, 900);
return;
}
ztbShowError(msg);
}).catch(function () { ztbShowError("Deploy failed - try again."); });
});
}
function customAction(key, act) {
if (act === "delete")
return ORIG(SUPA + "/rest/v1/deployment?strategy_key=eq." + encodeURIComponent(key),
{ method: "DELETE", headers: sbHeaders({ Prefer: "return=minimal" }) }).then(refreshList);
return ORIG(SUPA + "/rest/v1/deployment?strategy_key=eq." + encodeURIComponent(key), {
method: "PATCH", headers: sbHeaders({ Prefer: "return=minimal" }),
body: JSON.stringify({ status: act === "resume" ? "running" : "stopped" }),
}).then(refreshList);
}
/* keep our tab present across the app's full innerHTML re-renders, and keep the
* Builder pane alive: the app's async data loads call renderAlgo() which rebuilds
* #algoView wholesale - if the user is on Builder, we re-render it on top. */
var builderOn = false;
function injectTab() {
var bar = document.querySelector("#algoView .av-tabs");
if (bar && !bar.querySelector("[data-ztbuilder]")) {
var b = document.createElement("button");
b.className = "av-tab"; b.setAttribute("role", "tab"); b.setAttribute("data-ztbuilder", "1");
b.textContent = "Builder";
bar.appendChild(b);
}
if (builderOn && document.getElementById("algoView") && !document.querySelector(".ztb"))
renderBuilder();
}
function watch() {
var v = document.getElementById("algoView"); if (!v) return setTimeout(watch, 600);
injectTab();
new MutationObserver(injectTab).observe(v, { childList: true, subtree: true });
}
document.addEventListener("click", function (e) {
var t = e.target.closest && e.target.closest("[data-ztbuilder]");
if (t) {
e.preventDefault(); builderOn = true; renderBuilder();
var nudge = document.querySelector(".zt-cold-nudge");
if (nudge && nudge._ztDismiss) nudge._ztDismiss();
return;
}
if (e.target.closest && e.target.closest("[data-algoview]")) { builderOn = false; }
if (e.target.id === "ztbDeploy") { deployCustom(); return; }
var pre = e.target.closest && e.target.closest("[data-ztpreset]");
if (pre) { applyPreset(PRESETS[pre.dataset.ztpreset]); return; }
var coin = e.target.closest && e.target.closest(".ztb-chip[data-coin]");
if (coin) { coin.classList.toggle("on"); return; }
var seg = e.target.closest && e.target.closest("#ztbIvl button, #ztbStyle button");
if (seg) { seg.parentNode.querySelectorAll("button").forEach(function (x) { x.classList.remove("on"); }); seg.classList.add("on"); return; }
var act = e.target.closest && e.target.closest("[data-zta]");
if (act) { customAction(act.closest(".ztb-item").dataset.key, act.dataset.zta); return; }
});
document.addEventListener("change", function (e) {
if (e.target.matches && e.target.matches('.ztb-cond [data-f="ind"]'))
syncCond(e.target.closest(".ztb-cond"), true); // switching signal applies its sane default value
});
document.addEventListener("input", function (e) {
var c = e.target.closest && e.target.closest(".ztb-cond");
if (c) syncCond(c); // live plain-English sentence as they type
});
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", watch);
else watch();
})();