PageSourceSearch

https://gex-levels.com/assets/app.js?v=bbc80124f523

js gex-levels.com collected 2026-09-24 14:43:58 UTC 169,778 bytes, 3,371 lines download raw bytes

1const publicEnv = window.GEX_LEVELS_PUBLIC_ENV || {};
2const apiBase = window.GEX_LEVELS_API_BASE || publicEnv.apiBaseUrl || "https://api.gex-levels.com";
3// Auth API base. May be a same-origin path (e.g. "/api") that a Netlify proxy
4// forwards to the Worker, so that the session/CSRF cookies are first-party on the
5// site domain (cross-domain cookies to *.workers.dev are not sent on fetch and
6// cannot be read via document.cookie). Defaults to apiBase when not configured.
7const authApiBase = window.GEX_LEVELS_AUTH_BASE || publicEnv.authBaseUrl || apiBase;
8const siteUrl = publicEnv.siteUrl || "https://gex-levels.com";
9const sessionTokenKey = "gex_levels_session_token";
10const sessionExpiresKey = "gex_levels_session_expires_at";
11const accountTokenKey = "gex_levels_account_token";
12const accountExpiresKey = "gex_levels_account_expires_at";
13const deviceFingerprintKey = "gex_levels_device_fingerprint";
14
15function setText(selector, text) {
16  // querySelectorAll so duplicate hooks (e.g. the redesign shows the email in
17  // both the header and the account panel) all populate, not just the first.
18  document.querySelectorAll(selector).forEach((target) => {
19    target.textContent = text;
20  });
21}
22
23// The masked-key "Copy" row only makes sense when there is a real key to copy.
24// For account types where the key is entered directly in the extension (never
25// stored here), the value is "-", which rendered as a stray empty pill next to
26// a no-op Copy button. Set the value AND hide the whole row when it's empty.
27// One rule for "which key is THE user's key" everywhere on the account page
28// (2026-09-03). Several customers legitimately hold 2-3 active keys (a manual
29// key issued by support next to a Whop-minted one, or a re-trial). Display,
30// Reveal and Rotate used to each take the FIRST active row, which could be a
31// different key from the one typed into the platform -> "my key doesn't work".
32// Prefer the key most recently seen by the API, then the newest.
33function productLicenseRows(list, family = "indicator") {
34  return (Array.isArray(list) ? list : []).filter((l) => l && (family === "terminal"
35    ? ["terminal_monthly", "terminal_quarterly", "terminal_yearly"].includes(l.plan_id)
36    : ["indicator_monthly", "indicator_quarterly", "indicator_yearly", "manual_beta", "basic", "pro", "research"].includes(l.plan_id)));
37}
38function licenseIsCurrent(l) {
39  return l && l.status === "active" && !l.revoked_at && (!l.expires_at || Date.parse(l.expires_at) > Date.now());
40}
41function pickPrimaryLicense(list, family = "indicator") {
42  const rows = productLicenseRows(list, family);
43  const active = rows.filter(licenseIsCurrent);
44  const pool = active.length ? active : rows;
45  const ts = (v) => { const t = Date.parse(v || ""); return Number.isFinite(t) ? t : 0; };
46  return pool.slice().sort((a, b) => (ts(b.last_used_at) - ts(a.last_used_at)) || (ts(b.created_at) - ts(a.created_at)))[0] || null;
47}
48function activeLicenseCount(list) {
49  return productLicenseRows(list).filter(licenseIsCurrent).length;
50}
51
52function renderTerminalLicense(list, failed = false) {
53  const license = pickPrimaryLicense(list, "terminal");
54  const masked = license?.masked_key || license?.key_prefix || "No Terminal licence on this account";
55  setText("[data-terminal-license-key]", failed ? "Could not load — refresh and retry" : masked);
56  setText("[data-terminal-license-status]", license ? (licenseIsCurrent(license) ? "active" : (license.revoked_at ? "revoked" : "inactive / expired")) : "—");
57  setText("[data-terminal-license-expiry]", license?.expires_at ? new Date(license.expires_at).toLocaleDateString() : "—");
58  setText("[data-terminal-license-copy]", failed ? "Key unavailable" : masked);
59  const code = document.querySelector("[data-terminal-license-copy]");
60  if (code) delete code.dataset.fullKey;
61  const button = document.querySelector("[data-terminal-license-reveal]");
62  if (button) { button.disabled = !license; button.textContent = "Reveal Terminal key"; button.setAttribute("aria-pressed", "false"); }
63  setText("[data-terminal-license-output]", "");
64}
65
66function setMaskedKeyCopy(value) {
67  const real = value && value !== "-" ? String(value) : "";
68  // Owner 2026-08-30: the band stays VISIBLE at all times (it used to vanish when
69  // there was no masked prefix — e.g. "Entered in the extension · not stored here" —
70  // which left the card looking empty). The KEY itself stays masked; when there is
71  // no real prefix we mirror the licence-key line so the band still reads sensibly.
72  const fallback = (document.querySelector("[data-account-license]")?.textContent || "").trim();
73  setText("[data-account-license-copy]", real || (fallback && fallback !== "-" ? fallback : "•••• · reveal to copy"));
74  const code = document.querySelector("[data-account-license-copy]");
75  if (code) {
76    // Anything set through here is a MASKED preview (server never returns the full
77    // key on load — it is hashed at rest). Clear the full-key flag so the C
77opy
78    // button won't hand out a non-working prefix. The rotate flow sets the flag.
79    delete code.dataset.fullKey;
80  }
81  const row = code ? (code.closest(".rm-keyrow") || code.closest(".copy-row")) : null;
82  if (row) {
83    row.style.display = ""; // always visible now
84  }
85}
86
87async function checkApiHealth() {
88  const target = document.querySelector("[data-health-output]");
89  if (!target) {
90    return;
91  }
92
93  target.textContent = "Checking API health...";
94  try {
95    const response = await fetch(`${apiBase}/health`, {
96      method: "GET",
97      headers: { "Accept": "application/json" }
98    });
99    const payload = await response.json();
100    target.textContent = response.ok && payload.ok
101      ? "API is reachable."
102      : `API returned HTTP ${response.status}.`;
103  } catch (error) {
104    target.textContent = `Network error while checking the API. Please try again. (${error.message})`;
105  }
106}
107
108function getOrCreateDeviceFingerprint(label = "web-portal") {
109  const existing = window.localStorage.getItem(deviceFingerprintKey);
110  if (existing) {
111    return existing;
112  }
113  const randomPart = window.crypto && window.crypto.randomUUID
114    ? window.crypto.randomUUID()
115    : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
116  const value = `web:${String(label || "web-portal").trim()}:${randomPart}`;
117  window.localStorage.setItem(deviceFingerprintKey, value);
118  return value;
119}
120
121function getAccountToken() {
122  return window.localStorage.getItem(accountTokenKey) || "";
123}
124
125function getAccountExpiry() {
126  return window.localStorage.getItem(accountExpiresKey) || "";
127}
128
129function setAccountSession(token, expiresAt) {
130  window.localStorage.setItem(accountTokenKey, token);
131  window.localStorage.setItem(accountExpiresKey, expiresAt || "");
132}
133
134function clearAccountSession() {
135  window.localStorage.removeItem(accountTokenKey);
136  window.localStorage.removeItem(accountExpiresKey);
137}
138
139function isAccountSessionValid() {
140  const token = getAccountToken();
141  if (!token) return false;
142  const expires = getAccountExpiry();
143  if (expires) {
144    const ts = Date.parse(expires);
145    if (Number.isFinite(ts) && ts <= Date.now()) {
146      clearAccountSession();
147      return false;
148    }
149  }
150  return true;
151}
152
153function getSessionToken() {
154  return window.localStorage.getItem(sessionTokenKey) || "";
155}
156
157function setSession(token, expiresAt) {
158  window.localStorage.setItem(sessionTokenKey, token);
159  window.localStorage.setItem(sessionExpiresKey, expiresAt || "");
160}
161
162function clearSession() {
163  window.localStorage.removeItem(sessionTokenKey);
164  window.localStorage.removeItem(sessionExpiresKey);
165  // Also drop the readable-CSRF hint so a signed-out user is not shown as
166  // signed-in by the cross-subdomain fallback in getCsrfToken().
167  try { window.localStorage.removeItem("gex_csrf_hint"); } catch (_) { /* ignore */ }
168}
169
170// ---------------------------------------------------------------------------
171// B3: New cookie-based auth client (credentials:"include" + CSRF double-submit)
172// ---------------------------------------------------------------------------
173
174/**
175 * Read the gex_csrf cookie value.
176 * The auth backend sets this cookie as non-HttpOnly so JavaScript can read it.
177 * Its presence means an active cookie session exists (gex_session is HttpOnly).
178 */
179function getCsrfToken() {
180  const entry = document.cookie
181    .split(";")
182    .map((c) => c.trim())
183    .find((c) => c.startsWith("gex_csrf="));
184  if (entry) return entry.slice("gex_csrf=".length);
185  // Cross-subdomain fallback: when auth lives on api.gex-levels.com the
186  // gex_csrf cookie is host-only THERE and document.cookie here cannot read it,
187  // so a freshly logged-in user looked logged-out and bounced back to the
188  // sign-in form (2026-07-31 outage). After login we fetch the token from
189  // GET /auth/csrf (returns it in the BODY) and cache it here. The cookies
190  // themselves still ride along on every fetch — this hint only restores the
191  // "am I signed in" signal; the server keeps enforcing the real session.
192  try { return window.localStorage.getItem("gex_csrf_hint") || ""; } catch (_) { return ""; }
193}
194
195/**
196 * Reads ONLY the real, browser-held gex_csrf cookie — no localStorage fallback.
197 * Use this wherever a WRONG "you are signed in" answer traps the user (e.g. the
198 * /login redirect guard). getCsrfToken() is deliberately optimistic and will
199 * report a session that no longer exists; this one never does.
200 */
201function getCsrfCookieOnly() {
202  const entry = document.cookie
203    .split(";")
204    .map((c) => c.trim())
205    .find((c) => c.startsWith("gex_csrf="));
206  return entry ? entry.slice("gex_csrf=".length) : "";
207}
208
209/** Cache (or clear) the readable-CSRF hint after login/logout. */
210function setCsrfHint(token) {
211  try {
212    if (token) window.localStorage.setItem("gex_csrf_hint", token);
213    else window.localStorage.removeItem("gex_csrf_hint");
214  } catch (_) { /* storage unavailable — cookie path may still work */ }
215}
216
217function consumeUrlToken() {
218  const params = new URLSearchParams(window.location.search);
219  const token = params.get("token") || "";
220  if (token && window.history?.replaceState) {
221    params.delete("token");
222    const cleanQuery = params.toString();
223    const cleanUrl = `${window.location.pathname}
223${cleanQuery ? `?${cleanQuery}` : ""}${window.location.hash || ""}`;
224    window.history.replaceState(null, document.title, cleanUrl);
225  }
226  return token;
227}
228
229/** Returns true when the user has an active new-system cookie session. */
230function hasAuthSession() {
231  if (isAccountSessionValid()) return true;
232  return getCsrfToken() !== "";
233}
234
235/**
236 * Fetch wrapper for the new auth API.
237 * Always uses credentials:"include" so session cookies are sent automatically.
238 * Mutating requests (POST, DELETE) include X-CSRF-Token when the token is available.
239 */
240async function authApiJson(path, options = {}) {
241  const method = String(options.method || "GET").toUpperCase();
242  const headers = { "Accept": "application/json", ...(options.headers || {}) };
243  if (options.body) headers["Content-Type"] = "application/json";
244  const accountToken = isAccountSessionValid() ? getAccountToken() : "";
245  if (accountToken) headers["Authorization"] = `Bearer ${accountToken}`;
246  if (method !== "GET") {
247    // Always attach the double-submit CSRF token on mutations — even when a
248    // Bearer is present (the browser sends the gex_session cookie alongside it
249    // and the hardened Worker requires CSRF when that cookie is present).
250    // AUTHORITATIVE SOURCE: GET /auth/csrf returns the token in the BODY, because
251    // the gex_csrf cookie is host-only on api.gex-levels.com and is NOT readable
252    // via document.cookie on gex-levels.com — the browser still sends it, so the
253    // body token matches it. Fall back to a readable cookie only if the endpoint
254    // is unreachable. Pre-login (no session) /auth/csrf returns 401 → no header →
255    // login/signup proceed (those routes are CSRF-exempt server-side).
256    let token = "";
257    try {
258      const seedHeaders = { "Accept": "application/json" };
259      if (accountToken) seedHeaders["Authorization"] = `Bearer ${accountToken}`;
260      const seed = await fetch(`${apiBase}/auth/csrf`, { method: "GET", credentials: "include", headers: seedHeaders });
261      if (seed.ok) token = (await seed.json().catch(() => ({}))).csrf_token || "";
262    } catch { token = ""; }
263    if (!token) token = getCsrfToken() || "";
264    if (token) headers["X-CSRF-Token"] = token;
265  }
266  let response;
267  try {
268    response = await fetch(`${apiBase}${path}`, {
269      ...options,
270      method,
271      credentials: "include",
272      headers
273    });
274  } catch (networkError) {
275    // fetch() rejects with a TypeError only for genuine network failures, DNS
276    // problems, a blocked CORS preflight, or a CSP connect-src violation — never
277    // for an HTTP error status. Surface an honest, non-sensitive message instead
278    // of the raw "Failed to fetch".
279    const err = new Error(
280      "Cannot reach the sign-in service. Check your connection and try again — " +
281      "if it persists, the service may be temporarily unavailable."
282    );
283    err.code = "network_unreachable";
284    throw err;
285  }
286  let payload = {};
287  try { payload = await response.json(); } catch { payload = {}; }
288  if (!response.ok) {
289    if (response.status === 401) clearAccountSession();
290    // 404/405/501 on an auth route means the endpoint itself is unavailable
291    // (not deployed / wrong path) — distinct from a credential or CSRF
292    // rejection. The Worker returns {error:"not_found"} for missing routes, so
293    // treat that (and an empty body) as a service problem, not the user's input.
294    const endpointMissing =
295      (response.status === 404 || response.status === 405 || response.status === 501) &&
296      (!payload.error || payload.error === "not_found" || payload.error === "endpoint_not_found" || payload.error === "route_not_found");
297    if (endpointMissing) {
298      const err = new Error("Sign-in service is temporarily unavailable. Please try again later.");
299      err.code = "endpoint_unavailable";
300      throw err;
301    }
302    const err = new Error(friendlyApiError(payload.error || payload.message || `HTTP ${response.status}`));
303    err.code = payload.error || payload.message || `http_${response.status}`;
304    err.status = response.status;
305    throw err;
306  }
307  return payload;
308}
309
310/**
311 * Translate new-auth API error codes to user-facing messages.
312 * Falls back to friendlyApiError() for legacy codes.
313 */
314function friendlyAuthError(code) {
315  const messages = {
316    invalid_credentials: "Invalid email or password. Please try again.",
317    authentication_failed: "Invalid email or password. Please try again.",
318    account_not_found: "Invalid email or password. Please try again.",
319    email_not_verified: "Please verify your email before logging in. Check your inbox or resend below.",
320    account_suspended: "Account suspended. Contact [email protected].",
321    email_already_exists: "An account with this email already exists. Try logging in.",
322    password_too_short: "Password must be at least 12 characters.",
323    password_too_weak: "Password must be at least 12 characters.",
324    passwords_do_not_match: "Passwords do not match.",
325    invalid_token: "Link expired or already used. Please request a new one.",
326    invalid_or_expired_token: "Link expired or already used. Please request a new one.",
327    token_expired: "Link has expired. Please request a new one.",
328    rate_limited: "Too many attempts. Please wait a few minutes and try again.",
329    temporarily_unavailable: "Too many sign-in attempts, or the service is briefly busy. For your security, please wait a few minutes and try again.",
330    internal_error: "Account services are temporarily unavailable. Please try again later.",
331    origin_not_allowed: "Request blocked: origin not allowed.",
332    csrf_missing: "Session expired. Please refresh the page and try again.",
333    csrf_cookie_missing: "Session expired. Please refresh the page and try again.",
334    csrf_header_missing: "Session expired. Please refresh the page and try again.",
335    csrf_mismatch: "Session expired. Please refresh the page and try again.",
336    csrf_invalid: "Session expired. Please refresh the page and try again.",
337    missing_session: "Not signed in. Please log in.",
338    session_expired: "Session expired. Please log in again.",
339    session_not_found: "Session not found.",
340    service_unavailable: "Account services are temporarily unavailable. Please try again later.",
341    email_already_verified: "Email is already verified. You can log in.",
342    email_invalid: "Enter a valid email address.",
343    email_required: "Email is required.",
344  };
345  const key = String(code || "").trim();
346  return messages[key] || friendlyApiError(key);
347}
348
349// ---------------------------------------------------------------------------
350// B10: Entitlement helpers
351// ---------------------------------------------------------------------------
352
353/** Find a specific entitlement from the array returned by /auth/me or /account/entitlements */
354function findEntitlement(entitlements, productId) {
355  return (Array.isArray(entitlements) ? entitlements : []).find((e) => e.productId === productId) || null;
356}
357
358/** Returns true if the entitlement is currently active and not expired */
359function isEntitlementActive(entitlement) {
360  if (!entitlement) return false;
361  if (entitlement.accessStatus !== "active") return false;
362  if (entitlement.expiresAt && new Date(entitlement.expiresAt) < new Date()) return false;
363  return true;
364}
365
366function portalOutput(message) {
367  setText("[data-portal-output]", message);
368}
369
370function requestAccessOutput(message) {
371  setText("[data-request-access-output]", message);
372}
373
374// Marketing CTAs link to /request-access?product=<value> to carry the visitor's
375// intent (e.g. indicator_yearly, education_library). Preselect the matching
376// dropdown option so the form lands on the right product instead of "Select a
377// product". Only an exact, existing option value is honoured (no injection of
378// arbitrary text), and the NDA-row state is refreshed to match.
379function preselectRequestedProduct(form) {
380  if (!form) return;
381  const select = form.querySelector("select[name='product_requested']");
382  if (!select) return;
383  let requested = "";
384  try {
385    requested = new URLSearchParams(window.location.search).get("product") || "";
386  } catch {
387    return;
388  }
389  if (!requested) return;
390  const match = Array.from(select.options).some((opt) => opt.value === requested);
391  if (match) {
392    select.value = requested;
393  }
394}
395
396function updateRequestAccessNdaRequirement() {
397  const form = document.querySelector("[data-request-access-form]");
398  if (!form) {
399    return;
400  }
401  const product = form.querySelector("select[name='product_requested']");
402  const ndaRow = form.querySelector("[data-nda-row]");
403  const nda = form.querySelector("input[name='nda_acknowledged']");
404  const requiresNda = product?.value === "education_library";
405  if (nda) {
406    nda.required = requiresNda;
407    nda.checked = requiresNda ? nda.checked : false;
408  }
409  if (ndaRow) {
410    ndaRow.style.display = "grid";
411    const labelText = ndaRow.querySelector("span");
412    if (labelText) {
413      labelText.textContent = requiresNda
414        ? "Required for Education Library access."
415        : "Not required for Indicator or extension-related requests.";
416    }
417  }
418}
419
420function requestAccessStatusMessage(result) {
421  const delivery = String(result?.email_delivery_status || "").toLowerCase();
422  if (delivery === "sent") {
423    return "Request received and emailed to support. We will review it manually.";
424  }
425  if (delivery === "pending_configuration") {
426    return "Request received and stored. Email delivery is not configured yet, so support review remains manual.";
427  }
428  if (delivery === "failed") {
429    return "Request received and stored, but email delivery failed. Support review remains manual.";
430  }
431  return "Request received. Support review remains manual.";
432}
433
434function planLabel(planId) {
435  switch (String(planId || "").trim()) {
436    case "education_library":
437      return "Education Library";
438    case "indicator_monthly":
439      return "Indicator Monthly";
440    case "indicator_yearly":
441      return "Indicator Yearly";
442    case "indicator_quarterly":
443      return "Indicator Quarterly (90 days)";
444    case "extension":
445      return "Chrome Extension Private Beta";
446    case "basic":
447      return "Basic (legacy)";
448    case "pro":
449      return "Pro (legacy)";
450    case "research":
451      return "Research (legacy)";
452    default:
453      return String(planId || "-");
454  }
455}
456
457function authFormOutput(message) {
458  const target = document.querySelector("[data-auth-output]");
459  if (target) {
460    target.removeAttribute("hidden");
461    target.textContent = message;
462  }
463}
464
465function authHeaders() {
466  const token = getSessionToken();
467  return token ? { "Authorization": `Bearer ${token}` } : {};
468}
469
470async function apiJson(path, options = {}) {
471  let response;
472  try {
473    response = await fetch(`${apiBase}${path}`, {
474      ...options,
475      headers: {
476        "Accept": "application/json",
477        ...(options.body ? { "Content-Type": "application/json" } : {}),
478        ...(options.auth ? authHeaders() : {}),
479        ...(options.headers || {})
480      }
481    });
482  } catch (error) {
483    console.warn("API network error", { path, message: error?.message || "network_error" });
484    throw new Error(friendlyApiError("service_unavailable"));
485  }
486  let payload = {};
487  try {
488    payload = await response.json();
489  } catch {
490    payload = {};
491  }
492  if (!response.ok) {
493    throw new Error(friendlyApiError(payload.error || payload.message || `HTTP ${response.status}`));
494  }
495  return payload;
496}
497
498function friendlyApiError(error) {
499  const code = String(error || "").trim();
500  const messages = {
501    invalid_license: "Invalid license key. Please check your key and try again.",
502    license_expired: "This license has expired. Contact support to renew access.",
503    license_revoked: "This license has been revoked. Contact support if you think this is a mistake.",
504    license_inactive: "This license is not active. Contact support for help.",
505    device_limit_exceeded: "Device limit reached. Contact support to reset your devices.",
506    missing_session: "Your session is missing. Please create a new session.",
507    invalid_session: "Your session is invalid. Please create a new session.",
508    session_expired: "Your session expired. Please create a new session.",
509    session_revoked: "Your session was revoked. Please login again.",
510    rate_limited: "Too many attempts. Please wait a minute and try again.",
511    temporarily_unavailable: "Too many sign-in attempts, or the service is briefly busy. For your security, please wait a few minutes and try again.",
512    bad_request: "Check required fields and acknowledgements, then try again.",
513    service_unavailable: "Account services are temporarily unavailable. Please try again later.",
514    request_access_storage_unavailable: "Request storage is not configured yet. Contact support manually.",
515    paypal_sandbox_not_configured: "PayPal Sandbox checkout is not configured yet.",
516    turnstile_required: "Please complete the human-verification challenge, then submit again.",
517    turnstile_invalid: "Human verification failed. Please retry the challenge and submit again.",
518    turnstile_hostname_invalid: "Human verification could not be validated for this site. Please reload and try again.",
519    turnstile_action_invalid: "Human verification could not be validated. Please reload and try again.",
520    turnstile_unavailable: "Verification is temporarily unavailable. Please try again in a moment.",
521    turnstile_not_configured: "Verification is temporarily unavailable. Please try again later or contact support.",
522    indicator_entitlement_required: "An active Indicator subscription is required to view protected market levels.",
523    server_error: "Server error. Please try again or contact support."
524  };
525  return messages[code] || code || "Unexpected error. Please try again.";
526}
527
528// ---------------------------------------------------------------------------
529// Cloudflare Turnstile — human verification on the flows the Worker enforces
530// server-side (signup, password-reset request, request-access). Pages without a
531// [data-turnstile] container (login, email-verify, password-reset confirm) are
532// unaffected. The authoritative site key + enabled flag come from /public/config
533// at runtime, so the Worker's turnstile_enabled kill-switch and the real
534// (domain-locked) production site key are honored on the production origin.
535// TURNSTILE_FALLBACK_SITE_KEY is Cloudflare's public "always passes" TEST key —
536// not a secret and not the production key — used only when /public/config is
537// unreachable (local/preview origins the API CORS allowlist excludes) so the
538// widget can be exercised off-production. The Turnstile SECRET key never leaves
539// the Worker, and the production site key is only ever served by /public/config.
540// ---------------------------------------------------------------------------
541const TURNSTILE_FALLBACK_SITE_KEY = "1x00000000000000000000AA";
542let __turnstileConfigPromise = null;
543const __turnstileWidgets = new Map(); // container element -> { id, token }
544
545function loadPublicConfig() {
546  if (!__turnstileConfigPromise) {
547    // The API CORS allowlist only grants the production site origin, so only
548    // fetch from there; other origins (local/preview) use the fallback without
549    // triggering a noisy cross-origin console error.
550    if (typeof window !== "undefined" && window.location && window.location.origin !== siteUrl) {
551      __turnstileConfigPromise = Promise.resolve({ turnstileEnabled: true, turnstileSiteKey: TURNSTILE_FALLBACK_SITE_KEY, fetched: false });
552      return __turnstileConfigPromise;
553    }
554    __turnstileConfigPromise = fetch(`${apiBase}/public/config`, { headers: { Accept: "application/json" } })
555      .then((r) => r.json())
556      .then((d) => {
557        const c = d && d.config ? d.config : {};
558        return {
559          turnstileEnabled: Boolean(c.turnstile_enabled),
560          turnstileSiteKey: String(c.turnstile_site_key || "").trim() || TURNSTILE_FALLBACK_SITE_KEY,
561          fetched: true,
562        };
563      })
564      // Config unreachable (cross-origin on non-production hosts). The Worker is
565      // fail-closed on these flows, so default to showing the widget.
566      .catch(() => ({ turnstileEnabled: true, turnstileSiteKey: TURNSTILE_FALLBACK_SITE_KEY, fetched: false }));
567  }
568  return __turnstileConfigPromise;
569}
570
571function turnstileStatusEl(container) {
572  return container.parentElement
573    ? container.parentElement.querySelector("[data-turnstile-status]")
574    : null;
575}
576
577function setTurnstileStatus(container, message) {
578  const el = turnstileStatusEl(container);
579  if (!el) return;
580  if (message) { el.textContent = message; el.removeAttribute("hidden"); }
581  else { el.textContent = ""; el.setAttribute("hidden", ""); }
582}
583
584async function initTurnstile() {
585  const containers = Array.from(document.querySelectorAll("[data-turnstile]"));
586  if (!containers.length) return;
587
588  const cfg = await loadPublicConfig();
589  const siteKey = cfg.turnstileSiteKey || TURNSTILE_FALLBACK_SITE_KEY;
590  const active = Boolean(cfg.turnstileEnabled && siteKey);
591  if (!active) {
592    // Kill-switch off (or no site key): remove the gate entirely so forms submit.
593    containers.forEach((c) => { c.dataset.turnstileOff = "1"; setTurnstileStatus(c, ""); c.hidden = true; });
594    return;
595  }
596
597  containers.forEach((c) => setTurnstileStatus(c, "Loading human-verification…"));
598
599  // The api.js script loads async; wait for window.turnstile before rendering.
600  const deadline = Date.now() + 12000;
601  while (!(window.turnstile && window.turnstile.render) && Date.now() < deadline) {
602    await new Promise((res) => setTimeout(res, 150));
603  }
604  if (!(window.turnstile && window.turnstile.render)) {
605    containers.forEach((c) => setTurnstileStatus(c, "Could not load verification. Check your connection and reload."));
606    return;
607  }
608
609  containers.forEach((container) => {
610    if (__turnstileWidgets.has(container)) return;
611    const action = container.getAttribute("data-action") || "";
612    const entry = { id: null, token: "" };
613    entry.id = window.turnstile.render(container, {
614      sitekey: siteKey,
615      action,
616      theme: "auto",
617      callback: (token) => { entry.token = token; setTurnstileStatus(container, ""); },
618      "error-callback": () => { entry.token = ""; setTurnstileStatus(container, "Verification error. Please retry the challenge."); },
619      "expired-callback": () => { entry.token = ""; if (window.turnstile && entry.id !== null) window.turnstile.reset(entry.id); },
620      "timeout-callback": () => { entry.token = ""; if (window.turnstile && entry.id !== null) window.turnstile.reset(entry.id); }
621    });
622    __turnstileWidgets.set(container, entry);
623  });
624}
625
626// Returns: null = this form does not use Turnstile (or it is disabled) → no gate.
627//          false = Turnstile is active here but not solved → caller must abort.
628//          string = a fresh single-use token to attach to the request body.
629async function ensureTurnstileToken(form, say) {
630  const container = form.querySelector("[data-turnstile]");
631  if (!container || container.dataset.turnstileOff === "1") return null;
632  const cfg = await loadPublicConfig();
633  if (!cfg.turnstileEnabled) return null;
634  const entry = __turnstileWidgets.get(container);
635  const token = entry && entry.token ? entry.token : "";
636  if (!token) {
637    if (typeof say === "function") say("Please complete the human-verification challenge, then submit again.");
638    setTurnstileStatus(container, "Complete the verification above to continue.");
639    return false;
640  }
641  return token;
642}
643
644function resetTurnstile(form) {
645  const container = form.querySelector("[data-turnstile]");
646  if (!container) return;
647  const entry = __turnstileWidgets.get(container);
648  if (entry && window.turnstile && entry.id !== null) {
649    window.turnstile.reset(entry.id);
650    entry.token = "";
651  }
652}
653
654async function createPortalSession(event) {
655  event.preventDefault();
656  const form = event.currentTarget;
657  const data = new FormData(form);
658  const email = String(data.get("email") || "").trim().toLowerCase();
659  const licenseKey = String(data.get("license_key") || "").trim();
660  const deviceLabel = String(data.get("device_label") || "web-portal").trim();
661  if (!email) {
662    portalOutput("Enter your account email first.");
663    return;
664  }
665  if (!licenseKey) {
666    portalOutput("Enter a license key first.");
667    return;
668  }
669  portalOutput("Creating secure browser session...");
670  try {
671    const payload = await apiJson("/session/create", {
672      method: "POST",
673      body: JSON.stringify({
674        email,
675        license_key: licenseKey,
676        device_fingerprint: getOrCreateDeviceFingerprint(deviceLabel),
677        client_type: "web",
678        client_version: "portal-manual-beta-v1"
679      })
680    });
681    setSession(payload.session_token, payload.expires_at);
682    form.reset();
683    portalOutput(`Session created. Plan: ${payload.plan}. Expires: ${payload.expires_at}.`);
684    await loadAccountDashboard();
685    await loadLatestLevels();
686  } catch (error) {
687    portalOutput(error.message);
688  }
689}
690
691function renderAccount(payload) {
692  setText("[data-account-active]", payload.active ? "active" : "inactive");
693  setText("[data-account-email]", payload.account?.email || "-");
694  setText("[data-account-plan]", `${payload.plan?.name || payload.plan?.id || "-"} (${payload.plan?.id || "-"})`);
695  setText("[data-account-expires]", payload.license?.expires_at || "-");
696  setText("[data-account-license]", payload.license?.masked_key || payload.license?.key_prefix || "-");
697  setMaskedKeyCopy(payload.license?.masked_key || payload.license?.key_prefix || "-");
698  setText("[data-account-license-status]", payload.license?.status || "-");
699  setText("[data-account-billing]", payload.billing?.status || "-");
700  setText("[data-account-devices]", payload.devices ? `${payload.devices.active_count ?? "-"} / ${payload.devices.device_limit ?? "-"}` : "-");
701  setText("[data-account-session]", `${payload.session?.kind || "-"} / expires ${payload.session?.expires_at || "-"}`);
702}
703
704async function loadAccountStatus() {
705  if (hasAuthSession()) {
706    // New cookie-session path.
707    try {
708      const meRes = await authApiJson("/account/me");
709      renderAccount(meRes);
710      portalOutput("Account status loaded.");
711    } catch (err) {
712      portalOutput(err.message);
713    }
714  } else if (getSessionToken()) {
715    // Legacy Bearer path.
716    try {
717      const payload = await apiJson("/account/me", { method: "GET", auth: true });
718      renderAccount(payload);
719      portalOutput("Account status loaded.");
720    } catch (error) {
721      if (error.message.includes("session")) clearSession();
722      portalOutput(error.message);
723    }
724  } else {
725    portalOutput("Login first, then load your account.");
726  }
727}
728
729function renderTable(targetSelector, rows, columns, emptyText) {
730  const target = document.querySelector(targetSelector);
731  if (!target) return;
732  if (!rows || rows.length === 0) {
733    target.innerHTML = `<p class="muted">${escapeHtml(emptyText || "No records yet.")}</p>`;
734    return;
735  }
736  target.innerHTML = `
737    <table>
738      <thead><tr>${columns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join("")}</tr></thead>
739      <tbody>
740        ${rows.map((row) => `
741          <tr>
742            ${columns.map((column) => `<td>${escapeHtml(column.value(row))}</td>`).join("")}
743          </tr>
744        `).join("")}
745      </tbody>
746    </table>
747  `;
748}
749
750// B8: Render basic account info from the new auth /auth/me response.
751function renderAuthAccount(meRes) {
752  const user = meRes.user || {};
753  const session = meRes.session || {};
754  setText("[data-account-email]", user.email || "-");
755  setText("[data-account-active]", user.status || "active");
756  setText("[data-account-plan]", "-");
757  setText("[data-account-expires]", session.expiresAt || "-");
758  setText("[data-account-license]", "-");
759  setMaskedKeyCopy("-");
760  setText("[data-account-license-status]", "-");
761  setText("[data-account-billing]", "Payments and renewal details are managed in Whop.");
762  setText("[data-account-devices]", "-");
763  setText("[data-account-session]", `cookie session / expires ${session.expiresAt || "-"}`);
764}
765
766async function loadAccountDashboard() {
767  const hasCookie = hasAuthSession();
768  const hasBearer = !!getSessionToken();
769
770  if (!hasCookie && !hasBearer) {
771    portalOutput("Login first, then load your dashboard.");
772    return;
773  }
774
775  if (hasCookie) {
776    // B8: New-auth session path. /auth/me authenticates via the Bearer session
777    // token — the cross-subdomain gex_session cookie (SameSite=Lax, host-only) is
778    // NOT attached to fetch() to api.gex-levels.com. The legacy /account/me
779    // validates the Bearer against the license `sessions` store, so a new-auth
780    // user_sessions token 401s there; that 401 cleared the session and bounced the
781    // user back to /login (the redirect-loop bug). /auth/me returns {user,
782    // entitlements, session:{id,expiresAt}} and is mapped to the account view here.
783    try {
784      portalOutput("Loading account dashboard…");
785      const meRes = await authApiJson("/auth/me");
786      renderAccount({
787        active: true,
788        account: { email: meRes.user?.email },
789        session: { kind: "account", expires_at: meRes.session?.expiresAt },
790      });
791      const sesTarget = document.querySelector("[data-account-sessions]");
792      if (sesTarget) {
793        try {
794          // Real device list with per-session revoke (GET /account/sessions).
795          const ses = await authApiJson("/account/sessions");
796          renderAuthSessions(ses, "[data-account-sessions]");
797        } catch (_) {
798          const expires = meRes.session?.expiresAt || "unknown";
799          sesTarget.innerHTML = `<p class="muted">Current account session · expires ${escapeHtml(expires)}.</p>`;
800        }
801      }
802      // Surface the account's REAL entitlements (Indicator / Library). The
803      // new-auth path used to show only a placeholder, so a user WITH active
804      // products saw "no key". /auth/me returns entitlements — render them.
805      const ents = Array.isArray(meRes.entitlements) ? meRes.entitlements : [];
806      // /auth/me (getUserEntitlements) returns camelCase {productId, accessStatus,
807      // expiresAt}. Read BOTH shapes defensively and honour expiry — reading the
808      // wrong case made every owned product show as "Not active" / "No active product".
809      const entPid = (e) => e.productId || e.product_id || "";
810      const entStatus = (e) => String(e.accessStatus || e.access_status || "").toLowerCase();
811      const entExpiry = (e) => e.expiresAt || e.expires_at || null;
812      const entIsLive = (e) => {
813        if (e.is_live === true) return true;
814        if (!["active", "trial", "trialing"].includes(entStatus(e))) return false;
815        const x = entExpiry(e);
816        if (x) { const t = Date.parse(x); if (Number.isFinite(t) && t <= Date.now()) return false; }
817        return true;
818      };
819      const productLabel = (pid) => ({
820        indicator_monthly: "GEX Levels Indicator (monthly)",
821        indicator_yearly: "GEX Levels Indicator (yearly)",
822        indicator_quarterly: "GEX Levels Indicator (every 90 days)",
823        terminal_monthly: "GEX Levels Terminal (monthly)",
824        terminal_quarterly: "GEX Levels Terminal (every 3 months)",
825        terminal_yearly: "GEX Levels Terminal (yearly)",
826        education_library: "Education Library",
827      }[pid] || pid || "-");
828      const liveEnts = ents.filter(entIsLive);
829      // Dynamic access strip + Account "Access" row + welcome lede — reflect the
830      // REAL Library entitlement instead of a hardcoded "Active / lifetime". A
831      // non-buyer sees an honest "Not active" state with the next action.
832      const libEnt = ents.find((e) => entPid(e) === "education_library");
833      const libActive = libEnt ? entIsLive(libEnt) : false;
834      const indEnt = ents.find((e) => ["indicator_monthly", "indicator_yearly", "indicator_quarterly"].includes(entPid(e)));
835      const indActive = indEnt ? entIsLive(indEnt) : false;
836      const terminalEnt = ents.find((e) => ["terminal_monthly", "terminal_quarterly", "terminal_yearly"].includes(entPid(e)));
837      const terminalActive = terminalEnt ? entIsLive(terminalEnt) : false;
838      // Post-purchase pending window (2026-08-29): right after checkout the
839      // entitlement arrives via webhook with a short delay. During that window
840      // this dashboard used to say "No active subscription — start your free
841      // trial", which sent fresh buyers straight back to checkout for a second,
842      // immediately-charged membership (double-subscription refunds). The flag
843      // (set by gexCheckoutComplete; ?purchase= only seeds it once) swaps the
844      // CTA for an "activating…" note and refreshes until the webhook lands.
845      let purchasePending = null;
846      // Expired pending window (2026-09-01, Journeii case): the payment landed on
847      // Whop under a DIFFERENT email than this account (Whop refuses some alias
848      // addresses at its own checkout), so the webhook can never match and the
849      // old code silently fell back to the buy CTA — inviting a second charge.
850      // Keep the expired flag around and show a "don't buy again, contact
851      // support with the email used at payment" note instead.
852      let purchaseExpired = null;
853      try {
854        let pFlag = null;
855        try { pFlag = JSON.parse(sessionStorage.getItem("gex_purchase_pending") || "null"); } catch (_) { pFlag = null; }
856        if (!pFlag) {
857          const qp = new URLSearchParams(window.location.search).get("purchase");
858          if (qp) { pFlag = { product: qp, at: Date.now(), n: 0 }; sessionStorage.setItem("gex_purchase_pending", JSON.stringify(pFlag)); }
859        }
860        if (pFlag && Date.now() - pFlag.at < 10 * 60 * 1000 && (pFlag.n || 0) < 20) purchasePending = pFlag;
861        else if (pFlag) purchaseExpired = pFlag;
862      } catch (_) { purchasePending = null; }
863      const purchasedProductActive = flag => !!flag && liveEnts.some(e => entPid(e) === flag.product);
864      const pendingLib = purchasePending && purchasePending.product === "education_library" && !purchasedProductActive(purchasePending);
865      const pendingInd = purchasePending && String(purchasePending.product || "").startsWith("indicator_") && !purchasedProductActive(purchasePending);
866      const pendingTerminal = purchasePending && String(purchasePending.product || "").startsWith("terminal_") && !purchasedProductActive(purchasePending);
867      const expiredLib = purchaseExpired && purchaseExpired.product === "education_library" && !libActive;
868      const expiredInd = purchaseExpired && String(purchaseExpired.product || "").startsWith("indicator_") && !indActive;
869      const expiredTerminal = purchaseExpired && String(purchaseExpired.product || "").startsWith("terminal_") && !terminalActive;
870      const MISMATCH_NOTE = "Paid but nothing activated? If you completed payment with a DIFFERENT email on the payment page, do NOT purchase again. Take the licence key from the email you received and link it to this account at api.gex-levels.com/account/claim (one paste, no charge). Or email [email protected] from the address used at payment and we will link it.";
871      const clearPending = () => { try { sessionStorage.removeItem("gex_purchase_pending"); } catch (_) {} };
872      if (purchasedProductActive(purchasePending)) clearPending();
873      if (pendingLib || pendingInd || pendingTerminal) {
874        // Active reconciliation first: ask the Worker to pull this account's
875        // membership straight from the Whop API (covers a late or lost
876        // webhook), then refresh. Best-effort — on any failure the bounded
877        // plain refresh below keeps polling until the webhook lands.
878        try {
879          authApiJson("/account/sync-purchase", { method: "POST", body: JSON.stringify({ product_id: purchasePending.product }) })
880            .then((r) => { if (r && r.granted && r.product_id === purchasePending.product) { clearPending(); window.location.reload(); } })
881            .catch(() => {});
882        } catch (_) {}
883        // schedule one bounded refresh so the page picks the entitlement up
884        try {
885          purchasePending.n = (purchasePending.n || 0) + 1;
886          sessionStorage.setItem("gex_purchase_pending", JSON.stringify(purchasePending));
887          setTimeout(() => { try { window.location.reload(); } catch (_) {} }, 8000);
888        } catch (_) {}
889      }
890      const accPill = document.querySelector('[data-access-item="education_library"] [data-access-pill]');
891      const accNote = document.querySelector('[data-access-item="education_library"] [data-access-note]');
892      if (accPill) {
893        accPill.textContent = libActive ? "Active" : (pendingLib ? "Activating…" : "Not active");
894        accPill.className = "rm-pill " + (libActive || pendingLib ? "is-on" : "is-off");
895      }
896      if (accNote) accNote.textContent = libActive
897        ? "Full lifetime access — all 101 modules open in the in-site reader."
898        : (pendingLib
899          ? "Payment received — your access is activating (usually under a minute). This page refreshes itself; there is nothing to re-purchase."
900          : (expiredLib ? MISMATCH_NOTE
901            : "No active Library access yet — get the Library to unlock the in-site reader."));
902      const indPill = document.querySelector('[data-access-item="indicator"] [data-access-pill]');
903      const indNote = document.querySelector('[data-access-item="indicator"] [data-access-note]');
904      if (indPill) {
905        indPill.textContent = indActive ? "Active" : (pendingInd ? "Activating…" : "Not active");
906        indPill.className = "rm-pill " + (indActive || pendingInd ? "is-on" : "
906is-off");
907      }
908      if (indNote) indNote.textContent = indActive
909        ? "Active subscription — your licence key is in the Licence panel (left menu): click “Reveal key”, then Copy."
910        : (pendingInd
911          ? "Payment received — your subscription is activating (usually under a minute). This page refreshes itself; do NOT start another checkout."
912          : (expiredInd ? MISMATCH_NOTE
913            : "No active subscription — start your free trial to get a licence key."));
914      const terminalPill = document.querySelector('[data-access-item="terminal"] [data-access-pill]');
915      const terminalNote = document.querySelector('[data-access-item="terminal"] [data-access-note]');
916      if (terminalPill) {
917        terminalPill.textContent = terminalActive ? "Active" : (pendingTerminal ? "Activating…" : "Not active");
918        terminalPill.className = "rm-pill " + (terminalActive || pendingTerminal ? "is-on" : "is-off");
919      }
920      if (terminalNote) terminalNote.textContent = terminalActive
921        ? "Active subscription — reveal its separate licence key below and paste it into the desktop app."
922        : (pendingTerminal
923          ? "Payment received — your Terminal key is activating. This page refreshes itself; do NOT start another checkout."
924          : (expiredTerminal ? MISMATCH_NOTE : "No active Terminal subscription."));
925      setText("[data-account-access]", liveEnts.length ? liveEnts.map((e) => productLabel(entPid(e))).join(" · ") : "No active product");
926      const accLede = document.querySelector("[data-account-lede]");
927      if (accLede) accLede.textContent = liveEnts.length
928        ? "Everything you have access to, in one place."
929        : "Welcome back — your account is ready.";
930      setText("[data-account-plan]", liveEnts.length ? liveEnts.map((e) => productLabel(entPid(e))).join(", ") : "No active product");
931      setText("[data-account-license-status]", liveEnts.length ? "active" : "-");
932      // The key IS revealable on demand (AES-GCM at rest since 2026-08-20) — the
933      // old "Entered in the extension · not stored here" dead-end here told users
934      // with a perfectly ACTIVE licence that no key existed. Three support cases
935      // in a week (Jerry 27/08, alozone 02/09, John 03/09) all had active keys in
936      // D1 and "could not find the key" on this page. Show the real masked key
937      // and leave Reveal/Copy to do their job; best-effort so a failed call never
938      // blanks the panel.
939      setText("[data-account-license]", "Loading…");
940      try {
941        const licRes = await authApiJson("/account/licenses");
942        renderTerminalLicense(licRes.licenses);
943        const licRow = pickPrimaryLicense(licRes.licenses);
944        if (licRow) {
945          const maskedLic = licRow.masked_key || licRow.key_prefix || "-";
946          setText("[data-account-license]", maskedLic);
947          setMaskedKeyCopy(maskedLic);
948          setText("[data-account-license-status]", licenseIsCurrent(licRow) ? "active" : (licRow.revoked_at ? "revoked" : "inactive / expired"));
949          const nKeys = activeLicenseCount(licRes.licenses);
950          if (nKeys > 1) {
951            setText("[data-account-license-status]", "active · " + nKeys + " active keys on this account — showing the one most recently used by your indicator");
952          }
953        } else {
954          setText("[data-account-license]", indActive
955            ? "Key not found — click “Get new key” below or contact support"
956            : "No Indicator licence on this account");
957          setMaskedKeyCopy("-");
958        }
959      } catch (_) {
960        renderTerminalLicense([], true);
961        setText("[data-account-license]", "Could not load — use “Reveal key” below");
962        setMaskedKeyCopy("-");
963      }
964      const licTarget = document.querySelector("[data-account-licenses]");
965      if (licTarget) {
966        licTarget.innerHTML = ents.length
967          ? `<table><thead><tr><th>Product</th><th>Status</th><th>Expires</th></tr></thead><tbody>${ents.map((e) => `<tr><td>${escapeHtml(productLabel(entPid(e)))}</td><td>${escapeHtml(entIsLive(e) ? "active" : (entStatus(e) || "-"))}</td><td>${escapeHtml(entExpiry(e) || "-")}</td></tr>`).join("")}</tbody></table>`
968          : "<p class=\"muted\">No products on this account yet. Get access at /request-access.</p>";
969      }
970      const bilTarget = document.querySelector("[data-account-billing-events]");
971      const billingAccess = liveEnts.filter((e) => ["indicator_monthly", "indicator_yearly", "indicator_quarterly", "terminal_monthly", "terminal_quarterly", "terminal_yearly"].includes(entPid(e)));
972      const billingEnt = billingAccess[0];
973      setText("[data-account-billing]", billingEnt ? "Your active product access" : "No active Indicator or Terminal access found on this account");
974      setText("[data-billing-current-plan]", billingEnt ? [...new Set(billingAccess.map(e => productLabel(entPid(e))))].join(" · ") : "Already paid? Contact support before purchasing again.");
975      const yearlyLink = document.querySelector("[data-yearly-switch]");
976      if (yearlyLink) {
977        const alreadyYearly = billingEnt && entPid(billingEnt) === "indicator_yearly";
978        yearlyLink.textContent = alreadyYearly ? "Manage your yearly plan on Whop" : "Review switch to yearly on Whop";
979        setText("[data-yearly-status]", alreadyYearly
980          ? "Your account already has yearly Indicator access. View its renewal date and payment details in Whop."
981          : "Open your active membership to review a change to yearly. Confirm the total due, effective date and next renewal in Whop before accepting.");
982      }
983      if (bilTarget) {
984        bilTarget.innerHTML = "<p class=\"muted\">Whop holds receipts for purchases made through Whop. Admin-granted access does not create a paid subscription. Check your Whop account for exact charges, renewal dates and payment history.</p>";
985      }
986      // Gift keys (extra Indicator keys the buyer bought for someone else) are
987      // standalone license_keys with NO product entitlement, so they never appear
988      // in `ents` above. Surface them explicitly: as rows in Licence & devices
989      // (with their own device usage) and as a line in Billing. Buy/reveal stays
990      // on the "Gift a key" card in the Overview (setupGuestKeys).
991      try {
992        const gk = await authApiJson("/account/guest-keys");
993        const gkeys = (gk && gk.ok && Array.isArray(gk.keys)) ? gk.keys : [];
994        const activeGk = gkeys.filter((k) => String(k.status || "").toLowerCase() === "active" && !k.revoked_at);
995        const gl = document.querySelector("[data-guest-licenses]");
996        if (gl) {
997          if (gkeys.length) {
998            gl.hidden = false;
999            gl.innerHTML = '<div class="rm-card__label">Gift keys you manage</div>'
1000              + '<p class="rm-muted rm-mt-sm">Extra Indicator keys you bought for someone else. Reveal one to copy the full key and hand it over — each is a separate subscription and never touches your own access.</p>'
1001              + '<div class="rm-guest__list">' + gkeys.map(guestKeyRowHtml).join("") + '</div>';
1002            wireGuestReveals(gl);
1003          } else { gl.hidden = true; gl.innerHTML = ""; }
1004        }
1005        const gb = document.querySelector("[data-guest-billing]");
1006        const gbRow = document.querySelector("[data-guest-billing-row]");
1007        if (gb) {
1008          gb.textContent = activeGk.length
1009            ? (activeGk.length + " gift key subscription" + (activeGk.length > 1 ? "s" : "") + " · $6.99/mo each · billed by Whop, separate from your own plan.")
1010            : "";
1011          if (gbRow) gbRow.hidden = !activeGk.length;
1012        }
1013      } catch (_) { /* gift keys are optional — never block the dashboard */ }
1014      portalOutput("Account dashboard loaded.");
1015    } catch (err) {
1016      portalOutput(err.message);
1017    }
1018
1019  } else {
1020    // Legacy Bearer path — /account/sessions is now auth-intercepted (returns 401 without cookie),
1021    // so we skip it here and only load the endpoints that still use Bearer auth.
1022    try {
1023      portalOutput("Loading account dashboard…");
1024      const [account, licenses, billing] = await Promise.all([
1025        apiJson("/account/me", { method: "GET", auth: true }),
1026        apiJson("/account/licenses", { method: "GET", auth: true }),
1027        apiJson("/account/billing", { method: "GET", auth: true }),
1028      ]);
1029      renderAccount(account);
1030      renderTable("[data-account-licenses]", licenses.licenses || [], [
1031        { label: "License", value: (row) => row.masked_key || row.key_prefix || "-" },
1032        { label: "Plan", value: (row) => planLabel(row.plan_id) },
1033        { label: "Status", value: (row) => row.status || "-" },
1034        { label: "Expires", value: (row) => row.expires_at || "-" },
1035        { label: "Devices", value: (row) => row.device_limit ?? "-" }
1036      ], "No license linked to this account.");
1037      renderTable("[data-account-billing-events]", billing.events || [], [
1038        { label: "Date", value: (row) => row.created_at || "-" },
1039        { label: "Provider", value: (row) => row.provider || "manual_beta" },
1040        { label: "Status", value: (row) => row.provider_status || "-" },
1041        { label: "Amount", value: (row) => `${((Number(row.amount_cents || 0)) / 100).toFixed(2)} ${row.currency || "USD"}` }
1042      ], "No payment records available here. Open Whop to view your billing history and receipts.");
1043      renderTable("[data-account-sessions]", [], [], "Sessions require account login (not beta license session).");
1044      portalOutput("Account dashboard loaded.");
1045    } catch (error) {
1046      if (error.message.includes("session")) clearSession();
1047      portalOutput(error.message);
1048    }
1049  }
1050}
1051
1052function formatNumber(value) {
1053  return typeof value === "number" ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) : "-";
1054}
1055
1056function escapeHtml(value) {
1057  return String(value ?? "")
1058    .replaceAll("&", "&amp;")
1059    .replaceAll("<", "&lt;")
1060    .replaceAll(">", "&gt;")
1061    .replaceAll('"', "&quot;")
1062    .replaceAll("'", "&#039;");
1063}
1064
1065function renderLevelCard(levels) {
1066  const snapshot = levels.display_levels || levels.snapshot || {};
1067  const points = levels.points_of_interest || snapshot.points_of_interest || {};
1068  const zones = levels.zones || snapshot.zones || {};
1069  const focus = ["focus_1", "focus_2", "focus_3"]
1070    .map((key, index) => {
1071      const item = points[key];
1072      return item?.value ? `F${index + 1} ${formatNumber(item.value)} ${escapeHtml(item.confidence || "")}` : "";
1073    })
1074    .filter(Boolean)
1075    .join(", ") || "-";
1076  const battle = zones.battle_zone
1077    ? `${formatNumber(zones.battle_zone.low)} - ${formatNumber(zones.battle_zone.high)}`
1078    : "-";
1079  const warning = snapshot.dev_only || snapshot.synthetic || snapshot.not_real_market_data
1080    ? "<p class=\"warning-text\">Dev/synthetic beta snapshot - not real market data.</p>"
1081    : "";
1082  const requested = levels.requested_symbol || snapshot.requested_symbol || snapshot.symbol || "-";
1083  const resolved = levels.resolved_symbol || snapshot.resolved_symbol || snapshot.context_symbol || requested;
1084  const mapping = requested !== resolved ? `${requested} uses ${resolved} option-flow context` : `${requested} direct context`;
1085  return `
1086    <article class="card level-card">
1087      <h3>${escapeHtml(requested)}</h3>
1088      ${warning}
1089      <dl class="status-list">
1090        <dt>Timestamp</dt><dd>${escapeHtml(snapshot.timestamp || "-")}</dd>
1091        <dt>Backend context</dt><dd>${escapeHtml(resolved)}</dd>
1092        <dt>Mapping</dt><dd>${escapeHtml(mapping)}</dd>
1093        <dt>Expiration</dt><dd>${escapeHtml(levels.selected_expiration || snapshot.exp || snapshot.expiration || "-")}</dd>
1094        <dt>Spot</dt><dd>${formatNumber(snapshot.spot)}</dd>
1095        <dt>Call wall</dt><dd>${formatNumber(snapshot.call_wall)}</dd>
1096        <dt>Put wall</dt><dd>${formatNumber(snapshot.put_wall)}</dd>
1097        <dt>Gamma flip</dt><dd>${formatNumber(snapshot.gamma_flip)}</dd>
1098        <dt>Battle zone</dt><dd>${battle}</dd>
1099        <dt>Focus</dt><dd>${escapeHtml(focus || "-")}</dd>
1100      </dl>
1101    </article>
1102  `;
1103}
1104
1105// ---------------------------------------------------------------------------
1106// B9: Sessions UI — list active sessions with per-session revoke
1107// ---------------------------------------------------------------------------
1108
1109/**
1110 * Render sessions from GET /account/sessions into a target element.
1111 * Each non-current session gets a Revoke button.
1112 */
1113function renderAuthSessions(sessionsRes, targetSelector) {
1114  const target = document.querySelector(targetSelector);
1115  if (!target) return;
1116  const sessions = sessionsRes.sessions || [];
1117  const currentId = sessionsRes.current_session_id || "";
1118  if (sessions.length === 0) {
1119    target.innerHTML = "<p class=\"muted\">No active sessions found.</p>";
1120    return;
1121  }
1122  target.innerHTML = `
1123    <table>
1124      <thead>
1125        <tr><th>Status</th><th>Created</th><th>Expires</th><th>Last seen</th><th></th></tr>
1126      </thead>
1127      <tbody>
1128        ${sessions.map((s) => {
1129          const isCurrent = s.id === currentId;
1130          const revBtn = isCurrent
1131            ? ""
1132            : `<button class="button" type="button" data-revoke-session="${escapeHtml(s.id)}">Revoke</button>`;
1133          return `
1134          <tr>
1135            <td>${escapeHtml(isCurrent ? "Current" : "Active")}</td>
1136            <td>${escapeHtml(s.created_at || "-")}</td>
1137            <td>${escapeHtml(s.expires_at || "-")}</td>
1138            <td>${escapeHtml(s.last_seen_at || "-")}</td>
1139            <td>${revBtn}</td>
1140          </tr>`;
1141        }).join("")}
1142      </tbody>
1143    </table>
1144    ${sessions.filter((s) => s.id !== currentId).length > 0
1145      ? `<div class="rm-actions rm-mt"><button class="button" type="button" data-revoke-others>Sign out all other devices</button></div>`
1146      : ""}
1147  `;
1148  // Wire "sign out all other devices"
1149  const bulk = target.querySelector("[data-revoke-others]");
1150  if (bulk) {
1151    bulk.addEventListener("click", async () => {
1152      bulk.disabled = true;
1153      bulk.textContent = "Signing out…";
1154      try {
1155        await authApiJson("/account/sessions/revoke-others", { method: "POST" });
1156        const refreshed = await authApiJson("/account/sessions");
1157        renderAuthSessions(refreshed, targetSelector);
1158        portalOutput("Signed out all other devices.");
1159      } catch (err) {
1160        bulk.disabled = false;
1161        bulk.textContent = "Sign out all other devices";
1162        portalOutput(`Could not sign out other devices: ${err.message}`);
1163      }
1164    });
1165  }
1166  // Wire revoke buttons
1167  target.querySelectorAll("[data-revoke-session]").forEach((btn) => {
1168    btn.addEventListener("click", async () => {
1169      const sid = btn.getAttribute("data-revoke-session");
1170      btn.disabled = true;
1171      btn.textContent = "Revoking…";
1172      try {
1173        await authApiJson(`/account/sessions/${encodeURIComponent(sid)}`, { method: "DELETE" });
1174        // Reload sessions after revoke
1175        const refreshed = await authApiJson("/account/sessions");
1176        renderAuthSessions(refreshed, targetSelector);
1177        portalOutput("Session revoked.");
1178      } catch (err) {
1179        btn.disabled = false;
1180        btn.textContent = "Revoke";
1181        portalOutput(`Revoke failed: ${err.message}`);
1182      }
1183    });
1184  });
1185}
1186
1187// ---------------------------------------------------------------------------
1188// B10: Entitlements — Education Library (NDA-gated) and Indicators display
1189// ---------------------------------------------------------------------------
1190
1191/**
1192 * Load entitlements from /account/entitlements (cookie session required).
1193 * Updates library page notice and any [data-entitlements] element.
1194 * Returns the entitlements array, or null on failure.
1195 */
1196async function loadEntitlements() {
1197  if (!hasAuthSession()) return null;
1198  try {
1199    const res = await authApiJson("/account/entitlements");
1200    const entitlements = res.entitlements || [];
1201
1202    const libEnt = findEntitlement(entitlements, "education_library");
1203    const libActive = isEntitlementActive(libEnt);
1204
1205    // The Library page notice + [data-library-status] are driven authoritatively
1206    // by loadLibraryState() (via /library/access-check, which also enforces the
1207    // NDA gate that /account/entitlements cannot see). Owning it there avoids a
1208    // misleading "Access granted" when the entitlement is active but the NDA is
1209    // unsigned. This function still renders the ownership summary below.
1210
1211    // Generic entitlements target
1212    const target = document.querySelector("[data-entitlements]");
1213    if (target) {
1214      const indEnt = findEntitlement(entitlements, "indicator_monthly") ||
1215                     findEntitlement(entitlements, "indicator_yearly") ||
1216                     findEntitlement(entitlements, "indicator_quarterly");
1217      const indActive = isEntitlementActive(indEnt);
1218      const terminalEnt = findEntitlement(entitlements, "terminal_monthly") ||
1219                          findEntitlement(entitlements, "terminal_quarterly") ||
1220                          findEntitlement(entitlements, "terminal_yearly");
1221      const terminalActive = isEntitlementActive(terminalEnt);
1222      target.innerHTML = `
1223        <dl class="status-list">
1224          <dt>Education Library</dt>
1225          <dd>${escapeHtml(libActive ? "Access granted" : "Not purchased / pending approval")}</dd>
1226          <dt>GEX Levels Indicator</dt>
1227          <dd>${escapeHtml(indActive ? `Active (${escapeHtml(indEnt.productId || "-")})` : "No active subscription")}</dd>
1228          <dt>GEX Levels Terminal</dt>
1229          <dd>${escapeHtml(terminalActive ? `Active (${escapeHtml(terminalEnt.productId || "-")})` : "No active subscription")}</dd>
1230        </dl>
1231      `;
1232    }
1233
1234    return entitlements;
1235  } catch {
1236    return null;
1237  }
1238}
1239
1240// ---------------------------------------------------------------------------
1241// Library access STATE — honest, multi-state, NDA-aware.
1242// Single authoritative source: GET /library/access-check (active + owned +
1243// non-expired + non-revoked entitlement AND accepted NDA). Renders into
1244// [data-library-status] (account/dashboard) and [data-library-access-notice]
1245// (library page) with a clear message + the correct next action. Never claims
1246// access based on login, key presence, NDA alone, or an Indicator licence.
1247// ---------------------------------------------------------------------------
1248const LIBRARY_ACCESS_STATES = {
1249  granted: { label: "Active", message: "Your Education Library access is active.", action: { href: "/library", text: "Open Library" } },
1250  signed_out: { label: "Signed out", message: "Sign in to see your Education Library access.", action: { href: "/login", text: "Log in" } },
1251  no_entitlement: { label: "Not purchased", message: "You don't have Education Library access yet.", action: { href: "/access", text: "Request access" } },
1252  nda_required: { label: "Agreement required", message: "Your access is ready — review and accept the confidentiality agreement to unlock the Library.", action: { href: "/account", text: "Review agreement" } },
1253  expired_entitlement: { label: "Expired", message: "Your Education Library access has expired.", action: { href: "/access", text: "Renew access" } },
1254  revoked_entitlement: { label: "Revoked", message: "Your Education Library access was revoked. Contact support if you believe this is an error.", action: { href: "/support", text: "Contact support" } },
1255  temporarily_unavailable: { label: "Temporarily unavailable", message: "We couldn't check your Library access right now. Please try again shortly.", action: null },
1256};
1257
1258// Raw access-check fetch: returns { ok, status, payload } and NEVER throws or
1259// clears the session (a 403 here is an expected state, not an auth failure).
1260async function libraryAccessCheck() {
1261  const headers = { Accept: "application/json" };
1262  const token = isAccountSessionValid() ? getAccountToken() : "";
1263  if (token) headers["Authorization"] = `Bearer ${token}`;
1264  try {
1265    const res = await fetch(`${apiBase}/library/access-check`, { method: "GET", credentials: "include", headers });
1266    let payload = {};
1267    try { payload = await res.json(); } catch { payload = {}; }
1268    return { ok: res.ok, status: res.status, payload };
1269  } catch {
1270    return { ok: false, status: 0, payload: { reason: "temporarily_unavailable" } };
1271  }
1272}
1273
1274function resolveLibraryStateKey(res) {
1275  if (res.ok && res.payload && res.payload.access === "granted") return "granted";
1276  const reason = res.payload && res.payload.reason;
1277  if (res.status === 401) return "signed_out";
1278  if (reason && LIBRARY_ACCESS_STATES[reason]) return reason;
1279  if (reason === "inactive_entitlement" || reason === "wrong_owner" || reason === "wrong_product") return "no_entitlement";
1280  return "temporarily_unavailable";
1281}
1282
1283function renderLibraryState(stateKey) {
1284  const st = LIBRARY_ACCESS_STATES[stateKey] || LIBRARY_ACCESS_STATES.temporarily_unavailable;
1285  // Library page plain-text notice.
1286  const notice = document.querySelector("[data-library-access-notice]");
1287  if (notice) { notice.hidden = false; notice.textContent = `${st.label}. ${st.message}`; }
1288  // Compact inline variant (e.g. a dashboard <dd>): label + message, no button.
1289  document.querySelectorAll("[data-library-status-text]").forEach((el) => {
1290    el.textContent = `${st.label} — ${st.message}`;
1291  });
1292  // Account / dashboard status block (label + message + action button).
1293  document.querySelectorAll("[data-library-status]").forEach((el) => {
1294    el.hidden = false;
1295    const action = st.action
1296      ? `<a class="button" href="${escapeHtml(st.action.href)}">${escapeHtml(st.action.text)}</a>`
1297      : "";
1298    el.innerHTML =
1299      `<div class="lib-state lib-state--${escapeHtml(stateKey)}">` +
1300      `<strong>Education Library: ${escapeHtml(st.label)}</strong>` +
1301      `<p class="muted">${escapeHtml(st.message)}</p>` +
1302      (action ? `<div class="actions compact">${action}</div>` : "") +
1303      `</div>`;
1304  });
1305}
1306
1307async function loadLibraryState() {
1308  if (!document.querySelector("[data-library-status]") &&
1309      !document.querySelector("[data-library-status-text]") &&
1310      !document.querySelector("[data-library-access-notice]")) {
1311    return null;
1312  }
1313  if (!hasAuthSession()) { renderLibraryState("signed_out"); return "signed_out"; }
1314  const res = await libraryAccessCheck();
1315  const stateKey = resolveLibraryStateKey(res);
1316  renderLibraryState(stateKey);
1317  return stateKey;
1318}
1319
1320// ---------------------------------------------------------------------------
1321// NDA — full-document review-and-sign page (/nda-sign)
1322// Owner request 2026-07-14 (Lucid-style): the complete agreement renders as a
1323// paper document; the consent checkbox unlocks only after the reader scrolls
1324// to the end; Submit records the acceptance through the SAME server rail as
1325// the account card (/account/nda/acknowledge — account + version + UTC
1326// timestamp recorded server-side). No new endpoint, no worker change.
1327// ---------------------------------------------------------------------------
1328
1329async function setupNdaSignPage() {
1330  const paper = document.querySelector("[data-nda-paper]");
1331  if (!paper) return;
1332  const progress = document.querySelector("[data-nda-progress]");
1333  const hint = document.querySelector("[data-nda-scroll-hint]");
1334  const form = document.querySelector("[data-nda-sign-form]");
1335  const checkbox = document.querySelector("[data-nda-accept]");
1336  const submit = document.querySelector("[data-nda-sign-submit]");
1337  const output = document.querySelector("[data-nda-output]");
1338  const say = (msg) => { if (output) output.textContent = msg; };
1339
1340  let readToEnd = false;
1341  let signable = false;
1342  function refreshControls() {
1343    const canTick = readToEnd && signable;
1344    if (checkbox) {
1345      checkbox.disabled = !canTick;
1346      if (!canTick) checkbox.checked = false;
1347    }
1348    if (submit) submit.disabled = !(canTick && checkbox && checkbox.checked);
1349  }
1350  function onScroll() {
1351    const max = paper.scrollHeight - paper.clientHeight;
1352    const pct = max <= 4 ? 100 : Math.min(100, Math.round((paper.scrollTop / max) * 100));
1353    if (progress) progress.textContent = pct + "% read";
1354    if (pct >= 98 && !readToEnd) {
1355      readToEnd = true;
1356      if (hint) {
1357        hint.setAttribute("data-done", "");
1358        hint.textContent = "Document read to the end — you can now accept below.";
1359      }
1360      refreshControls();
1361    }
1362  }
1363  paper.addEventListener("scroll", onScroll, { passive: true });
1364  onScroll(); // short document / tall viewport: unlock immediately
1365
1366  let status;
1367  try {
1368    status = await authApiJson("/account/nda/status?product_id=education_library");
1369  } catch {
1370    say("Sign in first — your signature is recorded against your verified account. Go to /login, then come back to this page.");
1371    return;
1372  }
1373  if (status.acknowledged) {
1374    say(`Agreement already accepted (version ${status.version}). Your signed status is visible in your account.`);
1375    if (form) form.setAttribute("hidden", "");
1376    return;
1377  }
1378  if (!status.configured) {
1379    say("The agreement is not yet available to accept. No access is granted until it is in place.");
1380    return;
1381  }
1382  signable = true;
1383  say("Read the full document above, then tick the box and submit.");
1384  refreshControls();
1385  checkbox?.addEventListener("change", refreshControls);
1386  form?.addEventListener("submit", async (event) => {
1387    event.preventDefault();
1388    if (!checkbox?.checked) return;
1389    if (submit) submit.disabled = true; // prevent double submit
1390    say("Recording your acceptance…");
1391    try {
1392      await authApiJson("/account/nda/acknowledge", {
1393        method: "POST",
1394        body: JSON.stringify({ product_id: "education_library", version: status.version }),
1395      });
1396      if (form) form.setAttribute("hidden", "");
1397      say(`Agreement accepted (version ${status.version}) — recorded with a UTC timestamp on your account. The Library unlocks once your entitlement is active.`);
1398    } catch (err) {
1399      if (submit) submit.disabled = false;
1400      say(`Could not record acceptance: ${err.message}`);
1401    }
1402  });
1403}
1404
1405// ---------------------------------------------------------------------------
1406// NDA — versioned consent for Education Library (account page)
1407// ---------------------------------------------------------------------------
1408
1409async function setupNdaSection() {
1410  const section = document.querySelector("[data-nda-section]");
1411  if (!section || !hasAuthSession()) return;
1412
1413  const summary = section.querySelector("[data-nda-summary]");
1414  const docActions = section.querySelector("[data-nda-doc-actions]");
1415  const viewLink = section.querySelector("[data-nda-view]");
1416  const downloadLink = section.querySelector("[data-nda-download]");
1417  const form = section.querySelector("[data-nda-form]");
1418  const checkbox = section.querySelector("[data-nda-checkbox]");
1419  const acceptBtn = section.querySelector("[data-nda-accept]");
1420  const versionEl = section.querySelector("[data-nda-version]");
1421  const output = section.querySelector("[data-nda-output]");
1422
1423  function show(el) { if (el) el.removeAttribute("hidden"); }
1424  function hide(el) { if (el) el.setAttribute("hidden", ""); }
1425  function say(msg) { if (output) { output.removeAttribute("hidden"); output.textContent = msg; } }
1426
1427  // Wire (and reveal) the View / Download actions for BOTH states (before AND
1428  // after acceptance, so a signer can always re-read / keep a copy).
1429  // status.document_url is only the plain-language SUMMARY (/legal/nda) — it does
1430  // not contain the full agreement, and a `download` attr on an HTML page just
1431  // saved the page, so both buttons appeared broken. "View agreement" -> /nda-sign
1432  // (renders the FULL formatted agreement). "Download a copy" -> the real .txt for
1433  // the reported version (intl-nda-v1.1-en -> /legal/international-nda-v1.1-en.txt).
1434  function wireDocActions() {
1435    if (!status || !status.document_url) return;
1436    if (viewLink) viewLink.href = "/nda-sign";
1437    if (downloadLink) {
1438      if (status.version) {
1439        downloadLink.href = "/legal/" + status.version.replace(/^intl-/, "international-") + ".txt";
1440      } else {
1441        downloadLink.href = status.document_url;
1442        downloadLink.removeAttribute("download");
1443      }
1444    }
1445    show(docActions);
1446  }
1447
1448  let status;
1449  try {
1450    status = await authApiJson("/account/nda/status?product_id=education_library");
1451  } catch {
1452    return; // not signed in or endpoint unavailable — leave section hidden
1453  }
1454
1455  if (!status.required) return; // section only relevant for NDA-gated products
1456  show(section);
1457
1458  if (!status.configured) {
1459    summary.textContent = "The Education Library confidentiality agreement is being finalized and is not yet available to accept. No access is granted until it is in place.";
1460    return;
1461  }
1462
1463  if (status.acknowledged) {
1464    summary.textContent = `Agreement accepted (version ${status.version}). You can re-read or download your signed agreement below.`;
1465    wireDocActions(); // keep View / Download available after acceptance
1466    return;
1467  }
1468
1469  // Required, configured, not yet accepted → show document + unchecked consent form.
1470  summary.textContent = "Education Library access requires accepting the confidentiality agreement below. The agreement applies only to Education Library materials.";
1471  if (versionEl) versionEl.textContent = status.version;
1472  wireDocActions();
1473  // Checkbox starts unchecked (not pre-checked); accept stays disabled until checked.
1474  if (checkbox) checkbox.checked = false;
1475  if (acceptBtn) acceptBtn.disabled = true;
1476  show(form);
1477
1478  checkbox?.addEventListener("change", () => {
1479    if (acceptBtn) acceptBtn.disabled = !checkbox.checked;
1480  });
1481
1482  form?.addEventListener("submit", async (event) => {
1483    event.preventDefault();
1484    if (!checkbox?.checked) { say("Please read and check the box to accept."); return; }
1485    if (acceptBtn) acceptBtn.disabled = true; // prevent double submit
1486    say("Recording your acceptance…");
1487    try {
1488      await authApiJson("/account/nda/acknowledge", {
1489        method: "POST",
1490        body: JSON.stringify({ product_id: "education_library", version: status.version }),
1491      });
1492      hide(form);
1493      // Keep the View / Download actions visible so the signer can re-read or
1494      // keep a copy of what they just accepted.
1495      summary.textContent = `Agreement accepted (version ${status.version}
1495). Thank you. You can re-read or download your signed agreement below.`;
1496      say("Agreement accepted.");
1497    } catch (err) {
1498      if (acceptBtn) acceptBtn.disabled = false;
1499      say(`Could not record acceptance: ${err.message}`);
1500    }
1501  });
1502}
1503
1504// ---------------------------------------------------------------------------
1505// B12: Navigation — update nav links based on auth state
1506// ---------------------------------------------------------------------------
1507
1508// Reconcile the shared site header/nav with auth state on EVERY page.
1509// ROOT-CAUSE FIX: the public header's CTAs ("Sign in" / "Get access") live in
1510// `.ld-nav__cta` — a SIBLING of <nav>, not a descendant — so the old
1511// `nav a[href='/login']` selector never matched them, leaving authenticated
1512// users with a logged-out header site-wide. This targets the real elements and
1513// also handles the "Get access" / "Request access" CTAs and the mobile header.
1514function applyHeaderAuthState(loggedIn) {
1515  const docEl = document.documentElement;
1516  docEl.classList.toggle("is-authed", !!loggedIn);
1517  docEl.classList.toggle("is-anon", !loggedIn);
1518  // Desktop header CTA cluster.
1519  document.querySelectorAll(".ld-nav__cta").forEach((cta) => {
1520    const signin = cta.querySelector("a[href='/login']");
1521    const getAccess = cta.querySelector("a[href^='/request-access'], a[href^='/access']");
1522    if (loggedIn) {
1523      if (signin) { signin.href = "/account"; signin.textContent = "Account"; }
1524      if (getAccess) { getAccess.href = "/account"; getAccess.textContent = "Dashboard"; getAccess.removeAttribute("data-evt"); }
1525    } else {
1526      if (signin) { signin.href = "/login"; signin.textContent = "Sign in"; }
1527    }
1528  });
1529  // Mobile header CTA link.
1530  document.querySelectorAll(".ld-mobile-nav__cta").forEach((el) => {
1531    const href = el.getAttribute("href") || "";
1532    if (loggedIn && /\/(request-access|access)\b/.test(href)) { el.href = "/account"; el.textContent = "Dashboard"; el.removeAttribute("data-evt"); }
1533  });
1534  // Primary nav login/signup links (where present inside <nav>).
1535  document.querySelectorAll("nav a[href='/login']").forEach((l) => { if (loggedIn) { l.href = "/account"; l.textContent = "Account"; } });
1536  document.querySelectorAll("nav a[href='/signup']").forEach((l) => { l.style.display = loggedIn ? "none" : ""; });
1537
1538  // Make account creation discoverable from every public header. /signup works
1539  // but was historically only linked from /login, so a new visitor could not
1540  // find it. Inject a "Create account" link into the desktop CTA cluster (before
1541  // "Sign in") and the mobile menu when anonymous; remove it when authed.
1542  // Idempotent — mirrors the journal-quickaccess injection above.
1543  document.querySelectorAll(".ld-nav__cta").forEach((cta) => {
1544    let s = cta.querySelector("[data-signup-cta]");
1545    if (!loggedIn) {
1546      if (!s) {
1547        s = document.createElement("a");
1548        s.className = "ld-btn ld-btn--ghost ld-nav__signup";
1549        s.setAttribute("data-signup-cta", "");
1550        s.href = "/signup";
1551        s.textContent = "Create account";
1552        cta.insertBefore(s, cta.querySelector("a[href='/login'], a[href='/account']") || cta.firstChild);
1553      }
1554    } else if (s) { s.remove(); }
1555  });
1556  document.querySelectorAll(".ld-mobile-nav").forEach((mnav) => {
1557    let ms = mnav.querySelector("[data-signup-cta]");
1558    if (!loggedIn) {
1559      if (!ms) {
1560        ms = document.createElement("a");
1561        ms.setAttribute("data-signup-cta", "");
1562        ms.className = "ld-mobile-nav__auth ld-mobile-nav__signup";
1563        ms.href = "/signup";
1564        ms.textContent = "Create account";
1565        mnav.insertBefore(ms, mnav.querySelector(".ld-mobile-nav__cta"));
1566      }
1567    } else if (ms) { ms.remove(); }
1568  });
1569
1570  // Authenticated Journal quick-access. Added only when authenticated (removed
1571  // when anonymous); idempotent because this runs on the sync pass and again on
1572  // the server-confirm pass. Desktop: gold-accent control in the CTA cluster,
1573  // ahead of Dashboard. Mobile: a link in the shared mobile nav. Opens /journal.
1574  document.querySelectorAll(".ld-nav__cta").forEach((cta) => {
1575    let j = cta.querySelector("[data-journal-quickaccess]");
1576    if (loggedIn) {
1577      if (!j) {
1578        j = document.createElement("a");
1579        j.className = "ld-btn ld-btn--ghost ld-nav__journal";
1580        j.setAttribute("data-journal-quickaccess", "");
1581        j.href = "/journal";
1582        j.textContent = "Journal";
1583        cta.insertBefore(j, cta.querySelector("a[href='/account']"));
1584      }
1585    } else if (j) { j.remove(); }
1586  });
1587  document.querySelectorAll(".ld-mobile-nav").forEach((mnav) => {
1588    let mj = mnav.querySelector("[data-journal-quickaccess]");
1589    if (loggedIn) {
1590      if (!mj) {
1591        mj = document.createElement("a");
1592        mj.className = "ld-mobile-nav__journal";
1593        mj.setAttribute("data-journal-quickaccess", "");
1594        mj.href = "/journal";
1595        mj.textContent = "Journal";
1596        mnav.insertBefore(mj, mnav.querySelector(".ld-mobile-nav__cta"));
1597      }
1598    } else if (mj) { mj.remove(); }
1599  });
1600  // P0 FIX — mobile menu auth entry. The static .ld-mobile-nav (18 landing pages)
1601  // shipped with NO Sign in / Account link, so on a phone the burger menu offered
1602  // no way to reach /login (the desktop "Sign in" lives in .ld-nav__cta, which is
1603  // display:none below 720px). Surface the canonical route here: "Sign in" → /login
1604  // when anonymous, "Account" → /account when authed (mirrors the desktop swap).
1605  // The burger menu is itself JS-gated (landing.js toggle), so injecting in this
1606  // same JS lifecycle is consistent and adds no second auth system.
1607  document.querySelectorAll(".ld-mobile-nav").forEach((mnav) => {
1608    let ma = mnav.querySelector("[data-mobile-auth]");
1609    if (!ma) {
1610      ma = document.createElement("a");
1611      ma.setAttribute("data-mobile-auth", "");
1612      ma.className = "ld-mobile-nav__auth";
1613      mnav.insertBefore(ma, mnav.querySelector(".ld-mobile-nav__cta"));
1614    }
1615    if (loggedIn) { ma.href = "/account"; ma.textContent = "Account"; }
1616    else { ma.href = "/login"; ma.textContent = "Sign in"; }
1617  });
1618}
1619
1620let _headerSessionConfirmed = false;
1621// Confirm the optimistic (localStorage) header state against the server so a
1622// stale/revoked/expired token, or a logout in another tab, reverts the header to
1623// anonymous. Network errors keep the optimistic state (no flash).
1624async function confirmHeaderSession() {
1625  if (_headerSessionConfirmed) return;
1626  _headerSessionConfirmed = true;
1627  // Read the markers with NON-side-effecting getters first. (hasAuthSession() and
1628  // isAccountSessionValid() can CLEAR the token on a stale local-expiry stamp — we
1629  // must not let that pre-empt the server, which is the real authority on validity.)
1630  const accountToken = getAccountToken();
1631  if (!(accountToken || getCsrfToken() || getSessionToken())) return;
1632  try {
1633    const headers = { Accept: "application/json" };
1634    // Always present the token when we have one (do NOT gate on the local expiry
1635    // guess) so the server can validate it; the host-only session cookie rides
1636    // along via credentials:"include". Only a genuine 401 (both rejected) logs out.
1637    if (accountToken) headers.Authorization = `Bearer ${accountToken}`;
1638    const res = await fetch(`${apiBase}/auth/me`, { method: "GET", credentials: "include", headers });
1639    if (res.status === 401) {
1640      // The server is the authority and it says this session is gone. Clear the
1641      // csrf HINT too, not just the account token: the hint alone makes
1642      // hasAuthSession() report "signed in", which used to leave the user in a
1643      // phantom logged-in state that redirected them away from /login.
1644      clearAccountSession();
1645      setCsrfHint("");
1646      applyHeaderAuthState(false);
1647      return;
1648    }
1649    if (res.ok) applyHeaderAuthState(true);
1650  } catch (_) { /* network error → keep optimistic state */ }
1651}
1652
1653function updateNavForAuthState() {
1654  // Synchronous (localStorage/cookie) pass first → no permanent logged-out header.
1655  applyHeaderAuthState(hasAuthSession() || !!getSessionToken());
1656  // Then confirm with the server (revocation/expiry/cross-tab logout).
1657  confirmHeaderSession();
1658}
1659
1660// ---------------------------------------------------------------------------
1661// B6: Email verification page handler
1662// ---------------------------------------------------------------------------
1663
1664async function handleEmailVerifyPage() {
1665  const confirmSection = document.getElementById("verify-section");
1666  const resendSection = document.getElementById("resend-section");
1667  if (!confirmSection || !resendSection) return;
1668
1669  const token = consumeUrlToken();
1670
1671  const loginCta = confirmSection.querySelector("[data-verify-login]");
1672
1673  if (token) {
1674    // Confirming state: show the verify card, hide the resend card initially.
1675    confirmSection.hidden = false;
1676    resendSection.hidden = true;
1677    if (loginCta) loginCta.hidden = true;
1678    const output = confirmSection.querySelector("[data-auth-output]");
1679    if (output) { output.removeAttribute("hidden"); output.textContent = "Confirming your email…"; }
1680    try {
1681      await authApiJson("/auth/email-verification/confirm", {
1682        method: "POST",
1683        body: JSON.stringify({ token }),
1684      });
1685      // Success → offer login.
1686      if (output) output.textContent = "Your email is verified. You can now log in.";
1687      if (loginCta) loginCta.hidden = false;
1688    } catch (err) {
1689      // Expired/invalid/already-used (generic, anti-enumeration) or network error.
1690      // Show the reason and reveal the resend card so the user can get a fresh link.
1691      if (output) output.textContent = err.message;
1692      resendSection.hidden = false;
1693    }
1694  } else {
1695    // No token in URL → just show the resend form.
1696    confirmSection.hidden = true;
1697    resendSection.hidden = false;
1698  }
1699
1700  // Wire resend form
1701  const resendForm = document.querySelector("[data-email-verify-resend-form]");
1702  if (resendForm) {
1703    resendForm.addEventListener("submit", async (event) => {
1704      event.preventDefault();
1705      const email = String(new FormData(resendForm).get("email") || "").trim().toLowerCase();
1706      // Query within resendSection so we don't accidentally target the hidden verify box.
1707      const output = resendSection.querySelector("[data-auth-output]");
1708      if (output) { output.removeAttribute("hidden"); output.textContent = "Sending verification email…"; }
1709      try {
1710        await authApiJson("/auth/email-verification/request", {
1711          method: "POST",
1712          body: JSON.stringify({ email }),
1713        });
1714        if (output) output.textContent = "Verification email sent. Check your inbox (and spam folder).";
1715        resendForm.reset();
1716      } catch (err) {
1717        if (output) output.textContent = `Failed: ${err.message}`;
1718      }
1719    });
1720  }
1721}
1722
1723// ---------------------------------------------------------------------------
1724// B7: Password reset page handler
1725// ---------------------------------------------------------------------------
1726
1727async function handlePasswordResetPage() {
1728  const confirmSection = document.getElementById("confirm-section");
1729  const requestSection = document.getElementById("request-section");
1730  if (!confirmSection || !requestSection) return;
1731
1732  const token = consumeUrlToken();
1733
1734  if (token) {
1735    // Show confirm form (set new password)
1736    confirmSection.hidden = false;
1737    requestSection.hidden = true;
1738
1739    const confirmForm = document.querySelector("[data-password-reset-confirm-form]");
1740    if (confirmForm) {
1741      confirmForm.addEventListener("submit", async (event) => {
1742        event.preventDefault();
1743        const data = new FormData(confirmForm);
1744        const password = String(data.get("password") || "");
1745        const confirmPwd = String(data.get("confirm_password") || "");
1746        // Query within the active section.
1747        const output = confirmSection.querySelector("[data-auth-output]");
1748        if (output) output.removeAttribute("hidden");
1749        if (password !== confirmPwd) {
1750          if (output) output.textContent = "Passwords do not match.";
1751          return;
1752        }
1753        if (output) output.textContent = "Resetting password…";
1754        try {
1755          await authApiJson("/auth/password-reset/confirm", {
1756            method: "POST",
1757            body: JSON.stringify({ token, password }),
1758          });
1759          confirmForm.reset();
1760          if (output) output.textContent = "Password reset. All sessions have been signed out. You can now log in.";
1761          setTimeout(() => { window.location.href = "/login"; }, 3000);
1762        } catch (err) {
1763          if (output) output.textContent = `Reset failed: ${err.message}`;
1764        }
1765      });
1766    }
1767  } else {
1768    // Show request form (email input)
1769    confirmSection.hidden = true;
1770    requestSection.hidden = false;
1771
1772    const requestForm = document.querySelector("[data-password-reset-request-form]");
1773    if (requestForm) {
1774      requestForm.addEventListener("submit", async (event) => {
1775        event.preventDefault();
1776        const email = String(new FormData(requestForm).get("email") || "").trim().toLowerCase();
1777        // Query within requestSection to avoid targeting the hidden confirm box.
1778        const output = requestSection.querySelector("[data-auth-output]");
1779        const sayReset = (m) => { if (output) { output.removeAttribute("hidden"); output.textContent = m; } };
1780        const ttToken = await ensureTurnstileToken(requestForm, sayReset);
1781        if (ttToken === false) return;
1782        sayReset("Sending reset email…");
1783        try {
1784          const resetBody = { email };
1785          if (ttToken) resetBody["cf-turnstile-response"] = ttToken;
1786          await authApiJson("/auth/password-reset/request", {
1787            method: "POST",
1788            body: JSON.stringify(resetBody),
1789          });
1790          requestForm.reset();
1791          resetTurnstile(requestForm);
1792          sayReset("If an account exists for that email, a reset link has been sent. Check your inbox.");
1793        } catch (err) {
1794          resetTurnstile(requestForm);
1795          sayReset(`Failed: ${err.message}`);
1796        }
1797      });
1798    }
1799  }
1800
1801  // Also wire password-toggle buttons on confirm form
1802  document.querySelectorAll("[data-toggle-password]").forEach((button) => {
1803    button.addEventListener("click", togglePasswordVisibility);
1804  });
1805}
1806
1807// ---------------------------------------------------------------------------
1808// Library APPLICATION shell. For a fully authorized member (active entitlement
1809// + current NDA), /library is a product application — the marketing sections are
1810// removed and the in-site reader + quick-access-by-module become the page.
1811// Anonymous / unauthorized visitors keep the commercial Library page unchanged.
1812// Server-side authorization (entitlement + NDA + reader release + private R2) is
1813// unchanged; this only changes what an already-authorized member SEES.
1814// ---------------------------------------------------------------------------
1815function humanizeModule(m) {
1816  return String(m || "").replace(/^mod[_-]?/i, "").replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim() || "Module";
1817}
1818// Education Library difficulty colour code (Foundation → Core → Advanced). Tier
1819// is derived from the module slug so it stays identical across the module
1820// quick-access grid (here) and the in-site reader (library-reader.js, which
1821// reads window.gexLibDifficulty). Colour is never the only signal — a text label
1822// accompanies every dot/pill for accessibility.
1823var GEX_LIB_DIFFICULTY = {
1824  mod_foundations: "foundation",
1825  mod_data_tools_literacy: "foundation",
1826  mod_trading_psychology: "foundation",
1827  mod_optionflow: "core",
1828  mod_orderflow: "core",
1829  mod_bookmap_heatmap: "core",
1830  mod_market_regimes: "core",
1831  mod_execution_trade_management: "core",
1832  mod_checklists_routines_case_studies: "core",
1833  mod_macro_context: "core",
1834  mod_sector_index_structure: "core",
1835  mod_market_replay_training: "core",
1836  mod_bridge_optionflow_orderflow: "advanced",
1837  mod_volatility_products: "advanced",
1838  mod_risk_management_advanced: "advanced",
1839  mod_professional_workflow: "advanced",
1840  mod_earnings_playbooks: "advanced",
1841  mod_0dte_specialization: "advanced",
1842  mod_feature_engineering_backtesting: "advanced",
1843};
1844var GEX_LIB_DIFFICULTY_LABEL = { foundation: "Foundation", core: "Core", advanced: "Advanced" };
1845function gexLibDifficulty(m) { return GEX_LIB_DIFFICULTY[String(m || "")] || "core"; }
1846try { window.gexLibDifficulty = gexLibDifficulty; window.gexLibDifficultyLabel = GEX_LIB_DIFFICULTY_LABEL; } catch (_) {}
1847function cssEscapeId(s) {
1848  try { if (window.CSS && CSS.escape) return CSS.escape(String(s)); } catch (_) {}
1849  return String(s).replace(/["\\\]]/g, "\\$&");
1850}
1851
1852async function setupLibraryAppExperience() {
1853  const page = document.querySelector("[data-library-page]");
1854  if (!page) return;
1855  const check = await libraryAccessCheck();
1856  const granted = check.ok && check.payload && check.payload.access === "granted";
1857  if (!granted) return; // anonymous / unauthorized keep the marketing Library page
1858  document.body.classList.add("is-library-app");
1859
1860  const headers = { Accept: "application/json" };
1861  const t = isAccountSessionValid() ? getAccountToken() : "";
1862  if (t) headers.Authorization = `Bearer ${t}`;
1863
1864  // Quick access by module — counts from the authoritative item list.
1865  let items = [];
1866  try {
1867    const r = await fetch(`${apiBase}/library/items`, { credentials: "include", headers });
1868    const b = await r.json();
1869    if (r.ok && b && b.ok) items = b.items || [];
1870  } catch (_) {}
1871  const modulesEl = document.querySelector("[data-library-modules]");
1872  if (modulesEl && items.length) {
1873    const counts = {};
1874    for (const it of items) counts[it.module] = (counts[it.module] || 0) + 1;
1875    modulesEl.innerHTML = Object.keys(counts).sort().map((m) => {
1876      const diff = gexLibDifficulty(m);
1877      return `<button class="lib-mod-card" type="button" data-library-module="${escapeHtml(m)}" data-difficulty="${diff}">` +
1878      `<span class="lib-mod-card__name">${escapeHtml(humanizeModule(m))}</span>` +
1879      `<span class="lib-mod-card__count"><span class="lib-diff lib-diff--${diff}" aria-hidden="true"></span>${counts[m]} resource${counts[m] === 1 ? "" : "s"} · ${escapeHtml(GEX_LIB_DIFFICULTY_LABEL[diff])}</span></button>`;
1880    }).join("");
1881  }
1882
1883  // Continue learning — last opened resource (private per-user progress).
1884  try {
1885    const r = await fetch(`${apiBase}/library/progress`, { credentials: "include", headers });
1886    const b = await r.json();
1887    const rows = (r.ok && b && (b.progress || b.items || b.rows)) || [];
1888    const last = rows.slice().sort((a, c) => String(c.last_opened_at || c.updated_at || "").localeCompare(String(a.last_opened_at || a.updated_at || "")))[0];
1889    const contEl = document.querySelector("[data-library-continue]");
1890    if (contEl && last && last.resource_id) {
1891      const title = (items.find((it) => it.resource_id === last.resource_id) || {}).title || "your last resource";
1892      contEl.innerHTML = `<p class="lib-home__kicker">Continue learning</p><button class="ld-btn ld-btn--primary" type="button" data-library-open="${escapeHtml(last.resource_id)}">Continue: ${escapeHtml(title)}</button>`;
1893      contEl.hidden = false;
1894    }
1895  } catch (_) {}
1896
1897  // Drive the existing secure reader through its public DOM hooks (no change to
1898  // the reader's own auth/render logic). Module → filter; continue → open.
1899  page.addEventListener("click", (e) => {
1900    const mod = e.target.closest("[data-library-module]");
1901    if (mod) {
1902      const search = document.querySelector("[data-reader-search]");
1903      if (search) { search.value = mod.getAttribute("data-library-module"); search.dispatchEvent(new Event("input", { bubbles: true })); }
1904      const reader = document.querySelector("[data-library-reader]");
1905      if (reader) reader.scrollIntoView({ behavior: "smooth", block: "start" });
1906      return;
1907    }
1908    const open = e.target.closest("[data-library-open]");
1909    if (open) {
1910      const id = open.getAttribute("data-library-open");
1911      const btn = document.querySelector(`[data-reader-open="${cssEscapeId(id)}"]`);
1912      if (btn) btn.click();
1913      const reader = document.querySelector("[data-library-reader]");
1914      if (reader) reader.scrollIntoView({ behavior: "smooth", block: "start" });
1915    }
1916  });
1917}
1918
1919function setupLibraryExplorer() {
1920  const page = document.querySelector("[data-library-page]");
1921  if (!page) {
1922    return;
1923  }
1924
1925  const search = page.querySelector("[data-library-search]");
1926  const filterButtons = Array.from(page.querySelectorAll("[data-library-filter]"));
1927  const cards = Array.from(page.querySelectorAll("[data-library-module]"));
1928  const results = page.querySelector("[data-library-results]");
1929
1930  function applyFilter() {
1931    const query = String(search?.value || "").trim().toLowerCase();
1932    const activeFilter = filterButtons.find((button) => button.classList.contains("is-active"))?.dataset.libraryFilter || "all";
1933    let visible = 0;
1934
1935    for (const card of cards) {
1936      const searchText = String(card.dataset.searchText || "").toLowerCase();
1937      const category = String(card.dataset.category || "").toLowerCase();
1938      const tier = String(card.dataset.tier || "").toLowerCase();
1939      const matchesQuery = !query || searchText.includes(query);
1940      const matchesFilter = activeFilter === "all" || category === activeFilter || tier === activeFilter;
1941      const show = matchesQuery && matchesFilter;
1942      card.hidden = !show;
1943      if (show) {
1944        visible += 1;
1945      }
1946    }
1947
1948    if (results) {
1949      results.textContent = `${visible} module${visible === 1 ? "" : "s"} visible`;
1950    }
1951  }
1952
1953  search?.addEventListener("input", applyFilter);
1954  for (const button of filterButtons) {
1955    button.addEventListener("click", () => {
1956      for (const other of filterButtons) {
1957        other.classList.toggle("is-active", other === button);
1958      }
1959      applyFilter();
1960    });
1961  }
1962
1963  applyFilter();
1964}
1965
1966async function submitRequestAccess(event) {
1967  event.preventDefault();
1968  const form = event.currentTarget;
1969  const data = new FormData(form);
1970  const productRequested = String(data.get("product_requested") || "").trim();
1971  const ndaAcknowledged = data.get("nda_acknowledged") === "on";
1972  if (productRequested === "education_library" && !ndaAcknowledged) {
1973    requestAccessOutput("Education Library access requires NDA acknowledgement.");
1974    return;
1975  }
1976  const payload = {
1977    full_name: String(data.get("full_name") || "").trim(),
1978    email: String(data.get("email") || "").trim().toLowerCase(),
1979    discord_username: String(data.get("discord_username") || "").trim(),
1980    product_requested: productRequested,
1981    message: String(data.get("message") || "").trim(),
1982    nda_acknowledged: ndaAcknowledged,
1983    compliance_acknowledged: data.get("compliance_acknowledged") === "on"
1984  };
1985
1986  const ttToken = await ensureTurnstileToken(form, requestAccessOutput);
1987  if (ttToken === false) return;
1988  if (ttToken) payload["cf-turnstile-response"] = ttToken;
1989
1990  requestAccessOutput("Submitting request...");
1991  try {
1992    const response = await fetch("/api/request-access", {
1993      method: "POST",
1994      headers: {
1995        "Accept": "application/json",
1996        "Content-Type": "application/json"
1997      },
1998      body: JSON.stringify(payload)
1999    });
2000    let result = {};
2001    try {
2002      result = await response.json();
2003    } catch {
2004      result = {};
2005    }
2006    if (!response.ok) {
2007      throw new Error(friendlyApiError(result.error || result.message || `HTTP ${response.status}`));
2008    }
2009    form.reset();
2010    updateRequestAccessNdaRequirement();
2011    resetTurnstile(form);
2012    requestAccessOutput(`${requestAccessStatusMessage(result)}\nStatus: ${result.status || "received"}`);
2013  } catch (error) {
2014    resetTurnstile(form);
2015    requestAccessOutput(`Request not submitted: ${error.message}`);
2016  }
2017}
2018
2019// B4/B5: Real auth form handler — branches on signup vs login by checking for
2020// the confirm-password field (only present on the signup page).
2021// --- Two-factor login challenge (shown only when /auth/login returns twofa_required) ---
2022let pendingTwofaToken = "";
2023
2024function twofaOutput(msg) {
2025  const el = document.querySelector("[data-twofa-output]");
2026  if (el) { el.textContent = msg || ""; el.hidden = !msg; }
2027}
2028
2029let pendingTwofaFactors = {};
2030
2031function beginTwofaChallenge(form, token, factors) {
2032  pendingTwofaToken = token;
2033  pendingTwofaFactors = factors || {};
2034  if (form) form.hidden = true;
2035  const altLinks = document.querySelector("[data-auth-altlinks]");
2036  if (altLinks) altLinks.hidden = true;
2037  const step = document.querySelector("[data-twofa-step]");
2038  if (step) {
2039    step.hidden = false;
2040    const wakSupported = pendingTwofaFactors.webauthn && window.GEXWebAuthn && window.GEXWebAuthn.supported();
2041    const emailBtn = step.querySelector("[data-twofa-email-send]");
2042    if (emailBtn) emailBtn.hidden = !pendingTwofaFactors.email;
2043    const wakBtn = step.querySelector("[data-twofa-webauthn]");
2044    if (wakBtn) wakBtn.hidden = !wakSupported;
2045    // If only a passkey is available, hide the code box; otherwise show it.
2046    const codeOnly = !!(pendingTwofaFactors.totp || pendingTwofaFactors.email);
2047    const codeWrap = step.querySelector("[data-twofa-codewrap]");
2048    if (codeWrap) codeWrap.hidden = !codeOnly;
2049    const hint = step.querySelector("[data-twofa-hint]");
2050    if (hint) {
2051      hint.textContent = pendingTwofaFactors.totp
2052        ? "Enter the 6-digit code from your authenticator app. Lost your device? Enter a backup code."
2053        : pendingTwofaFactors.email
2054          ? "Choose how to finish signing in — enter an emailed code, or use a security ke
2054y."
2055          : "Use your security key or passkey to finish signing in.";
2056    }
2057    const input = step.querySelector("[data-twofa-code]");
2058    if (input && codeOnly) { input.value = ""; input.focus(); }
2059  }
2060  twofaOutput("");
2061}
2062
2063async function verifyTwofaChallenge() {
2064  const input = document.querySelector("[data-twofa-code]");
2065  const code = input ? input.value.trim() : "";
2066  if (!pendingTwofaToken) { twofaOutput("Your sign-in expired. Please start again."); return; }
2067  if (!/^[0-9a-z-]{4,12}$/i.test(code)) { twofaOutput("Enter the 6-digit code from your authenticator, email, or a backup code."); return; }
2068  twofaOutput("Verifying…");
2069  try {
2070    const payload = await authApiJson("/auth/2fa/login-verify", {
2071      method: "POST",
2072      body: JSON.stringify({ twofa_token: pendingTwofaToken, code }),
2073    });
2074    if (payload && payload.session_token) {
2075      setAccountSession(payload.session_token, payload.expires_at || "");
2076    }
2077    window.location.assign("/account");
2078  } catch (err) {
2079    twofaOutput(`Verification failed: ${err.message}`);
2080  }
2081}
2082
2083async function sendTwofaEmailCode() {
2084  if (!pendingTwofaToken) { twofaOutput("Your sign-in expired. Please start again."); return; }
2085  twofaOutput("Sending a code to your email…");
2086  try {
2087    await authApiJson("/auth/2fa/email/send", { method: "POST", body: JSON.stringify({ twofa_token: pendingTwofaToken }) });
2088    twofaOutput("We emailed you a 6-digit code. Enter it above.");
2089    const codeWrap = document.querySelector("[data-twofa-codewrap]");
2090    if (codeWrap) codeWrap.hidden = false;
2091    const input = document.querySelector("[data-twofa-code]");
2092    if (input) input.focus();
2093  } catch (err) {
2094    twofaOutput(`Could not send a code: ${err.message}`);
2095  }
2096}
2097
2098async function useTwofaSecurityKey() {
2099  if (!pendingTwofaToken) { twofaOutput("Your sign-in expired. Please start again."); return; }
2100  if (!window.GEXWebAuthn || !window.GEXWebAuthn.supported()) { twofaOutput("This browser does not support security keys."); return; }
2101  twofaOutput("Follow your browser's prompt…");
2102  try {
2103    const begin = await authApiJson("/auth/2fa/webauthn/auth-begin", { method: "POST", body: JSON.stringify({ twofa_token: pendingTwofaToken }) });
2104    const a = await window.GEXWebAuthn.authenticate(begin);
2105    const payload = await authApiJson("/auth/2fa/webauthn/auth-finish", {
2106      method: "POST",
2107      body: JSON.stringify({ twofa_token: pendingTwofaToken, credentialId: a.credentialId, authenticatorData: a.authenticatorData, clientDataJSON: a.clientDataJSON, signature: a.signature }),
2108    });
2109    if (payload && payload.session_token) setAccountSession(payload.session_token, payload.expires_at || "");
2110    window.location.assign("/account");
2111  } catch (err) {
2112    twofaOutput(err && err.name === "NotAllowedError" ? "Cancelled. Try again, or use a code instead." : `Security-key sign-in failed: ${err.message}`);
2113  }
2114}
2115
2116function initTwofaChallenge() {
2117  const btn = document.querySelector("[data-twofa-verify]");
2118  if (btn) btn.addEventListener("click", (e) => { e.preventDefault(); verifyTwofaChallenge(); });
2119  const input = document.querySelector("[data-twofa-code]");
2120  if (input) input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); verifyTwofaChallenge(); } });
2121  const emailBtn = document.querySelector("[data-twofa-email-send]");
2122  if (emailBtn) emailBtn.addEventListener("click", (e) => { e.preventDefault(); sendTwofaEmailCode(); });
2123  const wakBtn = document.querySelector("[data-twofa-webauthn]");
2124  if (wakBtn) wakBtn.addEventListener("click", (e) => { e.preventDefault(); useTwofaSecurityKey(); });
2125}
2126
2127// A `?next=` value is only honored when it's a same-origin relative path
2128// (starts with a single "/", never "//" or a "://" scheme) — otherwise an
2129// attacker-supplied next param could redirect a freshly-authenticated
2130// session off-site. Falls back to /account when absent/unsafe.
2131function getSafeNextPath() {
2132  try {
2133    const next = new URLSearchParams(window.location.search).get("next");
2134    if (next && next.startsWith("/") && !next.startsWith("//") && !next.includes("://")) {
2135      return next;
2136    }
2137  } catch (_) {}
2138  return "/account";
2139}
2140
2141async function handleAuthForm(event) {
2142  event.preventDefault();
2143  const form = event.currentTarget;
2144  const data = new FormData(form);
2145  const isSignup = form.querySelector("input[name='confirm_password']") !== null;
2146
2147  if (isSignup) {
2148    // B4: Signup — POST /auth/signup → show verify-email prompt; no auto-login.
2149    const password = form.querySelector("input[name='password']");
2150    const confirmPassword = form.querySelector("input[name='confirm_password']");
2151    if (password && confirmPassword && password.value !== confirmPassword.value) {
2152      authFormOutput("Passwords do not match. Please review and try again.");
2153      return;
2154    }
2155    const ttToken = await ensureTurnstileToken(form, authFormOutput);
2156    if (ttToken === false) return;
2157    authFormOutput("Creating account…");
2158    try {
2159      const discordRaw = String(data.get("discord_username") || "").trim();
2160      const body = {
2161        email: String(data.get("email") || "").trim().toLowerCase(),
2162        password: String(data.get("password") || ""),
2163      };
2164      if (discordRaw) body.discord_username = discordRaw;
2165      if (ttToken) body["cf-turnstile-response"] = ttToken;
2166      await authApiJson("/auth/signup", { method: "POST", body: JSON.stringify(body) });
2167      form.reset();
2168      resetTurnstile(form);
2169      authFormOutput(
2170        "Account created. Check your email for a verification link to activate your account. " +
2171        "If you do not receive it, visit /email-verify to resend."
2172      );
2173    } catch (err) {
2174      resetTurnstile(form);
2175      authFormOutput(`Sign-up failed: ${err.message}`);
2176    }
2177
2178  } else {
2179    // B5: Login — POST /auth/login → session cookie set by browser → redirect to /dashboard.
2180    authFormOutput("Signing in…");
2181    try {
2182      const loginPayload = await authApiJson("/auth/login", {
2183        method: "POST",
2184        body: JSON.stringify({
2185          email: String(data.get("email") || "").trim().toLowerCase(),
2186          password: String(data.get("password") || ""),
2187        }),
2188      });
2189      // 2FA: password accepted, but a second factor is required. The server has
2190      // minted NO session — it returned a short-lived challenge token. Show the
2191      // code step;
2191 the session is created only after /auth/2fa/login-verify.
2192      if (loginPayload && loginPayload.twofa_required && loginPayload.twofa_token) {
2193        beginTwofaChallenge(form, loginPayload.twofa_token, loginPayload.factors);
2194        return;
2195      }
2196      // Store the session BEFORE redirecting: cross-site SameSite=Lax cookies
2197      // are never attached to fetch() from this origin, so the Bearer token
2198      // in localStorage is the working session marker.
2199      if (loginPayload && loginPayload.session_token) {
2200        setAccountSession(loginPayload.session_token, loginPayload.expires_at || "");
2201      }
2202      // Fetch the CSRF token from the response BODY of /auth/csrf and cache it:
2203      // this is what lets the next page recognise the session when the cookie
2204      // is host-only on the API subdomain (unreadable from here). Best-effort —
2205      // a failure falls through to the redirect; the cookies are already set.
2206      try {
2207        const csrfPayload = await authApiJson("/auth/csrf", { method: "GET" });
2208        if (csrfPayload && csrfPayload.csrf_token) setCsrfHint(csrfPayload.csrf_token);
2209      } catch (_) { /* proceed — same-origin deploys can still read the cookie */ }
2210      window.location.assign(getSafeNextPath());
2211    } catch (err) {
2212      // authApiJson() always throws a user-safe, non-sensitive message
2213      // (credentials / CSRF / network-unreachable / endpoint-unavailable).
2214      // Show it directly; fall back to a generic line if one is ever missing.
2215      authFormOutput(err && err.message ? err.message : "Sign-in failed. Please try again.");
2216    }
2217  }
2218}
2219
2220function togglePasswordVisibility(event) {
2221  const button = event.currentTarget;
2222  const selector = button.getAttribute("data-toggle-password");
2223  const target = selector ? document.querySelector(selector) : button.closest(".password-field")?.querySelector("input");
2224  if (!target) {
2225    return;
2226  }
2227  const visible = target.type === "text";
2228  target.type = visible ? "password" : "text";
2229  button.textContent = visible ? "Show" : "Hide";
2230}
2231
2232async function copyMaskedValue(event) {
2233  const selector = event.currentTarget.getAttribute("data-copy-target");
2234  const target = selector ? document.querySelector(selector) : null;
2235  const value = target?.textContent?.trim() || "";
2236  const isFullKey = target?.dataset?.fullKey === "true";
2237  if (!value || value === "-" || /not stored here/i.test(value)) {
2238    // 2026-09-03: the key IS revealable now. Sending people to "Get new key"
2239    // here rotated (= invalidated) the key already typed into their platform.
2240    portalOutput("Click “Reveal key” first, then Copy — the full key appears on this page. Only use “Get new key” if Reveal says your key predates the reveal feature (a new key must then be entered in your indicator).");
2241    return;
2242  }
2243  if (!isFullKey) {
2244    // Guard: never let the user copy a masked preview/prefix — it won't unlock the
2245    // extension and is the classic "I copied my key but it says not found" trap.
2246    portalOutput("That's only a masked preview. Click “Reveal key” in the matching product card, then Copy. You do not need to replace your key.");
2247    return;
2248  }
2249  try {
2250    await navigator.clipboard.writeText(value);
2251    portalOutput("Key copied — paste it into the matching product's licence field.");
2252  } catch {
2253    portalOutput("Clipboard copy unavailable in this browser. Select the key and copy it manually.");
2254  }
2255}
2256
2257function renderLockedLevels(message) {
2258  const grid = document.querySelector("[data-levels-grid]");
2259  if (grid) {
2260    grid.innerHTML = `<article class="card levels-locked"><svg class="acc-ic" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z"/></svg><h3>Indicator levels locked</h3><p class="muted">${escapeHtml(message)}</p></article>`;
2261  }
2262}
2263
2264function hasActiveIndicatorEntitlement(entitlements) {
2265  return (Array.isArray(entitlements) ? entitlements : []).some((e) =>
2266    ["indicator_monthly", "indicator_yearly", "indicator_quarterly"].includes(e?.productId || e?.product_id) &&
2267    (e?.status || e?.access_status) === "active");
2268}
2269
2270async function loadLatestLevels() {
2271  // Protected market levels are gated server-side on an ACTIVE Indicator entitlement
2272  // (fail-closed). The client must confirm session + entitlement BEFORE requesting
2273  // them, so no protected value is ever requested/shown for an unauthorized user.
2274  if (!hasAuthSession()) {
2275    renderLockedLevels("Sign in with an Indicator subscription to view protected levels.");
2276    return;
2277  }
2278  let entitled = false;
2279  try {
2280    const me = await authApiJson("/auth/me");
2281    entitled = hasActiveIndicatorEntitlement(me?.entitlements);
2282  } catch { entitled = false; }
2283  if (!entitled) {
2284    renderLockedLevels("An active Indicator subscription is required to view protected market levels.");
2285    return;
2286  }
2287  const grid = document.querySelector("[data-levels-grid]");
2288  if (grid) {
2289    grid.innerHTML = "<article class=\"card\"><h3>Loading...</h3><p class=\"muted\">Fetching latest levels.</p></article>";
2290  }
2291  try {
2292    const symbols = ["QQQ", "SPY", "NQ", "ES"];
2293    // Authenticated + Indicator-entitled: request with the account Bearer; the
2294    // Worker re-verifies the entitlement and fails closed.
2295    // mode=locked (2026-07-31): the SESSION-OPEN snapshot, captured server-side
2296    // and identical to what the extension draws. Before this the page fetched
2297    // live data and presented it as "locked at open" — mid-session it showed a
2298    // completely different map than the chart (gamma flip 44 pts away, focus
2299    // levels re-ranked) while claiming to be the open.
2300    const payloads = await Promise.all(symbols.map((symbol) =>
2301      authApiJson(`/market-context/latest?symbol=${encodeURIComponent(symbol)}&mode=locked`)));
2302    if (grid) {
2303      grid.innerHTML = payloads.map(renderLevelCard).join("");
2304    }
2305    const capturedAt = payloads[0]?.locked_captured_at;
2306    portalOutput(capturedAt
2307      ? `Session-open snapshot — captured ${capturedAt.replace("T", " ").slice(0, 16)} UTC. Same map as the extension.`
2308      : "Latest levels loaded.");
2309  } catch (error) {
2310    renderLockedLevels(error?.message || "Latest levels are temporarily unavailable.");
2311    portalOutput(error?.message || "Latest levels unavailable.");
2312  }
2313}
2314
2315async function refreshPortalSession() {
2316  // Account-login sessions (cookie/Bearer) are managed server-side; "refresh"
2317  // re-loads the live account state. The legacy beta-portal token path remains
2318  // for users who created a session via a beta key.
2319  if (hasAuthSession()) {
2320    try {
2321      await loadAccountDashboard();
2322      portalOutput("Account refreshed.");
2323    } catch (error) {
2324      portalOutput(error?.message || "Could not refresh the account.");
2325    }
2326    return;
2327  }
2328  if (!getSessionToken()) {
2329    portalOutput("No active session to refresh. Please sign in.");
2330    return;
2331  }
2332  try {
2333    const payload = await apiJson("/session/refresh", { method: "POST", auth: true });
2334    setSession(payload.session_token, payload.expires_at);
2335    portalOutput(`Session refreshed. New expiry: ${payload.expires_at}.`);
2336    await loadAccountDashboard();
2337  } catch (error) {
2338    portalOutput(error.message);
2339  }
2340}
2341
2342async function logoutPortal() {
2343  // B5: Revoke new cookie session (POST /auth/logout requires CSRF).
2344  if (hasAuthSession()) {
2345    try {
2346      await authApiJson("/auth/logout", { method: "POST" });
2347    } catch { /* session already gone — local cleanup still applies */ }
2348    clearAccountSession();
2349  }
2350  // Legacy Bearer token logout.
2351  if (getSessionToken()) {
2352    try {
2353      await apiJson("/account/logout", { method: "POST", auth: true });
2354    } catch { /* local cleanup still applies if the remote session is already invali
2354d */ }
2355  }
2356  clearSession();
2357  clearAccountSession();
2358  renderAccount({});
2359  const grid = document.querySelector("[data-levels-grid]");
2360  if (grid) {
2361    grid.innerHTML = `
2362      <article class="card"><h3>QQQ</h3><p class="muted">Login, then load latest levels.</p></article>
2363      <article class="card"><h3>SPY</h3><p class="muted">Login, then load latest levels.</p></article>
2364      <article class="card"><h3>NQ</h3><p class="muted">Login, then load latest levels.</p></article>
2365      <article class="card"><h3>ES</h3><p class="muted">Login, then load latest levels.</p></article>
2366    `;
2367  }
2368  renderTable("[data-account-licenses]", [], [], "Logged out.");
2369  renderTable("[data-account-billing-events]", [], [], "Logged out.");
2370  renderTable("[data-account-sessions]", [], [], "Logged out.");
2371  portalOutput("Logged out. Session cleared from this browser.");
2372}
2373
2374function initAccountPage() {
2375  const body = document.body;
2376  if (!body || body.getAttribute("data-account-page") === null) return;
2377
2378  if (!(hasAuthSession() || !!getSessionToken())) {
2379    // The journal is a free, email-only lead magnet: rather than bounce an
2380    // anonymous visitor to /login, reveal the inline email gate (wired by
2381    // journal-access.js). Every OTHER account page still requires a full login.
2382    const gate = document.querySelector("[data-journal-gate]");
2383    if (body.getAttribute("data-journal-emailgate") !== null && gate) {
2384      document.documentElement.setAttribute("data-journal-anon", "1");
2385      gate.hidden = false;
2386      return;
2387    }
2388    window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname + window.location.search + window.location.hash));
2389    return;
2390  }
2391  // An authenticated visitor never sees the gate; make sure a stale anon flag is
2392  // cleared (e.g. after verifying and reloading into the same document).
2393  document.documentElement.removeAttribute("data-journal-anon");
2394
2395  Array.prototype.forEach.call(document.querySelectorAll("[data-logout]"), (btn) => {
2396    btn.addEventListener("click", (e) => {
2397      e.preventDefault();
2398      logoutPortal().then(() => window.location.replace("/login"));
2399    });
2400  });
2401
2402  // The shared workspace authenticates once and loads its own compact bootstrap.
2403  // Running the full account dashboard here caused unrelated D1/API reads.
2404  if (body.hasAttribute("data-workspace-page")) return;
2405  loadAccountDashboard();
2406  if (document.querySelector("[data-levels-grid]")) loadLatestLevels();
2407  setupAccountTilt();
2408  setupAccount3D();
2409}
2410
2411// Cursor parallax for the 3D hero "levels" stage. The scene already auto-floats
2412// in pure CSS; this layers a cursor-driven rotation onto .acc3d__tilt via CSSOM
2413// (no style="" attribute — CSP-safe). Gated to fine pointers + no reduced-motion,
2414// so touch/keyboard users keep the calm CSS-only float. Because the inner layers
2415// are Z-separated, rotating the tilt parallaxes the near ones (labels, spot,
2416// badge) more than the far grid — genuine depth, not a flat skew.
2417function setupAccount3D() {
2418  const stages = document.querySelectorAll("[data-acc3d]");
2419  if (!stages.length) return;
2420  const fine = window.matchMedia && window.matchMedia("(pointer: fine)").matches;
2421  const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2422  if (!fine || reduce) return;
2423  Array.prototype.forEach.call(stages, (stage) => {
2424    const tilt = stage.querySelector(".acc3d__tilt");
2425    if (!tilt) return;
2426    stage.addEventListener("pointermove", (e) => {
2427      const r = stage.getBoundingClientRect();
2428      const x = (e.clientX - r.left) / r.width - 0.5;
2429      const y = (e.clientY - r.top) / r.height - 0.5;
2430      tilt.style.transform = "rotateX(" + (-y * 12).toFixed(2) + "deg) rotateY(" + (x * 16).toFixed(2) + "deg)";
2431    });
2432    stage.addEventListener("pointerleave", () => { tilt.style.transform = ""; });
2433  });
2434}
2435
2436// Subtle cursor-driven 3D tilt on the premium account panels (the Account-
2437// overview card + the protected-levels wrapper carry data-tilt). CSP-safe: the
2438// transform is set via element.style (CSSOM), never a style="" attribute. Fully
2439// gated — inert under prefers-reduced-motion and on coarse/touch pointers, so it
2440// never interferes with data entry or touch scrolling. Attached to the stable
2441// containers (not per-injected level card) so it survives re-renders.
2442function setupAccountTilt() {
2443  const tilts = document.querySelectorAll("[data-tilt]");
2444  if (!tilts.length) return;
2445  const fine = window.matchMedia && window.matchMedia("(pointer: fine)").matches;
2446  const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2447  if (!fine || reduce) return;
2448  const MAX = 5; // degrees — calm, not gamer-y
2449  Array.prototype.forEach.call(tilts, (el) => {
2450    el.addEventListener("pointermove", (e) => {
2451      const r = el.getBoundingClientRect();
2452      const x = (e.clientX - r.left) / r.width - 0.5;
2453      const y = (e.clientY - r.top) / r.height - 0.5;
2454      el.style.transform =
2455        "perspective(1200px) rotateX(" + (-y * MAX).toFixed(2) + "deg) rotateY(" + (x * MAX).toFixed(2) + "deg)";
2456    });
2457    el.addEventListener("pointerleave", () => {
2458      el.style.transform = "perspective(1200px)";
2459    });
2460  });
2461}
2462
2463// Bouncing a visitor OFF the sign-in form is the one redirect that can lock them
2464// out of the product, so it must never fire on a guess.
2465//
2466// The bug it fixes (reported 2026-08-25, "impossible de se login", worst on
2467// phones): gex_csrf_hint is a localStorage convenience that outlives the real
2468// cookie session. Mobile browsers evict cross-site cookies aggressively (Safari
2469// ITP caps them around 7 days) while localStorage survives, so the hint routinely
2470// outlived the session it described. hasAuthSession() then reported "signed in",
2471// this guard redirected the user away from /login, and the ONLY escape was
2472// reaching the dashboard and pressing Log out — the one path that called
2473// clearSession() and dropped the hint. Users abandoned instead of finding it.
2474//
2475// Now: redirect only on a signal that cannot be a leftover; otherwise show the
2476// form immediately (never trap) and let the server settle it in the background.
2477function initLoginRedirect() {
2478  if (!/^\/login\/?$/.test(window.location.pathname)) return;
2479  // A local token or cookie can outlive a revoked server session. Redirecting
2480  // from those hints alone trapped users between /login and /workspace.
2481  if (!isAccountSessionValid() && getCsrfToken() === "") return;
2482  authApiJson("/auth/me")
2483    .then(() => { window.location.replace(getSafeNextPath()); })
2484    .catch((error) => {
2485      if (error.status === 401) {
2486        clearAccountSession(); clearSession(); setCsrfHint(""); applyHeaderAuthState(false);
2487      }
2488      // A network failure leaves the login form usable and keeps local state.
2489    });
2490}
2491
2492// Affiliate referral capture. If a landing URL carries ?ref=CODE, record the
2493// click server-side (the Worker sets a first-party referral cookie). Bounded +
2494// sanitized; an unknown/invalid code fails safe. No PII leaves the browser.
2495function captureReferral() {
2496  try {
2497    // Guard against the sitewide assets/ref-track.js double-firing the same click.
2498    if (window.__gexRefTracked) return;
2499    window.__gexRefTracked = true;
2500    const code = new URLSearchParams(window.location.search).get("ref");
2501    if (!code || !/^[A-Za-z0-9_-]{4,32}$/.test(code)) return;
2502    try {
2503      localStorage.setItem("gex_ref_code", code);
2504      localStorage.setItem("gex_ref_code_at", String(Date.now()));
2505    } catch (_) { /* storage blocked — cookie rail still works */ }
2506    const path = window.location.pathname || "/";
2507    const product = /library/.test(path) ? "education_library" : /indicator/.test(path) ? "indicator_monthly" : null;
2508    fetch(`${apiBase}/affiliate/track`, {
2509      method: "POST",
2510      credentials: "include",
2511      headers: { "Content-Type": "application/json", Accept: "application/json" },
2512      body: JSON.stringify({ code, landing_path: path.slice(0, 200), product_interest: product }),
2513    }).catch(() => {});
2514  } catch (_) { /* never break the page */ }
2515}
2516
2517// Remove every "start an Indicator subscription" CTA for a signed-in member who
2518// already has an active Indicator entitlement (a live trial counts) — the direct
2519// prevention of the double-subscription incident (2026-08-29). Runs on any page
2520// that carries such a CTA (/indicator, /pricing, …); anonymous visitors and
2521// members without an active sub keep the normal CTAs. Best-effort: any error
2522// leaves the page exactly as it was. The Library CTA (/checkout) is never touched.
2523async function gateIndicatorCtas() {
2524  const ctas = document.querySelectorAll('a[href^="/checkout/indicator"]');
2525  if (!ctas.length) return;
2526  if (!hasAuthSession() && !getSessionToken()) return; // anonymous → keep CTAs
2527  try {
2528    const me = await authApiJson("/auth/me");
2529    const ents = (me && Array.isArray(me.entitlements)) ? me.entitlements : [];
2530    const active = ents.some((e) => {
2531      const pid = e.productId || e.product_id || "";
2532      if (!["indicator_monthly", "indicator_yearly", "indicator_quarterly"].includes(pid)) return false;
2533      const st = String(e.accessStatus || e.access_status || "").toLowerCase();
2534      if (e.is_live !== true && st !== "active") return false;
2535      const x = e.expiresAt || e.expires_at || null;
2536      if (x) { const t = Date.parse(x); if (Number.isFinite(t) && t <= Date.now()) return false; }
2537      return true;
2538    });
2539    if (!active) return;
2540    ctas.forEach((a) => {
2541      const period = (a.getAttribute("href") || "").match(/indicator-(monthly|quarterly|yearly)/)?.[1];
2542      a.textContent = period ? "Review " + period + " billing" : "Manage your subscription";
2543      a.setAttribute("href", period ? "/billing?target=indicator_" + period + "#yearly-switch" : "/billing#yearly-switch");
2544      a.classList.add("cta-owned");
2545      a.removeAttribute("data-dc-tpl");
2546    });
2547  } catch (_) { /* leave the CTAs as-is on any failure */ }
2548}
2549
2550// One gift-key row: prefix · status/renewal/devices · Reveal & copy. Shared by
2551// the "Gift a key" card (Overview) and the "Gift keys you manage" block sitting
2552// under the personal key in Licence & devices, so a buyer can reveal + copy a
2553// gift key from either place. A revoked key drops the reveal button.
2554function guestKeyRowHtml(k) {
2555  const exp = k.expires_at ? new Date(k.expires_at).toLocaleDateString("en-US", { month: "short", day: "
2555numeric", year: "numeric" }) : "";
2556  const st = k.revoked_at ? "revoked" : String(k.status || "").toLowerCase();
2557  const lim = Number(k.device_limit || 2);
2558  const dev = (typeof k.device_active_count === "number") ? (k.device_active_count + " / " + lim + " device" + (lim > 1 ? "s" : "")) : "";
2559  const meta = (st === "active" ? (exp ? "renews " + exp : "active") : st) + (dev ? " · " + dev : "");
2560  return '<div class="rm-guest__row" data-guest-row="' + escapeHtml(k.id) + '">'
2561    + '<code class="rm-guest__pfx">' + escapeHtml(k.key_prefix || "") + '…</code>'
2562    + '<span class="rm-guest__meta">' + escapeHtml(meta) + '</span>'
2563    + (st !== "revoked" ? '<button class="rm-btn rm-btn--sm" type="button" data-guest-reveal="' + escapeHtml(k.id) + '">Reveal &amp; copy</button>' : '')
2564    + '<span class="rm-guest__key" data-guest-keyout hidden></span></div>';
2565}
2566function wireGuestReveals(root) {
2567  if (!root) return;
2568  root.querySelectorAll("[data-guest-reveal]").forEach((b) => {
2569    b.addEventListener("click", async () => {
2570      const id = b.getAttribute("data-guest-reveal");
2571      b.disabled = true; b.textContent = "Revealing…";
2572      try {
2573        const r = await authApiJson("/account/guest-keys", { method: "POST", body: JSON.stringify({ id }) });
2574        const out = b.parentNode.querySelector("[data-guest-keyout]");
2575        if (r && r.ok && r.key) {
2576          if (out) { out.hidden = false; out.textContent = r.key; }
2577          try { await navigator.clipboard.writeText(r.key); b.textContent = "Copied ✓"; }
2578          catch (_) { b.textContent = "Copy it above"; }
2579        } else { b.textContent = "Try again"; b.disabled = false; }
2580      } catch (_) { b.textContent = "Try again"; b.disabled = false; }
2581    });
2582  });
2583}
2584
2585// Gift / second-key card on the account dashboard. Lists the member's gift keys
2586// (with reveal-to-copy) and starts a discounted guest checkout. The card stays
2587// hidden unless the guest plan is wired (server `available`) or the member
2588// already owns gift keys — so nothing broken shows before Marc launches it.
2589async function setupGuestKeys() {
2590  const card = document.querySelector("[data-guest-card]");
2591  if (!card) return;
2592  const listEl = card.querySelector("[data-guest-keys]");
2593  const statusEl = card.querySelector("[data-guest-status]");
2594  const buyBtn = card.querySelector("[data-guest-buy]");
2595  const setStatus = (m) => { if (statusEl) statusEl.textContent = m || ""; };
2596
2597  async function loadKeys() {
2598    let res = null;
2599    try { res = await authApiJson("/account/guest-keys"); } catch (_) { res = null; }
2600    if (!res || !res.ok) return { available: false, keys: [] };
2601    return { available: !!res.available, keys: Array.isArray(res.keys) ? res.keys : [] };
2602  }
2603  function renderKeys(keys) {
2604    if (!listEl) return;
2605    if (!keys.length) { listEl.hidden = true; listEl.innerHTML = ""; return; }
2606    listEl.hidden = false;
2607    listEl.innerHTML = '<div class="rm-guest__lh">Keys you’ve bought to gift</div>'
2608      + keys.map(guestKeyRowHtml).join("");
2609    wireGuestReveals(listEl);
2610  }
2611
2612  const state = await loadKeys();
2613  if (!state.available && !state.keys.length) { card.hidden = true; return; }
2614  card.hidden = false;
2615  renderKeys(state.keys);
2616  if (!state.available && buyBtn) { buyBtn.disabled = true; buyBtn.textContent = "Opening soon"; }
2617  if (buyBtn && state.available) {
2618    buyBtn.addEventListener("click", async () => {
2619      buyBtn.disabled = true; setStatus("Opening secure checkout…");
2620      try {
2621        const r = await authApiJson("/checkout/guest-key", { method: "POST", body: "{}" });
2622        if (r && r.ok && r.checkout_url) { window.location.assign(r.checkout_url); return; }
2623        setStatus("Couldn’t open checkout — please retry.");
2624      } catch (err) {
2625        const code = (err && err.code) || "";
2626        setStatus(code === "checkout_not_configured" ? "This opens shortly — check back soon." : "Couldn’t open checkout — please retry.");
2627      }
2628      buyBtn.disabled = false;
2629    });
2630  }
2631}
2632
2633// Compliant checkout interstitial (/checkout). Requires an authenticated buyer
2634// so the purchase links to a credentialed account (no passwordless lockout).
2635// Collects the two L.221-28 digital-content consents, POSTs them to
2636// /checkout/consent (durable record on the Worker), then redirects to the Whop
2637// hosted checkout the Worker returns. While the boutique is dormant the Worker
2638// answers checkout_not_configured and we show a friendly "not open yet" notice.
2639function setupCheckoutPage() {
2640  // HERO-TRUST checkout controller. Drop-in replacement for the existing
2641  // setupCheckoutPage(); app.js still calls this when [data-checkout-form] exists.
2642  // Preserves every data-* hook. English copy. Zero layout shift on every state.
2643  const form = document.querySelector("[data-checkout-form]");
2644  if (!form) return;
2645  const anon = document.querySelector("[data-checkout-anon]");
2646
2647  // Whop fires this by name (data-whop-checkout-on-complete) when payment
2648  // succeeds in the embedded form. The webhook grants the entitlement with a
2649  // short delay — so flag the purchase (sessionStorage) BEFORE redirecting:
2650  // the dashboard reads the flag and shows "activating…" instead of the
2651  // "start your free trial" CTA that made buyers re-checkout and get charged
2652  // twice (double-subscription incident, 2026-08-29).
2653  window.gexCheckoutComplete = function () {
2654    var prod = form.getAttribute("data-checkout-product") || "education_library";
2655    try { sessionStorage.setItem("gex_purchase_pending", JSON.stringify({ product: prod, at: Date.now() })); } catch (_) {}
2656    try { window.location.assign("/account?purchase=" + encodeURIComponent(prod)); } catch (_) {}
2657  };
2658  // If Whop's loader/iframe never mounts (CSP/network), reveal a fallback note;
2659  // the pay button then routes to the hosted Whop checkout instead of the embed.
2660  const embedFallback = document.querySelector("[data-checkout-embed-fallback]");
2661  setTimeout(async () => {
2662    const mounted = !!document.querySelector("#whop-embedded-checkout iframe");
2663    if (!mounted) { if (embedFallback) embedFallback.hidden = false; return; }
2664    // Prefill the buyer's account email so the purchase links back to their
2665    // existing GEX Levels account (the webhook resolves the buyer by email).
2666    // Best-effort; the buyer can still change it in the form.
2667    try {
2668      const me = await authApiJson("/auth/me");
2669      const email = me && me.user && me.user.email;
2670      if (email && window.wco && typeof window.wco.setEmail === "function") {
2671        window.wco.setEmail("whop-embedded-checkout", email);
2672      }
2673    }
2673 catch (_) { /* prefill is optional */ }
2674  }, 4000);
2675
2676  // Auth gate: show the sign-in card OR the pay panel — never both, never empty.
2677  const loggedIn = hasAuthSession() || !!getSessionToken();
2678  if (!loggedIn) {
2679    if (anon) anon.hidden = false;
2680    form.hidden = true;
2681    return;
2682  }
2683  if (anon) anon.hidden = true;
2684  form.hidden = false;
2685
2686  const immediate = form.querySelector("[data-consent-immediate]");
2687  const waiver = form.querySelector("[data-consent-waiver]");
2688  const submit = form.querySelector("[data-checkout-submit]");
2689  const statusEl = form.querySelector("[data-checkout-error]");
2690  const product = form.getAttribute("data-checkout-product") || "education_library";
2691  const hostedOnly = product === "education_library";
2692  const embeddedOnly = product === "indicator_quarterly" || product.startsWith("terminal_");
2693  const submitLabel = submit ? submit.textContent : "";
2694
2695  // Already-subscribed guard (2026-08-29): if this signed-in account already
2696  // holds a live entitlement for this product family, replace the pay panel
2697  // with a clear "already covered" card — a second checkout would create a
2698  // second Whop membership that skips the trial and charges immediately. The
2699  // Worker enforces the same rule server-side (409 already_subscribed); this
2700  // is the friendly layer in front of it. Best-effort: on any error the page
2701  // behaves exactly as before.
2702  (async () => {
2703    try {
2704      const me = await authApiJson("/auth/me");
2705      const ents = (me && Array.isArray(me.entitlements)) ? me.entitlements : [];
2706      const fam = product === "education_library"
2707        ? ["education_library"]
2708        : (product.startsWith("terminal_")
2709          ? ["terminal_monthly", "terminal_quarterly", "terminal_yearly"]
2710          : ["indicator_monthly", "indicator_yearly", "indicator_quarterly"]);
2711      const live = ents.find((e) => {
2712        const pid = e.productId || e.product_id || "";
2713        if (!fam.includes(pid)) return false;
2714        const st = String(e.accessStatus || e.access_status || "").toLowerCase();
2715        if (e.is_live !== true && st !== "active") return false;
2716        const x = e.expiresAt || e.expires_at || null;
2717        if (x) { const t = Date.parse(x); if (Number.isFinite(t) && t <= Date.now()) return false; }
2718        return true;
2719      });
2720      if (!live) return;
2721      const x = live.expiresAt || live.expires_at || null;
2722      const until = x ? new Date(x).toLocaleDateString("en-US", { month: "short", day: "
2722numeric", year: "numeric" }) : null;
2723      if (product === "education_library") {
2724        form.innerHTML =
2725          '<div class="co-covered">'
2726          + '<h2>You already have Library access ✓</h2>'
2727          + '<p>This account already has access to the Education Library. A second checkout could charge you again, so it is disabled here.</p>'
2728          + '<p><a class="scp2 ds" href="/library">Open the Library</a></p>'
2729          + '<p class="muted">Something look wrong with your access? Contact <a href="/support">support</a> rather than purchasing again.</p>'
2730          + '</div>';
2731        return;
2732      }
2733      form.innerHTML =
2734        '<div class="co-covered">'
2735        + '<h2>You’re already covered ✓</h2>'
2736        + '<p>This account already has active access to this product'
2737        + (until ? " (current period runs to <b>" + escapeHtml(until) + "</b>)" : "")
2738        + ". Starting another checkout would create a <b>second, separately billed</b> subscription — so we’ve disabled it here.</p>"
2739        + '<p><a class="scp2 ds" href="/account">Go to your dashboard</a></p>'
2740        + '<p><a href="/billing?target=' + encodeURIComponent(product) + '#yearly-switch">Review this billing-period change</a>. Canceling renewal keeps your current access until its end date; it does not automatically switch your plan.</p>'
2741        + '<p class="muted">Something look wrong with your access? Contact support — don’t re-purchase.</p>'
2742        + "</div>";
2743    } catch (_) { /* guard is optional — never breaks the checkout page */ }
2744  })();
2745
2746  // The status line is ALWAYS in the DOM with a reserved min-height (CSS), so
2747  // writing to it never reflows the button below. state ∈ hint|error|busy|"".
2748  const setStatus = (msg, state) => {
2749    if (!statusEl) return;
2750    statusEl.textContent = msg || "";
2751    if (state) statusEl.setAttribute("data-state", state);
2752    else statusEl.removeAttribute("data-state");
2753  };
2754
2755  const bothChecked = () =>
2756    !!(immediate && immediate.checked && waiver && waiver.checked);
2757
2758  // Keep the button gated and show a quiet hint until both boxes are ticked.
2759  // The hint occupies the SAME reserved slot an error would — no movement.
2760  const syncSubmit = () => {
2761    const ready = bothChecked();
2762    if (submit) submit.disabled = !ready;
2763    if (ready) setStatus("", "");
2764    else setStatus("Tick both boxes to continue.", "hint");
2765  };
2766
2767  if (immediate) immediate.addEventListener("change", syncSubmit);
2768  if (waiver) waiver.addEventListener("change", syncSubmit);
2769  syncSubmit();
2770
2771  let busy = false;
2772  form.addEventListener("submit", async (e) => {
2773    e.preventDefault();
2774    if (busy) return;
2775    if (!bothChecked()) {
2776      setStatus("Tick both boxes to continue.", "hint");
2777      return;
2778    }
2779    busy = true;
2780    setStatus("Processing…", "busy");
2781    if (submit) {
2782      submit.disabled = true;
2783      submit.textContent = "Processing…";
2784    }
2785    try {
2786      if (embeddedOnly && (!document.querySelector("#whop-embedded-checkout iframe") ||
2787          !window.wco || typeof window.wco.submit !== "function")) {
2788        setStatus("The secure payment form is unavailable. Refresh this page and try again. No payment was submitted.", "error");
2789        return;
2790      }
2791      const res = await authApiJson("/checkout/consent", {
2792        method: "POST",
2793        body: JSON.stringify({
2794          product_id: product,
2795          consent_immediate_performance: true,
2796          consent_withdrawal_waiver: true,
2797        }),
2798      });
2799      if (res && res.ok) {
2800        if (hostedOnly) {
2801          const destination = new URL(String(res.checkout_url || ""));
2802          const path = destination.pathname.replace(/\/$/, "");
2803          if (destination.protocol !== "https:" || destination.hostname !== "whop.com" ||
2804              !["/gex-levels-8a99/gex-levels-education-library", "/checkout/plan_4c8tfHtqQ6Of4"].includes(path)) {
2805            throw new Error("checkout_not_configured");
2806          }
2807          // The Worker still returns the product page. Preserve its signed-in
2808          // buyer/affiliate metadata, but open the exact plan checkout where
2809          // Whop displays eligible card financing instead of the site embed.
2810          destination.pathname = "/checkout/plan_4c8tfHtqQ6Of4";
2811          window.location.assign(destination.toString());
2812          return;
2813        }
2814        // Consent recorded. Pay ON-SITE in the embedded Whop form when it
2815        // mounted; otherwise fall back to the hosted Whop checkout so the buy
2816        // flow never breaks (e.g. loader blocked).
2817        const wco = window.wco;
2818        const embedMounted = !!document.querySelector("#whop-embedded-checkout iframe");
2819        if (wco && typeof wco.submit === "function" && embedMounted) {
2820          try {
2821            await wco.submit("whop-embedded-checkout");
2822            // Payment proceeds inside the iframe; gexCheckoutComplete handles
2823            // success. Re-enable shortly so the buyer can retry if they cancel.
2824            setStatus("Complete your card details above to finish.", "busy");
2825            setTimeout(() => { busy = false; if (submit) { submit.textContent = submitLabel; submit.disabled = !bothChecked(); } }, 1500);
2826            return;
2827          } catch (_) { /* embedded-only products must stay on this page */ }
2828        }
2829        if (!embeddedOnly && res.checkout_url) {
2830          window.location.assign(res.checkout_url);
2831          return;
2832        }
2833      }
2834      setStatus("Couldn't reach checkout, please retry.", "error");
2835    } catch (err) {
2836      const code = (err && err.code) || "";
2837      if (code === "checkout_not_configured" || code === "http_503") {
2838        setStatus("The store is opening shortly — please try again soon.", "error");
2839      } else if (
2840        code === "missing_session" ||
2841        code === "session_invalid_or_expired" ||
2842        code === "session_expired" ||
2843        code === "http_401"
2844      ) {
2845        // Send the buyer BACK TO THE CHECKOUT THEY WERE ON — a hardcoded
2846        // /checkout here bounced Indicator buyers to the Library checkout
2847        // after login ("it won't let me pay", 2026-07-23).
2848        window.location.assign("/login?next=" + encodeURIComponent(window.location.pathname));
2849        return;
2850      } else if (code === "already_subscribed") {
2851        setStatus("You already have active access to this product — no charge was made. Manage it from your dashboard (/account).", "error");
2852      } else if (code === "consent_required") {
2853        setStatus("Both confirmations are required to continue.", "error");
2854      } else if (code === "network_unreachable") {
2855        setStatus("Couldn't reach checkout, please retry.", "error");
2856      } else {
2857        setStatus("Couldn't reach checkout, please retry.", "error");
2858      }
2859    } finally {
2860      busy = false;
2861      if (submit) {
2862        submit.textContent = submitLabel;
2863        submit.disabled = !bothChecked();
2864      }
2865    }
2866  });
2867}
2868
2869function setupPlanChange() {
2870  const target = document.querySelector('[data-plan-change-target]');
2871  if (!target) return;
2872  const preview = document.querySelector('[data-plan-change-preview]');
2873  const status = document.querySelector('[data-plan-change-status]');
2874  const review = document.querySelector('[data-plan-change-review]');
2875  const confirm = document.querySelector('[data-plan-change-confirm]');
2876  const open = document.querySelector('[data-plan-change-open]');
2877  const help = document.querySelector('[data-plan-change-help]');
2878  const checkout = document.querySelector('[data-plan-change-checkout]');
2879  const sync = document.querySelector('[data-plan-change-sync]');
2880  const requested = new URLSearchParams(location.search).get('target');
2881  if ([...target.options].some(o => o.value === requested)) target.value = requested;
2882  let generation = 0;
2883  let manageUrl = null;
2884  const reset = () => {
2885    generation++; manageUrl = null; open.hidden = true; open.removeAttribute('href');
2886    if (checkout) { checkout.hidden = true; checkout.removeAttribute('href'); }
2887    confirm.checked = false; review.hidden = true; preview.disabled = false;
2888    help.href = '/support?product=' + encodeURIComponent(target.value);
2889    status.textContent = 'Review your selection. No billing change has been made.';
2890  };
2891  target.addEventListener('change', reset);
2892  confirm.addEventListener('change', () => { open.hidden = !confirm.checked || !manageUrl; });
2893  if (sync) sync.addEventListener('click', async () => {
2894    reset(); const revision = generation; sync.disabled = true;
2895    status.textContent = 'Refreshing this account from Whop…';
2896    try {
2897      const r = await authApiJson('/account/sync-billing', { method: 'POST', body: '{}' });
2898      if (revision !== generation) return;
2899      if (!r?.ok || r.errors) throw new Error('billing_unavailable');
2900      status.textContent = r.throttled ? 'Please wait a moment before refreshing again.' : 'Billing status refreshed. Review your preferred plan again. No payment or subscription change has been made.';
2901    } catch (_) { if (revision === generation) status.textContent = 'Billing status could not be fully refreshed. Please retry or contact support. No payment has been made.'; }
2902    finally { sync.disabled = false; }
2903  });
2904  preview.addEventListener('click', async () => {
2905    reset(); const revision = generation; preview.disabled = true;
2906    status.textContent = 'Checking your existing membership with Whop…';
2907    try {
2908      const r = await authApiJson('/account/plan-change?target=' + encodeURIComponent(target.value));
2909      if (revision !== generation) return;
2910      if (!r?.ok || r.changed !== false) throw new Error('billing_unavailable');
2911      if (r.mode === 'new_subscription') {
2912        const expected = '/checkout/' + target.value.replace('_', '-');
2913        if (!checkout || r.target_product !== target.value || r.checkout_url !== expected) throw new Error('invalid_link');
2914        status.textContent = 'Your previous membership has ended. This is a new subscription, not a billing-period switch. Review the price and any trial eligibility at checkout. No payment has been made; gifted days are not automatically added again.';
2915        checkout.href = expected; checkout.hidden = false;
2916        return;
2917      }
2918      const url = new URL(r.manage_url);
2919      if (url.origin !== 'https://whop.com' || url.username || url.password || url.search || url.hash || !/^\/billing\/manage\/mem_[a-zA-Z0-9]+\/?$/.test(url.pathname)) throw new Error('invalid_link');
2920      const end = new Date(r.current_period_end).toLocaleDateString('en-GB', {day:'numeric',month:'long',year:'numeric'});
2921      status.textContent = (r.already_on_target ? 'Whop still links this membership to the selected plan. ' : 'Your plan has not changed. Requested: $' + r.amount_usd + ' every ' + r.billing_period_days + ' days. ')
2922        + (r.membership_status === 'canceled' || r.membership_status === 'expired'
2923          ? 'Whop reports this membership ended but still lists a period through ' + end + '; access and a plan switch must be confirmed with support before another purchase. '
2924          : 'Current billing period runs until ' + end + '. ')
2925        + (r.cancel_at_period_end ? 'Renewal is canceled; this alone does not start a new plan. ' : '')
2926        + (r.payment_collection_paused ? 'Payment collection is paused. Contact support before resuming billing. ' : '')
2927        + (r.membership_status === 'past_due' ? 'A payment is overdue. Review the outstanding payment in Whop first. ' : '')
2928        + 'This opens your verified membership management in Whop. If that page offers a change of billing period, review its final price and date there; if it does not, contact support. Never buy a second subscription just to switch.';
2929      manageUrl = url.toString(); open.href = manageUrl; review.hidden = false;
2930    } catch (err) {
2931      if (revision !== generation) return;
2932      const code = err?.code || err?.message || '';
2933      const billingErrors = {
2934        billing_unavailable: 'Whop billing could not be reached. Please retry in a moment.',
2935        network_unreachable: 'The billing service could not be reached. Check your connection and retry.',
2936        plan_unavailable: 'This billing plan is currently unavailable. Contact support.',
2937        membership_plan_unrecognized: 'Your linked Whop plan could not be matched to the selected product. Contact support to check the link.',
2938        membership_period_unavailable: 'Your membership was found, but its current period could not be verified. Contact support.',
2939        membership_inactive: 'Whop no longer confirms a current membership. Use Refresh billing from Whop below, then review the plan again.',
2940        membership_review_required: 'The membership links could not be resolved safely. Refresh billing from Whop, then contact support if this continues.',
2941        manage_link_unavailable: 'Your membership was found, but its management link could not be verified. Contact support.',
2942        invalid_link: 'The membership management link could not be verified. Contact support.'
2943      };
2944      status.textContent = /session|401/.test(code) ? 'Sign in to review your subscription. No change has been made.'
2945        : code === 'no_active_membership' ? 'No active Whop membership for this product is linked to this account. Admin-granted access has no Whop billing period to change. Your access is unchanged; contact support if you already pay through Whop.'
2946        : Object.hasOwn(billingErrors, code) ? billingErrors[code] + ' No charge or change has been made. Do not buy a second subscription.'
2947        : 'We could not safely verify a single active membership. No charge or change has been made. Contact support using the button below; do not buy a second subscription.';
2948    } finally { if (revision === generation) preview.disabled = false; }
2949  });
2950  reset();
2951}
2952
2953document.addEventListener("DOMContentLoaded", () => {
2954  setupPlanChange();
2955  captureReferral();
2956  initAccountPage();
2957  initLoginRedirect();
2958  setText("[data-api-base]", apiBase);
2959  setText("[data-app-env]", publicEnv.appEnv || "development");
2960  setText("[data-site-url]", siteUrl);
2961
2962  // B12: Update nav links based on current auth state.
2963  updateNavForAuthState();
2964
2965  const healthButton = document.querySelector("[data-check-health]");
2966  if (healthButton) {
2967    healthButton.addEventListener("click", checkApiHealth);
2968  }
2969
2970  // Legacy beta license form (existing /session/create flow — unchanged).
2971  const licenseForm = document.querySelector("[data-license-form]");
2972  if (licenseForm) {
2973    licenseForm.addEventListener("submit", createPortalSession);
2974  }
2975
2976  // B8: Load account dashboard button (handles both cookie and Bearer sessions).
2977  const loadAccountButton = document.querySelector("[data-load-account]");
2978  if (loadAccountButton) {
2979    loadAccountButton.addEventListener("click", loadAccountDashboard);
2980  }
2981
2982  // Human-readable guidance for the key rotate/reveal flow (2026-08-28). A
2983  // support case ("the steps weren't popping up") traced to a bare "Error: …"
2984  // when a session lapsed mid-flow — this turns the raw code into a clear next
2985  // step so the customer never gets stuck.
2986  function describeKeyFlowError(err) {
2987    const code = String((err && err.code) || "").toLowerCase();
2988    const msg = String((err && err.message) || "").toLowerCase();
2989    if (code === "network_unreachable") return "Can't reach the service — check your connection and try again.";
2990    if (/401|unauth|session|expired|forbidden|csrf/.test(code) || /session|expired|sign in/.test(msg)) {
2991      return "Your session has expired — please refresh the page and sign in again, then retry. Your key is safe.";
2992    }
2993    if (code === "endpoint_unavailable") return "The key service is briefly unavailable. Try again in a moment.";
2994    return (err && err.message) ? err.message : "Something went wrong — please try again.";
2995  }
2996
2997  const rotateBtn = document.querySelector("[data-license-rotate]");
2998  if (rotateBtn) {
2999    rotateBtn.addEventListener("click", async () => {
3000      const out = document.querySelector("[data-license-rotate-output]");
3001      const show = (msg) => { if (out) { out.textContent = msg; out.removeAttribute("hidden"); } };
3002      if (!confirm("This will generate a new key and deactivate your current key on all devices. Continue?")) return;
3003      try {
3004        rotateBtn.disabled = true;
3005        show("Loading your licence…");
3006        const lics = await authApiJson("/account/licenses");
3007        const active = pickPrimaryLicense(lics.licenses);
3008        if (!active) { show("No active licence on your account yet. If you just purchased, wait a moment and refresh — if it persists, contact support and we'll sort it out."); rotateBtn.disabled = false; return; }
3009        show("Generating new key…");
3010        const res = await authApiJson(`/account/licenses/${active.id}/rotate`, { method: "POST" });
3011        if (!res.ok) throw new Error(res.error || "rotate_failed");
3012        show(`Your new key (copy it now — shown once):\n\n${res.raw_key_shown_once}`);
3013        const copyRow = document.querySelector("[data-account-license-copy]");
3014        if (copyRow) {
3015          // Keep the FULL key in the copy row so the Copy button hands the user the
3016          // real, working key (previously this was immediately clobbered by the
3017          // prefix, so "Copy" gave a non-functional value). Flag it as the full key.
3018          copyRow.textContent = res.raw_key_shown_once;
3019          copyRow.dataset.fullKey = "true";
3020          const rowWrap = copyRow.closest(".rm-keyrow") || copyRow.closest(".copy-row");
3021          if (rowWrap) { rowWrap.style.display = ""; }
3022        }
3023        // The masked summary line still shows only the prefix (never the full key).
3024        setText("[data-account-license]", res.key_prefix || "-");
3025      } catch (err) {
3026        show(describeKeyFlowError(err));
3027      } finally {
3028        rotateBtn.disabled = false;
3029      }
3030    });
3031  }
3032
3033  // Reveal + copy the FULL key (2026-08-20). The key is hashed at rest for auth,
3034  // but we also keep an AES-GCM ciphertext so the account page can hand the user
3035  // their real key on demand. Toggles: Reveal → fetch + show full key (Copy then
3036  // hands out the working key), Hide → re-mask back to the prefix.
3037  const terminalReveal = document.querySelector("[data-terminal-license-reveal]");
3038  if (terminalReveal) terminalReveal.addEventListener("click", async () => {
3039    const code = document.querySelector("[data-terminal-license-copy]");
3040    const show = (message) => setText("[data-terminal-license-output]", message);
3041    if (terminalReveal.getAttribute("aria-pressed") === "true") {
3042      if (code) { code.textContent = document.querySelector("[data-terminal-license-key]")?.textContent || "—"; delete code.dataset.fullKey; }
3043      terminalReveal.textContent = "Reveal Terminal key";
3044      terminalReveal.setAttribute("aria-pressed", "false");
3045      show("");
3046      return;
3047    }
3048    terminalReveal.disabled = true;
3049    show("Loading your Terminal key…");
3050    try {
3051      const list = await authApiJson("/account/licenses");
3052      const license = pickPrimaryLicense(list.licenses, "terminal");
3053      if (!license) { show("No Terminal key found. An Indicator key cannot unlock the Terminal. Contact support if you already have access."); return; }
3054      const result = await authApiJson(`/account/licenses/${license.id}/reveal`);
3055      if (!result?.available || !result.license_key) { show("This key cannot be revealed. Contact support to recover Terminal access; do not replace your Indicator key."); return; }
3056      if (code) { code.textContent = result.license_key; code.dataset.fullKey = "true"; }
3057      terminalReveal.textContent = "Hide Terminal key";
3058      terminalReveal.setAttribute("aria-pressed", "true");
3059      show("Use Copy, then paste this key into the desktop Terminal. Keep it private.");
3060    } catch (err) { show(describeKeyFlowError(err)); }
3061    finally { terminalReveal.disabled = false; }
3062  });
3063
3064  const revealBtn = document.querySelector("[data-license-reveal]");
3065  if (revealBtn) {
3066    const copyRow = document.querySelector("[data-account-license-copy]");
3067    const out = document.querySelector("[data-license-reveal-output]");
3068    const show = (msg) => { if (out) { out.textContent = msg; out.removeAttribute("hidden"); } };
3069    const hide = () => { if (out) { out.textContent = ""; out.setAttribute("hidden", ""); } };
3070    const reMask = () => {
3071      const masked = (document.querySelector("[data-account-license]")?.textContent || "-").trim();
3072      setMaskedKeyCopy(masked);
3073      revealBtn.textContent = "Reveal key";
3074      revealBtn.setAttribute("aria-pressed", "false");
3075      hide();
3076    };
3077    revealBtn.addEventListener("click", async () => {
3078      // Already revealed → hide.
3079      if (revealBtn.getAttribute("aria-pressed") === "true") { reMask(); return; }
3080      try {
3081        revealBtn.disabled = true;
3082        show("Loading your key…");
3083        const lics = await authApiJson("/account/licenses");
3084        const active = pickPrimaryLicense(lics.licenses);
3085        if (!active) { show("No licence found on your account."); return; }
3086        const res = await authApiJson(`/account/licenses/${active.id}/reveal`);
3087        if (res && res.available && res.license_key) {
3088          if (copyRow) {
3089            copyRow.textContent = res.license_key;
3090            copyRow.dataset.fullKey = "true";
3091            const rowWrap = copyRow.closest(".rm-keyrow") || copyRow.closest(".copy-row");
3092            if (rowWrap) rowWrap.style.display = "";
3093          }
3094          revealBtn.textContent = "Hide";
3095          revealBtn.setAttribute("aria-pressed", "true");
3096          show("Key revealed — the Copy button now hands you the full working key.");
3097        } else {
3098          // Ciphertext missing (key minted before at-rest reveal existed).
3099          show("This key predates the reveal feature. Click “Get new key” once to enable reveal + copy — your extension will need the new key.");
3100        }
3101      } catch (err) {
3102        show(describeKeyFlowError(err));
3103      } finally {
3104        revealBtn.disabled = false;
3105      }
3106    });
3107  }
3108
3109  const loadLevelsButton = document.querySelector("[data-load-levels]");
3110  if (loadLevelsButton) {
3111    loadLevelsButton.addEventListener("click", loadLatestLevels);
3112  }
3113
3114  const refreshButton = document.querySelector("[data-refresh-session]");
3115  if (refreshButton) {
3116    refreshButton.addEventListener("click", refreshPortalSession);
3117  }
3118
3119  // B5/B12: All logout buttons handle both cookie and Bearer sessions.
3120  document.querySelectorAll("[data-logout]").forEach((btn) => {
3121    btn.addEventListener("click", logoutPortal);
3122  });
3123
3124  const requestAccessForm = document.querySelector("[data-request-access-form]");
3125  if (requestAccessForm) {
3126    requestAccessForm.addEventListener("submit", submitRequestAccess);
3127    requestAccessForm.querySelector("select[name='product_requested']")?.addEventListener("change", updateRequestAccessNdaRequirement);
3128    preselectRequestedProduct(requestAccessForm);
3129    updateRequestAccessNdaRequirement();
3130  }
3131
3132  // B4/B5: Auth form (signup and login pages).
3133  initTwofaChallenge();
3134  document.querySelectorAll("[data-auth-form]").forEach((form) => {
3135    form.addEventListener("submit", handleAuthForm);
3136  });
3137
3138  document.querySelectorAll("[data-toggle-password]").forEach((button) => {
3139    button.addEventListener("click", togglePasswordVisibility);
3140  });
3141
3142  document.querySelectorAll("[data-copy-target]").forEach((button) => {
3143    button.addEventListener("click", copyMaskedValue);
3144  });
3145
3146  // B13: Library page — filter/search + optional entitlement notice.
3147  setupLibraryExplorer();
3148  if (document.querySelector("[data-library-page]")) {
3149    loadEntitlements();
3150    // For an authorized member, turn /library into the Library application
3151    // (hide marketing, show quick-access-by-module + the reader).
3152    setupLibraryAppExperience();
3153  }
3154
3155  // B6: Email verification page.
3156  if (document.getElementById("verify-section") || document.querySelector("[data-email-verify-resend-form]")) {
3157    handleEmailVerifyPage();
3158  }
3159
3160  // B7: Password reset page.
3161  if (document.getElementById("confirm-section") || document.getElementById("request-section")) {
3162    handlePasswordResetPage();
3163  }
3164
3165  // Compliant checkout interstitial (/checkout): L.221-28 consent gate.
3166  if (document.querySelector("[data-checkout-form]")) {
3167    setupCheckoutPage();
3168  }
3169
3170  // Remove "start Indicator" CTAs for members who already have an active sub/trial.
3171  gateIndicatorCtas();
3172
3173  // B9: Auto-load sessions table if present and user has a cookie session.
3174  if (document.querySelector("[data-account-sessions]") && hasAuthSession()) {
3175    authApiJson("/account/sessions")
3176      .then((res) => renderAuthSessions(res, "[data-account-sessions]"))
3177      .catch(() => {});
3178  }
3179
3180  // B10: Auto-load entitlements if target present and cookie session active.
3181  if (document.querySelector("[data-entitlements]") && hasAuthSession()) {
3182    loadEntitlements();
3183  }
3184
3185  // Gift / second-key card — reveal + wire only when the guest plan is available
3186  // (or the member already bought gift keys).
3187  if (document.querySelector("[data-guest-card]") && (hasAuthSession() || getSessionToken())) {
3188    setupGuestKeys();
3189  }
3190
3191  // Honest, NDA-aware Library access state (account / dashboard / library pages).
3192  if (document.querySelector("[data-library-status]") ||
3193      document.querySelector("[data-library-status-text]") ||
3194      document.querySelector("[data-library-access-notice]")) {
3195    loadLibraryState();
3196  }
3197
3198  // NDA: versioned consent section (account page, Education Library).
3199  if (document.querySelector("[data-nda-section]")) {
3200    setupNdaSection();
3201  }
3202
3203  // NDA: full-document review-and-sign page (/nda-sign).
3204  if (document.querySelector("[data-nda-paper]")) {
3205    setupNdaSignPage();
3206  }
3207
3208  // Turnstile: render on any page that carries a [data-turnstile] gate
3209  // (signup, request-access, password-reset request). No-op elsewhere.
3210  if (document.querySelector("[data-turnstile]")) {
3211    initTurnstile();
3212  }
3213});
3214
3215// ─── PWA registration (v2026-07-26) ─────────────────────────────────────────
3216// Registering from any page puts the worker in control of the whole origin, so
3217// app.js is enough — no need to touch every template. Guarded and silent: if
3218// service workers are unavailable or registration fails, the site behaves
3219// exactly as before. See sw.js for the caching rules (API is never cached).
3220(function registerGexServiceWorker() {
3221  if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
3222  if (location.protocol !== "https:" && location.hostname !== "localhost") return;
3223  window.addEventListener("load", function () {
3224    navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(function () {
3225      /* PWA is an enhancement; never surface a failure to the user. */
3226    });
3227  });
3228})();
3229
3230
3231// ---------------------------------------------------------------------------
3232// Terms re-acceptance modal (2026-09-10).
3233//
3234// Whop collects acceptance of the terms at checkout, which covers a NEW
3235// purchase. It does not re-ask an existing subscriber when a published policy
3236// changes, and Terms 2.1 tightened the payment-dispute section materially. This
3237// asks once, records the acceptance server-side (POST /account/policy-consent,
3238// which stores the version the SERVER publishes, never one sent from here), and
3239// never shows again for that version.
3240//
3241// Every style is applied through the CSSOM, not a stylesheet. A versioned CSS
3242// file whose hash does not change when its content does gets served from cache,
3243// and the dialog then renders as unstyled text at the foot of the page — which
3244// is exactly what happened on the first attempt. CSSOM is not blocked by the
3245// CSP; only style="" in markup is.
3246//
3247// Fails quiet by design: signed out, offline, or the endpoint unreachable → no
3248// dialog. Nagging a logged-out visitor, or blocking the site on a database
3249// blip, would cost more than the reminder is worth.
3250(function () {
3251  "use strict";
3252  if (typeof authApiJson !== "function") return;
3253  var KEY = "terms";
3254
3255  function css(el, o) {
3256    for (var k in o) { if (Object.prototype.hasOwnProperty.call(o, k)) el.style[k] = o[k]; }
3257  }
3258
3259  function render(version) {
3260    var overlay = document.createElement("div");
3261    overlay.setAttribute("role", "dialog");
3262    overlay.setAttribute("aria-modal", "true");
3263    overlay.setAttribute("aria-label", "Terms of Service update");
3264    css(overlay, {
3265      position: "fixed", inset: "0", zIndex: "2147483000",
3266      display: "flex", alignItems: "center", justifyContent: "center",
3267      padding: "20px", background: "rgba(6,7,10,.72)",
3268      backdropFilter: "blur(3px)", webkitBackdropFilter: "blur(3px)",
3269      font: "15px/1.6 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
3270    });
3271
3272    var card = document.createElement("div");
3273    css(card, {
3274      width: "100%", maxWidth: "480px", boxSizing: "border-box",
3275      background: "#15161b", color: "#e9e5dc",
3276      border: "1px solid #2f2a22", borderRadius: "14px",
3277      padding: "30px 30px 26px",
3278      boxShadow: "0 30px 70px -24px rgba(0,0,0,.9)",
3279      textAlign: "left"
3280    });
3281
3282    var eyebrow = document.createElement("p");
3283    eyebrow.textContent = "Terms of Service · version " + version;
3284    css(eyebrow, {
3285      margin: "0 0 12px", fontSize: "11px", fontWeight: "600",
3286      letterSpacing: ".14em", textTransform: "uppercase", color: "#c8a878"
3287    });
3288
3289    var h = document.createElement("h2");
3290    h.textContent = "We have updated our Terms.";
3291    css(h, { margin: "0 0 14px", fontSize: "23px", lineHeight: "1.25", fontWeight: "600", color: "#fbf8f2" });
3292
3293    var p1 = document.createElement("p");
3294    p1.textContent = "The section on payment disputes and chargebacks has changed. If a charge looks wrong, contact us first — opening a dispute with your bank without contacting us now ends the account.";
3295    css(p1, { margin: "0 0 16px", color: "#bdb5a8" });
3296
3297    var p2 = document.createElement("p");
3298    css(p2, { margin: "0 0 22px", color: "#bdb5a8" });
3299    p2.appendChild(document.createTextNode("Your statutory rights as a consumer are unchanged. "));
3300    var link = document.createElement("a");
3301    link.href = "/terms"; link.target = "_blank"; link.rel = "noopener";
3302    link.textContent = "Read the full Terms";
3303    css(link, { color: "#d8b26a", textDecoration: "underline", textUnderlineOffset: "2px" });
3304    p2.appendChild(link);
3305    p2.appendChild(document.createTextNode("."));
3306
3307    var row = document.createElement("div");
3308    css(row, { display: "flex", flexWrap: "wrap", gap: "12px", alignItems: "center" });
3309
3310    var btn = document.createElement("button");
3311    btn.type = "button";
3312    btn.textContent = "I accept";
3313    css(btn, {
3314      cursor: "pointer", border: "0", borderRadius: "9px",
3315      padding: "11px 24px", font: "inherit", fontWeight: "600",
3316      background: "#d8b26a", color: "#1a1710"
3317    });
3318    btn.addEventListener("mouseenter", function () { btn.style.background = "#e6c684"; });
3319    btn.addEventListener("mouseleave", function () { btn.style.background = "#d8b26a"; });
3320
3321    var st = document.createElement("span");
3322    css(st, { fontSize: "13px", color: "#a29a8d" });
3323
3324    // Someone who disagrees must have a way out that is not "click accept".
3325    var out = document.createElement("p");
3326    css(out, { margin: "18px 0 0", fontSize: "12.5px", color: "#8d8579" });
3327    out.textContent = "These terms apply to your existing subscription from 10 October 2026. If you do not agree, you can cancel before then from your Whop account.";
3328
3329    btn.addEventListener("click", function () {
3330      btn.disabled = true;
3331      btn.style.opacity = ".6";
3332      btn.style.cursor = "default";
3333      st.textContent = "Saving…";
3334      authApiJson("/account/policy-consent", {
3335        method: "POST",
3336        body: JSON.stringify({ policy_key: KEY })
3337      }).then(function () {
3338        if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
3339      }).catch(function () {
3340        btn.disabled = false;
3341        btn.style.opacity = "1";
3342        btn.style.cursor = "pointer";
3343        st.textContent = "Could not save — please try again.";
3344      });
3345    });
3346
3347    row.appendChild(btn);
3348    row.appendChild(st);
3349    card.appendChild(eyebrow);
3350    card.appendChild(h);
3351    card.appendChild(p1);
3352    card.appendChild(p2);
3353    card.appendChild(row);
3354    card.appendChild(out);
3355    overlay.appendChild(card);
3356    document.body.appendChild(overlay);
3357    try { btn.focus(); } catch (_) {}
3358  }
3359
3360  function start() {
3361    if (!document.body) return;
3362    try {
3363      authApiJson("/account/policy-consent?key=" + KEY).then(function (r) {
3364        if (r && r.ok && r.accepted === false && r.current_version) render(r.current_version);
3365      }).catch(function () { /* signed out or unreachable: stay silent */ });
3366    } catch (_) { /* never break a page over a dialog */ }
3367  }
3368
3369  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
3370  else start();
3371})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.