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("&", "&") 1059 .replaceAll("<", "<") 1060 .replaceAll(">", ">") 1061 .replaceAll('"', """) 1062 .replaceAll("'", "'"); 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 & 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.