1/** 2 * Main application entry point 3 */ 4 5// Global error handlers â catch uncaught exceptions and promise rejections 6window.onerror = function(message, source, lineno, colno, error) { 7 console.error('[QuantCalc] Uncaught error:', message, 'at', source, lineno, colno); 8 if (typeof showError === 'function') { 9 showError('An unexpected error occurred. Please refresh the page.'); 10 } 11 return false; 12}; 13 14window.addEventListener('unhandledrejection', function(event) { 15 console.error('[QuantCalc] Unhandled promise rejection:', event.reason); 16 if (typeof showError === 'function') { 17 const msg = event.reason && event.reason.message 18 ? event.reason.message 19 : 'An unexpected error occurred'; 20 showError(msg); 21 } 22}); 23 24document.addEventListener('DOMContentLoaded', async () => { 25 // Captured BEFORE initLicense(), which strips ?key=/?license= from the URL. 26 // A license-activation load must never be overlaid with the example 27 // scenario (the router already sends key-holders to classic â this is 28 // belt-and-braces for direct /app.html?key= hits). 29 const hadActivationKey = /[?&](key|license)=/.test(window.location.search); 30 31 try { 32 // Determine which API to use 33 activeAPI = await getAPIClient(); 34 35 // Initialize license from localStorage 36 initLicense(); 37 38 // Initialize daily usage tracking (syncs localStorage counter with UI) 39 if (typeof initDailyUsage === 'function') initDailyUsage(); 40 41 // Restore any previously-entered inputs so a refresh (post-error or 42 // otherwise) doesn't wipe the user's work. Must run BEFORE initInputs, 43 // which populates DOM fields from AppState.inputs. 44 if (typeof AppState !== 'undefined' && typeof AppState.loadInput
44sFromStorage === 'function') { 45 AppState.loadInputsFromStorage(); 46 } 47 48 // Deep-link prefill: a CWS calculator extension can hand off the user's 49 // numbers via ?seed_<key>=... so they land here mid-analysis instead of 50 // a blank form. Runs after storage restore (seeds win) and before 51 // initInputs (which paints AppState.inputs into the DOM). 52 seedInputsFromUrl(); 53 54 // Saved-plan magic link: ?plan=<token> restores a plan the user saved 55 // earlier (the "something to lose" feature). Async (KV fetch), awaited 56 // here so the restored inputs are in AppState before initInputs paints 57 // them into the DOM. A plan link wins over seeds + storage. 58 const planRestored = await restorePlanFromUrl(); 59 60 // Initialize all components 61 initInputs(); 62 initTaxInputs(); 63 if (typeof initAdvancedModeling === 'function') initAdvancedModeling(); 64 initCMETab(); 65 initOptimizer(); 66 67 // Set up simulation button. 68 // IMPORTANT: wire the core interaction handlers BEFORE initCharts(). 69 // Chart.js loads from a CDN; if that CDN is blocked (corporate 70 // firewall / AV â the single most common support class for this app), 71 // initCharts() throws "Chart is not defined". If that ran first and 72 // inside the shared try/catch, the whole init aborted and the Run 73 // button was never wired â the calculator was 100% dead. Wiring Run 74 // first + isolating chart init means numbers still work when charts 75 // can't render. 76 const runBtn = document.getElementById('runSimulation'); 77 if (runBtn) { 78 runBtn.addEventListener('click', runSimulation); 79 } 80 81 // Dead-click guard: both Run buttons ship `disabled` in the markup 82 // because on slow loads they paint (and accept clicks) long before 83 // this handler is wired â the first click silently did nothing. 84 // Enable them only now that the click handler above is attached. 85 // If init throws before this point they stay disabled: a visibly 86 // dead button beats one that looks alive and does nothing. 87 const runBtnMobile = document.getElementById('runSimMobile'); 88 [runBtn, runBtnMobile].forEach((btn) => { 89 if (btn) { 90 btn.disabled = false; 91 btn.removeAttribute('aria-busy'); 92 } 93 }); 94 95 // Set up simulation count dropdown with tier check 96 const simSelect = document.getElementById('numSimulations'); 97 if (simSelect) { 98 simSelect.addEventListener('change', handleSimCountChange); 99 } 100 101 // Charts are progressive enhancement â never let a chart-init failure 102 // (e.g. blocked Chart.js CDN) take down the calculator. 103 try { 104 initCharts(); 105 } catch (chartErr) { 106 console.error('Chart init failed (calculator still works, charts disabled):', chartErr); 107 } 108 109 // Daily limit modal: close on background click or Escape 110 const dailyModal = document.getElementById('dailyLimitModal'); 111 if (dailyModal) { 112 dailyModal.addEventListener('click', (e) => { 113 if (e.target === dailyModal) hideDailyLimitModal(); 114 }); 115 } 116 117 // Daily-limit lead capture (option A). Single submit handler attached 118 // once at init; idempotent on the per-session "already captured" flag 119 // so reopening the modal doesn't ask again. 120 initDailyLimitLeadCapture(); 121 initDailyLimitWall(); 122 initProGateOffer(); 123 initUpgradeLeadCapture(); 124 if (typeof initSavePlan === 'function') initSavePlan(); 125 document.addEventListener('keydown', (e) => { 126 if (e.key === 'Escape' && typeof hideDailyLimitModal === 'function') { 127 hideDailyLimitModal(); 128 } 129 }); 130 131 // Don't auto-run on load â let user click "Run Simulation" 132 // This prevents burning rate limit on every page visit (especially mobile) 133 showLoading(false); 134
135 // EXCEPTION: a saved-plan magic link (?plan=) means the user explicitly 136 // asked to see their plan â auto-run AND scroll to the results so they 137 // land on their plan, not a long pre-filled form (critical on mobile, 138 // where inputs + results are stacked vertically). 139 if (planRestored && typeof runSimulation === 'function') { 140 window.__qcScrollToResultsOnce = true; 141 runSimulation(); 142 } 143 144 // First-paint pre-baked render: show the committed example scenario 145 // instead of a dead "--%" empty state. Renders via updateResults() 146 // directly (never runSimulation()), so it costs no API call, no daily 147 // quota, and fires no simulation analytics. Fire-and-forget â any 148 // failure silently keeps today's empty state and never blocks init. 149 renderDefaultScenario({ 150 planRestored: planRestored, 151 activationInProgress: hadActivationKey 152 }).catch((e) => { 153 console.warn('[QuantCalc] default scenario skipped:', e && e.message); 154 }); 155 156 // Initialize PRO lock overlays for free users 157 initProLockOverlays(); 158 if (typeof initWhatIf === 'function') initWhatIf(); 159 160 // Silent preflight: detect networks that block the API before the user 161 // clicks Run. Surfaces a banner with a link to the self-test page. 162 runPreflightCheck(); 163 164 checkJustPurchased(); 165 166 } catch (error) { 167 console.error('Initialization error:', error); 168 if (typeof showError === 'function') { 169 showError('Failed to initialize: ' + error.message); 170 } 171 } 172}); 173 174/** 175 * Deep-link prefill from CWS extensions. 176 * 177 * Reads whitelisted `seed_<key>` query params and writes coerced/validated 178 * values into AppState.inputs. Keys map 1:1 to canonical AppState.inputs keys 179 * (plus the five allocation classes). Anything unknown or malformed is ignored 180 * â this is untrusted URL input, so every value is bounds-checked and the whole 181 * thing is wrapped so a bad param can never block app init. 182 */ 183function seedInputsFromUrl() { 184 try { 185 const params = new URLSearchParams(window.location.search); 186 if (typeof AppState === 'undefined' || !AppState.inputs) return; 187 188 // key -> 'int' | 'num' (non-negative numeric inputs) 189 const NUMERIC = { 190 currentAge: 'int', retirementAge: 'int', lifeExpectancy: 'int', 191 currentSavings: 'num', monthlyContribution: 'num', annualSpending: 'num', 192 socialSecurity: 'num', ssStartAge: 'int', inflationRate: 'num', 193 pension: 'num', pensionStartAge: 'int', 194 traditionalBalance: 'num', rothBalance: 'num', taxableBalance: 'num', 195 costBasisRatio: 'num', medicareStartAge: 'int', spouseCurrentAge: 'int', 196 annualQcd: 'num', 197 // Household income: a two-person plan seeded without the spouse's 198 // Social Security is a materially different plan from the one the 199 // link claims to open (added 2026-09-13 for the /advisors/ worked 200 // example, which is a couple). 201 spouseSocialSecurity: 'num', spouseSsStartAge: 'int' 202 }; 203 const ALLOC_KEYS = ['usStocks', 'intlStocks', 'bonds', 'realEstate', 'cash']; 204 const FILING = ['single', 'mfj', 'mfs', 'hoh']; 205 const WITHDRAWAL = ['fixed-real', 'guyton-klinger', 'vpw', 'bucket']; 206 207 // Bare (un-prefixed) params are accepted as aliases for the same 208 // whitelist: LLMs and humans hand-compose links like 209 // ?currentAge=70&endAge=110 (seen live 2026-07-27; nothing in this repo 210 // emits that form). seed_ wins over bare on conflict. BARE_ALIASES maps 211 // foreign names onto canonical keys. Anything outside the whitelist 212 // (key, plan, product, qc_internal, utm_*) is never read or scrubbed. 213 const BARE_ALIASES = { endAge: 'lifeExpectancy' }; 214 const readSeedParam = (key) => { 215 if (params.has('seed_' + key)) return params.get('seed_' + key); 216 if (params.has(key)) return params.get(key);
217 for (const alias in BARE_ALIASES) { 218 if (BARE_ALIASES[alias] === key && params.has(alias)) return params.get(alias); 219 } 220 return null; 221 }; 222 223 let applied = false; 224 225 Object.keys(NUMERIC).forEach(key => { 226 const raw = readSeedParam(key); 227 if (raw === null) return; 228 const n = parseFloat(raw); 229 if (!isFinite(n) || n < 0) return; 230 AppState.inputs[key] = (NUMERIC[key] === 'int') ? Math.round(n) : n; 231 applied = true; 232 }); 233 234 { 235 const fsRaw = readSeedParam('filingStatus'); 236 if (fsRaw !== null) { 237 const fs = fsRaw.toLowerCase(); 238 if (FILING.indexOf(fs) !== -1) { AppState.inputs.filingStatus = fs; applied = true; } 239 } 240 } 241 242 { 243 // The withdrawal rule is the whole point of some hand-offs â the 244 // /advisors/ worked example is a guardrails demonstration, and 245 // landing on fixed-real would open a different plan than the page 246 // just described. Enum-checked like filingStatus; anything else is 247 // ignored rather than trusted. 248 const wsRaw = readSeedParam('withdrawalStrategy'); 249 if (wsRaw !== null) { 250 const ws = wsRaw.toLowerCase(); 251 if (WITHDRAWAL.indexOf(ws) !== -1) { 252 AppState.inputs.withdrawalStrategy = ws; applied = true; 253 } 254 } 255 } 256 257 { 258 const stRaw = readSeedParam('state'); 259 if (stRaw !== null) { 260 const st = stRaw.toUpperCase(); 261 if (/^[A-Z]{2}$/.test(st)) { AppState.inputs.state = st; applied = true; } 262 } 263 } 264 265 { 266 // How later years move the federal and state amounts ('law' | 267 // 'inflation'), enum-checked like filingStatus. Federal engine 268 // 2.0 W4: one setting, thresholdIndexing; the state-only 269 // stateThresholdIndexing of state engine 1.10.0 links is still 270 // read (the new name wins when both are given). 271 let ixRaw = readSeedParam('thresholdIndexing'); 272 if (ixRaw === null) ixRaw = readSeedParam('stateThresholdIndexing'); 273 if (ixRaw !== null) { 274 const ix = ixRaw.toLowerCase(); 275 if (ix === 'law' || ix === 'inflation') { AppState.inputs.thresholdIndexing = ix; applied = true; } 276 } 277 } 278 279 // seed_taxAware=1 lands the visitor in Tax-Aware Mode â used by the 280 // blog->app deep-links from tax-topic posts (state tax, Roth, TLH, RMD, 281 // IRMAA, ACA) so a reader arrives already in the tax view with their 282 // state/filing preset. initTaxInputs() runs AFTER seedInputsFromUrl() 283 // (app.js DOMContentLoaded order), so it reads this flag and syncs the 284 // toggle + panel. Any non-truthy value is a no-op. 285 { 286 const taRaw = readSeedParam('taxAware'); 287 if (taRaw !== null) { 288 const v = taRaw.toLowerCase(); 289 if (v === '1' || v === 'true' || v === 'yes') { 290 AppState.inputs.taxAware = true; applied = true; 291 } 292 } 293 } 294 295 ALLOC_KEYS.forEach(key => { 296 const raw = readSeedParam(key); 297 if (raw === null) return; 298 const n = parseFloat(raw); 299 if (!isFinite(n) || n < 0 || n > 100) return; 300 if (!AppState.inputs.allocations) AppState.inputs.allocations = {}; 301 AppState.inputs.allocations[key] = Math.round(n); 302 applied = true; 303 }); 304 305 if (!applied) return; 306 307 // Persist so a refresh keeps the handed-off plan, then scrub the seed_* 308 // params from the URL (leave utm_* for analytics attribution). 309 if (typeof AppState.saveInputsToStorage === 'function') { 310 AppState.saveInputsToStorage(); 311 } 312 const BARE_SCRUB = new Set( 313 Object.keys(NUMERIC).concat(ALLOC_KEYS, 314 ['filingStatus', 'state', 'thresholdIndexing', 'stateThresholdIndexing', 'taxAware'], Object.keys(BARE_ALIASES))); 315 let scrubbed = false;
316 Array.from(params.keys()).forEach(k => { 317 if (k.indexOf('seed_') === 0 || BARE_SCRUB.has(k)) { params.delete(k); scrubbed = true; } 318 }); 319 if (scrubbed) { 320 const qs = params.toString(); 321 window.history.replaceState({}, '', 322 window.location.pathname + (qs ? '?' + qs : '')); 323 } 324 } catch (e) { 325 console.warn('[QuantCalc] seedInputsFromUrl skipped:', e && e.message); 326 } 327} 328 329/** 330 * Restore a saved plan from ?plan=<token> (the save-plan magic link). 331 * Fetches the plan JSON from the same-origin Worker KV route (/_plans/load), 332 * merges only known input fields into AppState.inputs, persists, and scrubs the 333 * param. Never blocks app init â any failure is swallowed. 334 */ 335async function restorePlanFromUrl() { 336 let restored = false; 337 try { 338 const params = new URLSearchParams(window.location.search); 339 const token = (params.get('plan') || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 64); 340 if (!token || typeof AppState === 'undefined' || !AppState.inputs) return false; 341 342 const resp = await fetch('/_plans/load?token=' + encodeURIComponent(token), { method: 'GET' }); 343 if (resp.ok) { 344 const plan = await resp.json(); 345 if (plan && typeof plan === 'object') { 346 // Whitelist: only restore keys that already exist on AppState.inputs. 347 Object.keys(plan).forEach(k => { 348 if (Object.prototype.hasOwnProperty.call(AppState.inputs, k)) { 349 AppState.inputs[k] = plan[k]; 350 } 351 }); 352 // Federal engine 2.0 W4: a plan saved before the setting 353 // covered federal amounts carries stateThresholdIndexing 354 if (!Object.prototype.hasOwnProperty.call(plan, 'thresholdIndexing') && 355 (plan.stateThresholdIndexing === 'law' || plan.stateThresholdIndexing === 'inflation')) { 356 AppState.inputs.thresholdIndexing = plan.stateThresholdIndexing; 357 } 358 if (typeof AppState.saveInputsToStorage === 'function') AppState.saveInputsToStorage(); 359 restored = true; 360 } 361 } else if (resp.status === 404 && typeof showError === 'function') { 362 showError('That saved-plan link has expired or is invalid.'); 363 } 364 // Scrub the ?plan param either way. 365 params.delete('plan'); 366 const qs = params.toString(); 367 window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '')); 368 } catch (e) { 369 console.warn('[QuantCalc] restorePlanFromUrl skipped:', e && e.message); 370 } 371 return restored; 372} 373 374/** 375 * Initialize license handling 376 */ 377function initLicense() { 378 const urlParams = new URLSearchParams(window.location.search); 379 if (urlParams.get('clear_license') === '1') { 380 localStorage.removeItem('quantcalc_license'); 381 AppState.licenseKey = ''; 382 AppState.tier = 'FREE'; 383 window.history.replaceState({}, '', window.location.pathname); 384 } 385 386 // Auto-activate from URL: ?key=QC-PRO-... or ?license=... lets a recovery 387 // email work on any browser without the user copy-pasting into a modal. 388 const urlKey = (urlParams.get('key') || urlParams.get('license') || '').trim(); 389 if (urlKey) { 390 AppState.setLicenseKey(urlKey); 391 urlParams.delete('key'); 392 urlParams.delete('license'); 393 const qs = urlParams.toString(); 394 window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '')); 395 if (AppState.isPro() && typeof showSuccess === 'function') { 396 showSuccess('License activated! All PRO features unlocked.'); 397 } 398 } 399 400 // Load saved license and update tier using the proper method 401 // This handles both license formats: QC-PRO (v1) and QCAL- (v2 lifetime). 402 if (AppState.licenseKey) { 403 AppState.setLicenseKey(AppState.licenseKey); 404 } else { 405 AppState.updateTierUI(); 406 } 407 408 // License button 409 const licenseBtn = document.getElementById('licenseBtn'); 410 if (licenseBtn) { 411 licenseBtn.addEventListener('click', () => { 412 // GA4: mirror showLicenseFromPopup() â this is the same intent 413 // (user has a key), entered via the header instead of the modal. 414 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 415 window.QuantCalcAnalytics.track('license_key_attempt', { 416 event_category: 'conversion_funnel', 417 event_label: 'has_license_key', 418 source: 'header_license_btn' 419 }); 420 } 421 document.getElementById('licenseInput').value = AppState.licenseKey || ''; 422 const m = document.getElementById('licenseModal'); 423 if (typeof openModal === 'function') openModal(m); else m.classList.remove('hidden'); 424 }); 425 } 426 427 // Close license modal 428 document.getElementById('closeLicenseModal')?.addEventListener('click', () => { 429 const m = document.getElementById('licenseModal'); 430 if (typeof closeModal === 'function') closeModal(m); else m.classList.add('hidden'); 431 }); 432 433 // Save license 434 document.getElementById('saveLicense')?.addEventListener('click', () => {
435 const key = document.getElementById('licenseInput').value.trim(); 436 const m = document.getElementById('licenseModal'); 437 438 /* Only PERSIST something that is shaped like a licence. 439 * 440 * This used to store whatever was typed before validating it, and 441 * app.html routes anyone with a stored licence to the frozen classic 442 * UI on every subsequent visit. So a free visitor who opened "Have a 443 * key?" out of curiosity and typed anything at all was told "invalid 444 * key, using FREE tier" -- and then silently exiled to a different 445 * app, permanently, with no way back that they would ever find. 446 * Reproduced on production before this change. 447 * 448 * Deliberately shape-only, and deliberately generous: the backend is 449 * still the authority and still demotes a forged or product-mismatched 450 * key on validation. This check exists to stop junk becoming a routing 451 * decision, not to decide entitlement. */ 452 const looksLikeKey = /^(QC-PRO|QC-ADVISOR|QCAL-)/i.test(key); 453 454 if (key && !looksLikeKey) { 455 if (typeof closeModal === 'function') closeModal(m); else m.classList.add('hidden'); 456 showError('That does not look like a license key. Still on the free tier.'); 457 return; 458 } 459 460 AppState.setLicenseKey(key); 461 if (typeof closeModal === 'function') closeModal(m); else m.classList.add('hidden'); 462 463 if (key && AppState.isPro()) { 464 showSuccess('PRO license activated! All features unlocked.'); 465 } else if (key) { 466 showError('Invalid license key format. Using FREE tier.'); 467 } 468 }); 469 470 // Clear license 471 document.getElementById('clearLicense')?.addEventListener('click', () => { 472 AppState.setLicenseKey(''); 473 document.getElementById('licenseInput').value = ''; 474 const m = document.getElementById('licenseModal'); 475 if (typeof closeModal === 'function') closeModal(m); else m.classList.add('hidden'); 476 }); 477 478 // Close modal on background click 479 document.getElementById('licenseModal')?.addEventListener('click', (e) => { 480 if (e.target.id === 'licenseModal') { 481 const m = document.getElementById('licenseModal'); 482 if (typeof closeModal === 'function') closeModal(m); else m.classList.add('hidden'); 483 } 484 }); 485 486 // GA4: Set subscription_tier user property so all session events are 487 // tagged with PRO/FREE for retention segmentation. 488 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.setUserTier) { 489 window.QuantCalcAnalytics.setUserTier(AppState.tier); 490 } 491 492} 493 494/** 495 * Handle simulation count change - check for PRO requirement 496 */ 497function handleSimCountChange(e) { 498 const value = parseInt(e.target.value); 499 500 if (value > 100 && !AppState.isPro()) { 501 // Reset to 100 and show upgrade modal 502 e.target.value = '100'; 503 AppState.inputs.numSimulations = 100; 504 showUpgradeModal('simulations'); 505 } else { 506 AppState.inputs.numSimulations = value; 507 // A deliberate choice: never silently replaced by the PRO default. 508 AppState.inputs.simCountChosen = true; 509 AppState.saveInputsToStorage(); 510 } 511} 512 513function initCharts() { 514 initProjectionChart(); 515 initRuinChart(); 516 initAllocationChart(); 517 initSpaghettiChart(); 518 initCashflowChart(); 519 initGlidepathChart(); 520 521 // Tax-aware charts (initialized but hidden until tax mode is active) 522 if (typeof initAccountDepletionChart === 'function') initAccountDepletionChart(); 523 if (typeof initTaxBurdenChart === 'function') initTaxBurdenChart(); 524 if (typeof initIrmaaZonesChart === 'function') initIrmaaZonesChart(); 525} 526 527async function runSimulation() { 528 var tier = (typeof AppState !== 'undefined' && AppState.isPro && AppState.isPro()) ? 'pro' : 'free'; 529 530 // Check daily limit for FREE tier before making API call 531 if (typeof canRunSimulation === 'function' && !canRunSimulation()) { 532 showDailyLimitModal(); 533 return; 534 } 535 536 // A free plan on a non-standard roster would be refused by the engine 537 // (handlers.c: custom rosters are PRO). Offer it instead of spending a 538 // request on a 403 â and before simulation_run, since nothing runs. 539 if (typeof AppState !== 'undefined' && !AppState.isPro() && 540 typeof getAssetCount === 'function' && getAssetCount() !== 5) { 541 showProGateOffer('custom_roster', { trigger: 'client', assetCount: getAssetCount() }); 542 return; 543 } 544 545 // GA4: Track simulation attempt 546 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 547 window.QuantCalcAnalytics.track('simulation_run', { 548 event_category: 'engagement', 549 tier: tier, 550 event_label: tier + '_simulation' 551 }); 552 } 553 554 // Validate inputs 555 const errors = validateInputs(); 556 if (errors.length > 0) { 557 const unbalanced = (typeof unbalancedAllocPeriods === 'function') ? unbalancedAllocPeriods() : []; 558 if (unbalanced.length && typeof balanceAllocPeriods === 'function') { 559 offerAllocationBalance('Please fix: ' + errors.join('; ')); 560 } else { 561 showError('Please fix: ' + errors.join('; ')); 562 } 563 // GA4: a third of runs were exiting invisibly (12 runs -> 8 successes, 564 // 0 error events, briefing 2026-08-02). Mirror simulation_success. 565 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 566 window.QuantCalcAnalytics.track('simulation_error', { 567 event_category: 'engagement', 568 reason: 'validation', 569 event_label: errors.slice(0, 2).join('; ').substring(0, 90) 570 }); 571 } 572 return; 573 } 574 575 // Show loading 576 showLoading(true); 577 578 try { 579 // Save inputs so Stress Test tool can import them 580 AppState.saveInputsToStorage(); 581 582 const request = AppState.getSimulationRequest(); 583 const response = await activeAPI.simulate(request); 584 585 if (response.success) { 586 // Increment daily usage counter for FREE tier 587 if (typeof incrementDailyUsage === 'function' && !AppState.isPro()) { 588 incrementDailyUsage(); 589 } 590 591 // GA4: Track successful simulation 592 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 593 window.QuantCalcAnalytics.track('simulation_success', { 594 event_category: 'engagement', 595 tier: tier, 596 event_label: tier + '_simulation_success' 597 }); 598 } 599 600 // GA4 (usage plane): pro_sim_run â fires only for PRO users, with 601 // a bucketed sim_count dimension so dashboards can split usage 602 // by depth without exploding event property cardinality. 603 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.trackProUsage && 604 window.QuantCalcAnalytics.bucketSimCount) { 605 window.QuantCalcAnalytics.trackProUsage('pro_sim_run', { 606 sim_count_bucket: window.QuantCalcAnalytics.bucketSimCount( 607 AppState.inputs && AppState.inputs.numSimulations 608 ? AppState.inputs.numSimulations 609 // request uses snake_case keys (num_simulations) 610 : (request && request.num_simulations) || 100) 611 }); 612 } 613 614 updateResults(response.results); 615 616 // What the daily-limit wall will quote back: this run's own 617 // numbers, recorded now so they survive a reload (see 618 // recordWallRun). Free only â PRO never sees the wall. 619 if (!AppState.isPro()) { 620 try { recordWallRun(response.results); } catch (e) { /* never fail a good run */ } 621 } 622 623 // A real run replaces the pre-baked example â retire its chip/flag.
624 if (typeof dismissExampleScenario === 'function') { 625 dismissExampleScenario(); 626 } 627 628 // Reveal the save-plan bar once there's a plan worth saving (all tiers). 629 const savePlanBar = document.getElementById('savePlanBar'); 630 if (savePlanBar) savePlanBar.style.display = 'flex'; 631 632 // Free-tier conversion surfaces: 633 // - basic sim -> lead with the savings-hero invitation (the hook) 634 // - tax-aware -> the savings headline + value-gate already rendered 635 // by updateResults; show the secondary PRO pitch. 636 if (!AppState.isPro()) { 637 if (!AppState.inputs.taxAware) { 638 showSavingsHeroCTA(); 639 } else { 640 showProPreviewCard(); 641 } 642 } 643 644 // Life-event attribution. Read the move BEFORE overwriting the 645 // remembered run, then render the impact card last so it lands 646 // directly under the results summary, above the generic pitch â 647 // it is the more specific offer for this visitor. 648 const lifeEventMove = lifeEventMoveSincePreviousRun(request, response.results); 649 rememberPlanRun(request, response.results); 650 showLifeEventImpactCard(request, response.results, lifeEventMove); 651 652 // Saved-plan link: scroll to the results so the user lands on their 653 // plan (the savings headline for tax plans, else the gauge), not the 654 // long input form above it. One-shot. 655 if (window.__qcScrollToResultsOnce) { 656 window.__qcScrollToResultsOnce = false; 657 // Defer so the results layout (charts resize on rAF) settles 658 // before we measure the scroll target â matters on mobile. 659 setTimeout(function () { 660 try { 661 var _hl = document.getElementById('taxSavingsHeadline'); 662 var _anchor = (_hl && _hl.style.display !== 'none') 663 ? _hl 664 : document.getElementById('resultsSummary'); 665 if (_anchor && _anchor.scrollIntoView) { 666 _anchor.scrollIntoView({ behavior: 'smooth', block: 'start' }); 667 } 668 } catch (e) { /* non-fatal */ } 669 }, 200); 670 } 671 } else { 672 throw new Error(response.error || 'Simulation failed'); 673 } 674 675 } catch (error) { 676 console.error('Simulation error:', error); 677 const msg = String(error && error.message || 'unknown'); 678 // A backend PRO-feature gate is a paywall moment, not a failure â 679 // present the PRO-feature offer (unlock, or run the free standard 680 // mix) instead of dead-ending with an error string (2 users hit 681 // "Custom asset rosters ... require a PRO license" as a bare error in 682 // the first 3 instrumented days, GA4 2026-08-03; 8 in the 30 days to 683 // 2026-09-23 via the generic popup). Normally pre-empted client-side 684 // above; this is the server-authoritative path. 685 const isProGate = /PRO license/i.test(msg); 686 // A backend 429 quota block is likewise a paywall moment (reaches this 687 // path when the localStorage counter and the server disagree â cleared 688 // storage, second tab). Route it to the daily-limit modal, which fires 689 // the daily_limit_hit conversion event â counting it as 690 // simulation_error inflated the error rate (4 of 11 events, GA4 691 // 2026-08-10..11 triage). 692 const isQuotaBlock = /simulation limit reached/i.test(msg); 693 // Separate "the request never reached us" from "the backend rejected 694 // the request". Both landed in reason='api' before, which left the 695 // remaining error bucket unreadable once pro-gate routing was split out. 696 const isNetwork = /failed to fetch|networkerror|network request failed|load failed|timed? ?out|aborted|connection/i.test(msg); 697 if (isProGate) { 698 // An offer, not an error (see showProGateOffer). Tracked as 699 // pro_gate_offer_shown{trigger:'server'}, no longer as a 700 // simulation_error â it was never a failure. 701 showProGateOffer(proGateFeatureFromMessage(msg), { 702 trigger: 'server', message: msg 703 }); 704 } else if (isQuotaBlock && typeof showDailyLimitModal === 'function') { 705 showDailyLimitModal(); 706 } else { 707 showError('Simulation error: ' + msg); 708 }
709 // GA4: the API/exception exit was the other invisible path. 710 // (Quota blocks are tracked by showDailyLimitModal as daily_limit_hit; 711 // PRO gates by showProGateOffer as pro_gate_offer_shown.) 712 if (!isQuotaBlock && !isProGate && window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 713 window.QuantCalcAnalytics.track('simulation_error', { 714 event_category: 'engagement', 715 reason: isNetwork ? 'network' : 'api', 716 event_label: msg.substring(0, 90) 717 }); 718 } 719 } finally { 720 showLoading(false); 721 } 722} 723 724/* ---- The daily-limit wall --------------------------------------------- 725 * 726 * 27 of 104 simulating users hit the free limit in the 30 days to 2026-09-23 727 * and ONE opened the offer from it. The wall used to lead with a generic 728 * blurred chart and a one-line personal sentence that filled only while the 729 * run was still in memory: `AppState.results` is gone on any reload, and on a 730 * return visit it holds the pre-baked example (flagged __qcExample), so the 731 * visitor who came back to run again â the one the wall exists for â saw no 732 * number of their own. (A server 429 on a first run, e.g. a shared IP, hit the 733 * same blank.) It also could not be measured: nothing recorded whether it 734 * filled. 735 * 736 * Now every successful FREE run records a small summary of what the page just 737 * showed (success rate, median, and on tax-aware runs the same present-value 738 * advantage and "year 1 of N" the results panel stated), in memory and in 739 * localStorage, and the wall leads with it. Only numbers that run produced; 740 * nothing is recomputed or invented. */ 741 742const WALL_RUN_KEY = 'quantcalc_wall_last_run'; 743const LIMIT_HIT_KEY = 'quantcalc_limit_hit_date'; 744// In-memory copy for when storage is blocked (Safari private mode). On window, 745// like __qcLastPlanRun, so a test can start from "no run on file". 746window.__qcWallRun = null; 747 748/** 749 * Classify the tax-aware headline exactly as updateTaxSummary (tax-inputs.js) 750 * worded it, so the wall can only repeat a claim the page already made: 751 * 'depletion' â the money runs out on the median path; the figure is 752 * spending + tax funded before it does (not a tax saving); 753 * 'neutral' â no order beat traditional-first by more than $1,000; 754 * 'close' â the orders are close (p10 below âsavings); 755 * 'advantage' â the recommended order comes out $X ahead (present value). 756 * null when the headline was hidden (no account balances). 757 */ 758function wallTaxMode(ts, inputs) { 759 const accountTotal = (inputs.traditionalBalance || 0) + (inputs.rothBalance || 0) + (inputs.taxableBalance || 0); 760 if (accountTotal <= 0) return null; 761 const savings = ts.taxSavings || 0; 762 const p10 = typeof ts.taxSavingsP10 === 'number' ? ts.taxSavingsP10 : null; 763 const unf = (ts.unfundedSpendingOptimized || 0) > 0 || (ts.unfundedSpendingNaive || 0) > 0; 764 if (unf && savings > 1000) return 'depletion'; 765 if (savings <= 1000) return 'neutral'; 766 if (p10 !== null && p10 < -savings) return 'close'; 767 return 'advantage'; 768} 769 770/** 771 * The summary the wall quotes, built from ONE real run's response. Tax fields 772 * come from the server's echo (results.taxAware / taxSummary / 773 * taxScheduleTotalYears), not from what the form asks for now. 774 */ 775function summarizeRunForWall(results, inputs) { 776 if (!results || results.__qcExample || typeof results.success_rate !== 'number') return null; 777 const out = { 778 v: 1, 779 date: getTodayString(), 780 successRate: results.success_rate, 781 median: (results.percentiles && typeof results.percentiles.p50 === 'number') ? results.percentiles.p50 : null, 782 numSims: typeof results.num_simulations === 'number' ? results.num_simulations : null, 783 taxAware: !!(results.taxAware && results.taxSummary), 784 taxMode: null, 785 taxSavings: null, 786 scheduleShown: null, 787 scheduleTotal: null 788 }; 789 if (out.taxAware) { 790 out.taxMode = wallTaxMode(results.taxSummary, inputs); 791 out.taxSavings = typeof results.taxSummary.taxSavings === 'number' ? results.taxSummary.taxSavings : null; 792 const total = results.taxScheduleTotalYears; 793 if (typeof total === 'number' && total > 0) { 794 // tax-inputs.js's FREE_PREVIEW_YEARS: the rows the table showed. 795 const preview = window.QC_FREE_PREVIEW_YEARS; 796 out.scheduleTotal = total; 797 out.scheduleShown = Math.min(preview, total); 798 } 799 } 800 return out; 801} 802 803/** Called on every successful FREE run (runSimulation). Storage is optional. */ 804function recordWallRun(results) { 805 const summary = summarizeRunForWall(results, AppState.inputs); 806 if (!summary) return; 807 window.__qcWallRun = summary; 808 try { localStorage.setItem(WALL_RUN_KEY, JSON.stringify(summary)); } catch (e) { /* fail open */ } 809} 810 811function loadWallRun() { 812 if (window.__qcWallRun) return window.__qcWallRun; 813 try { 814 const raw = localStorage.getItem(WALL_RUN_KEY); 815 if (!raw) return null; 816 const s = JSON.parse(raw); 817 if (!s || s.v !== 1 || typeof s.successRate !== 'number') return null; 818 return s; 819 } catch (e) { return null; } 820} 821 822/** 'tax_savings' | 'success_only' | 'none' â the GA4 personal_kind. */ 823function wallPersonalKind(summary) { 824 if (summary.taxAware && (summary.taxMode === 'advantage' || summary.taxMode === 'depletion') && 825 typeof summary.taxSavings === 'number' && summary.taxSavings > 1000) { 826 return 'tax_savings'; 827 } 828 return 'success_only'; 829} 830
831/** 832 * The wall's sentences, in order. Pure (tested on its own): every figure is a 833 * field of `summary`, i.e. something the visitor's own run produced. 834 */ 835function wallPersonalLines(summary) { 836 if (!summary) return []; 837 const lines = []; 838 const kind = wallPersonalKind(summary); 839 const pct = (summary.successRate * 100).toFixed(0) + '%'; 840 const when = (summary.date && summary.date !== getTodayString()) ? 'Your last run (' + summary.date + ')' : 'Your last run'; 841 let lead = when + ': ' + pct + ' success rate'; 842 if (typeof summary.median === 'number' && summary.median > 0) { 843 lead += ', median ending balance ' + formatCurrencyLong(summary.median); 844 } 845 lines.push({ cls: 'dl-personal-lead', text: lead + '.' }); 846 847 const estimate = summary.numSims ? ' (present value; an estimate from your ' + 848 summary.numSims.toLocaleString('en-US') + '-simulation run)' : ' (present value; an estimate)'; 849 if (kind === 'tax_savings') { 850 const amt = formatCurrencyLong(summary.taxSavings); 851 lines.push({ cls: 'dl-personal-tax', text: summary.taxMode === 'depletion' 852 ? 'Tax-aware withdrawal order: funds about ' + amt + ' more of your spending and tax before the money runs out' + estimate + '.' 853 : 'Tax-aware withdrawal order: about ' + amt + ' ahead of drawing traditional first' + estimate + '.' }); 854 } else if (summary.taxAware && summary.taxMode === 'close') { 855 lines.push({ cls: 'dl-personal-tax', text: 'Tax-aware check: on your paths the recommended withdrawal order and drawing traditional first come out close.' }); 856 } else if (summary.taxAware && summary.taxMode === 'neutral') { 857 lines.push({ cls: 'dl-personal-tax', text: 'Tax-aware check: no withdrawal order beat drawing traditional first by more than $1,000 on your paths.' }); 858 } 859 860 if (summary.taxAware && summary.scheduleTotal && summary.scheduleShown && 861 summary.scheduleTotal > summary.scheduleShown) { 862 const seen = summary.scheduleShown === 1 ? 'year 1' : 'the first ' + summary.scheduleShown + ' years'; 863 lines.push({ cls: 'dl-personal-schedule', text: 'Youâve seen ' + seen + ' of your ' + summary.scheduleTotal + 864 '-year withdrawal plan. PRO shows every year: how much to take from each account, any Roth conversion, and that yearâs tax and IRMAA surcharge.' }); 865 } else if (!summary.taxAware) { 866 lines.push({ cls: 'dl-personal-note', text: 'Next time, switch on Tax-aware mode (free): it tests which account to draw from first and what that order is worth for your plan. PRO adds the full year-by-year withdrawal plan' + 867 (summary.numSims && summary.numSims < 10000 ? ' and re-runs it at 10,000 simulations instead of ' + summary.numSims.toLocaleString('en-US') : '') + '.' }); 868 } 869 return lines; 870} 871 872/** Fill #dailyLimitPersonal; returns the personal_kind it rendered. */ 873function renderDailyLimitPersonal() { 874 const host = document.getElementById('dailyLimitPersonal'); 875 const generic = document.getElementById('dailyLimitGenericPreview'); 876 const summary = loadWallRun(); 877 const lines = wallPersonalLines(summary); 878 if (host) { 879 host.textContent = ''; 880 lines.forEach(function (l) { 881 const p = document.createElement('p'); 882 p.className = l.cls; 883 p.textContent = l.text; 884 host.appendChild(p); 885 }); 886 host.classList.toggle('hidden', lines.length === 0); 887 } 888 // The blurred sample chart is only the fallback for a visitor with no run. 889 if (generic) generic.classList.toggle('hidden', lines.length > 0); 890 return lines.length ? wallPersonalKind(summary) : 'none'; 891} 892 893let _dailyLimitControl = null; 894 895/** 896 * Show the daily-limit wall. 897 */ 898function showDailyLimitModal() { 899 // GA4: Track daily limit hit â user hit the paywall 900 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 901 window.QuantCalcAnalytics.track('daily_limit_hit', { 902 event_category: 'conversion_funnel', 903 event_label: 'free_daily_limit_reached' 904 }); 905 } 906 907 // Remember the day this browser hit the wall, for returned_after_limit. 908 try { localStorage.setItem(LIMIT_HIT_KEY, getTodayString()); } catch (e) { /* fail open */ } 909 910 const modal = document.getElementById('dailyLimitModal'); 911 if (modal) {
912 const kind = renderDailyLimitPersonal(); 913 _dailyLimitControl = null; 914 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 915 window.QuantCalcAnalytics.track('daily_limit_modal_shown', { 916 event_category: 'conversion_funnel', 917 personal_kind: kind, 918 // The client counter (a server 429 can open the wall below it). 919 runs_today: getDailyUsage().count 920 }); 921 } 922 if (typeof openModal === 'function') openModal(modal); else modal.classList.remove('hidden'); 923 } else { 924 // Fallback if modal doesn't exist 925 showUpgradePopup('You have used your 3 free simulations today. Upgrade to PRO for unlimited simulations.'); 926 } 927} 928 929function trackDailyLimitControl(control) { 930 _dailyLimitControl = control; 931 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 932 window.QuantCalcAnalytics.track('daily_limit_cta', { 933 event_category: 'conversion_funnel', 934 control: control 935 }); 936 } 937} 938 939function hideDailyLimitModal() { 940 const modal = document.getElementById('dailyLimitModal'); 941 if (!modal) return; 942 // Every exit is one of the wall's controls. Explicit buttons record 943 // themselves (initDailyLimitWall); what reaches here unrecorded while the 944 // wall is open is a dismissal â backdrop, Escape. 945 if (!modal.classList.contains('hidden') && !_dailyLimitControl) trackDailyLimitControl('close'); 946 if (typeof closeModal === 'function') closeModal(modal); else modal.classList.add('hidden'); 947} 948 949/** Map a click inside the wall to its control name (null = not a control). */ 950function dailyLimitControlFor(target) { 951 if (target.closest('.modal-close')) return 'close'; 952 const el = target.closest('[data-track]'); 953 if (!el) return null; 954 switch (el.getAttribute('data-track')) { 955 case 'daily_limit_unlock_cta': return 'checkout'; 956 case 'daily_limit_have_key': return 'have_key'; 957 case 'daily_limit_advisor': return 'advisor'; 958 case 'save_plan_open': return 'save_plan'; 959 default: return null; 960 } 961} 962 963/** 964 * Wire the wall's instrumentation once, and report a comeback: a browser that 965 * hit the limit on an EARLIER calendar day and is back in the app. Fires once 966 * per hit (the marker is cleared), so returns / hits is the comeback rate. 967 */ 968function initDailyLimitWall() { 969 const modal = document.getElementById('dailyLimitModal'); 970 if (modal) { 971 // Capture phase: runs before the buttons' inline onclick handlers, 972 // which close the modal â the control must be recorded first so the 973 // close is not also reported as a dismissal. 974 modal.addEventListener('click', function (e) { 975 const control = dailyLimitControlFor(e.target); 976 if (control) trackDailyLimitControl(control); 977 }, true); 978 } 979 try { 980 const hit = localStorage.getItem(LIMIT_HIT_KEY); 981 const today = getTodayString(); 982 if (hit && hit !== today && /^\d{4}-\d{2}-\d{2}$/.test(hit)) { 983 const days = Math.round((Date.parse(today + 'T00:00:00Z') - Date.parse(hit + 'T00:00:00Z')) / 86400000); 984 localStorage.removeItem(LIMIT_HIT_KEY); 985 if (days > 0 && window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 986 window.QuantCalcAnalytics.track('returned_after_limit', { 987 event_category: 'conversion_funnel', 988 days_since: days 989 }); 990 } 991 } 992 } catch (e) { /* storage blocked â no comeback signal, nothing else lost */ } 993} 994 995/* ---- PRO-feature offer (was: an error) -------------------------------- 996 * 997 * 8 of the 18 users who hit simulation_error in the 30 days to 2026-09-23 hit 998 * a PRO gate, not a failure: a free plan whose roster is not the standard five 999 * asset classes (removing a class is free; the engine only runs other counts 1000 * for PRO â handlers.c). It reached them as an error string or as the generic 1001 * upgrade popup with the raw error in it, and offered no way to just run. 1002 * 1003 * Checked before the request whenever the client can tell (roster size), so 1004 * no request is spent; the server's 403 remains the authority and lands in 1005 * the same offer (a revoked key the client still believes is PRO). Neither 1006 * path costs a free run: the client counts only successful runs, and the 1007 * server refunds a gated request before answering. */ 1008 1009const PRO_GATE_FEATURES = { 1010 custom_roster: { 1011 title: 'Custom asset mixes are part of PRO', 1012 body: function (ctx) { 1013 const n = ctx && ctx.assetCount; 1014 return (n ? 'Your allocation uses ' + n + ' asset classes. ' : '') + 1015 'The free plan runs the five standard classes (US stocks, international stocks, bonds, real estate, cash); PRO runs any mix of up to 10.'; 1016 }, 1017 canRunStandard: true 1018 } 1019}; 1020 1021let _proGateFeature = null; 1022let _proGateActed = false;
1023 1024function proGateFeatureFromMessage(msg) { 1025 return /asset roster|asset classes/i.test(msg) ? 'custom_roster' : 'other'; 1026} 1027 1028function showProGateOffer(feature, opts) { 1029 opts = opts || {}; 1030 const modal = document.getElementById('proGateOfferModal'); 1031 const def = PRO_GATE_FEATURES[feature] || null; 1032 if (!modal) { 1033 // Markup missing (a page without the offer): the previous behaviour. 1034 showUpgradePopup({ context: opts.message || 'This option requires PRO.', source: 'pro_feature_gate_api' }); 1035 return; 1036 } 1037 _proGateFeature = feature; 1038 _proGateActed = false; 1039 const title = document.getElementById('proGateOfferTitle'); 1040 const body = document.getElementById('proGateOfferBody'); 1041 const unlock = document.getElementById('proGateUnlockBtn'); 1042 const standard = document.getElementById('proGateStandardBtn'); 1043 const note = document.getElementById('proGateStandardNote'); 1044 if (title) title.textContent = def ? def.title : 'This option is part of PRO'; 1045 if (body) body.textContent = def ? def.body(opts) : opts.message; 1046 if (unlock) { 1047 // Price from the pricing SSOT (config/pricing.json -> pricing.js). 1048 unlock.textContent = 'Unlock with PRO \u2014 $' + QC_PRICING.personal.price_usd + ' once'; 1049 } 1050 const canStandard = !!(def && def.canRunStandard); 1051 if (standard) standard.classList.toggle('hidden', !canStandard); 1052 if (note) note.classList.toggle('hidden', !canStandard); 1053 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 1054 window.QuantCalcAnalytics.track('pro_gate_offer_shown', { 1055 event_category: 'conversion_funnel', 1056 feature: feature, 1057 trigger: opts.trigger || 'client' 1058 }); 1059 } 1060 openModal(modal); 1061} 1062 1063function trackProGateAction(action) { 1064 _proGateActed = true; 1065 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 1066 window.QuantCalcAnalytics.track('pro_gate_offer_action', { 1067 event_category: 'conversion_funnel', 1068 action: action, 1069 feature: _proGateFeature 1070 }); 1071 } 1072} 1073 1074function hideProGateOffer() { 1075 const modal = document.getElementById('proGateOfferModal'); 1076 if (!modal) return; 1077 if (!modal.classList.contains('hidden') && !_proGateActed) trackProGateAction('close'); 1078 closeModal(modal); 1079} 1080 1081/** "Run with the standard mix (free)": restore the five standard classes 1082 * (resetRosterToDefault â the same undoable action as the roster panel's 1083 * button) and run. The run goes through runSimulation, so the allocation 1084 * total is still validated: nothing here bypasses the 100% rule. */ 1085function proGateRunStandard() { 1086 trackProGateAction('run_standard'); 1087 hideProGateOffer(); 1088 if (typeof resetRosterToDefault === 'function') resetRosterToDefault(); 1089 return runSimulation(); 1090} 1091 1092function proGateUnlock() { 1093 trackProGateAction('unlock'); 1094 hideProGateOffer(); 1095 if (typeof checkoutFromWall === 'function') { 1096 checkoutFromWall('pro_gate', 'Unlock custom asset mixes and your full year-by-year plan.'); 1097 } 1098} 1099 1100function initProGateOffer() { 1101 const modal = document.getElementById('proGateOfferModal'); 1102 if (!modal) return; 1103 modal.addEventListener('click', function (e) { 1104 if (e.target === modal) { hideProGateOffer(); return; } 1105 const btn = e.target.closest && e.target.closest('[data-pro-gate-action]'); 1106 if (!btn) return; 1107 const action = btn.getAttribute('data-pro-gate-action'); 1108 if (action === 'unlock') proGateUnlock(); 1109 else if (action === 'run_standard') proGateRunStandard(); 1110 else if (action === 'close') hideProGateOffer(); 1111 }); 1112} 1113 1114/* Allocation must total exactly 100% â that rule stays. What changes is where 1115 * the fix is: the error named a "Balance to 100%" button that lives in the 1116 * allocation panel, often collapsed or a screen away on a phone (~10 of 104 1117 * simulating users stopped on this error in 30 days). The error now carries 1118 * the fix itself. It is OFFERED, never applied: one click rescales only the 1119 * failing periods (exact largest-remainder rounding, inputs.js 1120 * balanceAllocPeriods), shows the result, and the visitor runs again â 1121 * through validateInputs, which still blocks anything but exactly 100. */ 1122function offerAllocationBalance(message) { 1123 showError(message, 15000, { 1124 label: 'Balance to 100%', 1125 onClick: function () { 1126 const fixed = balanceAllocPeriods(); 1127 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 1128 window.QuantCalcAnalytics.track('input_autofix', { 1129 event_category: 'engagement', 1130 event_label: 'allocation_balanced_from_error', 1131 periods: fixed.length 1132 }); 1133 } 1134 if (typeof showSuccess === 'function') { 1135 showSuccess('Allocation rescaled to exactly 100% â check the new weights, then run again.', 6000); 1136 } 1137 const panel = document.querySelector('.allocation-total'); 1138 if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1139 } 1140 }); 1141} 1142 1143window.showDailyLimitModal = showDailyLimitModal; 1144window.hideDailyLimitModal = hideDailyLimitModal; 1145window.summarizeRunForWall = summarizeRunForWall; 1146window.wallPersonalLines = wallPersonalLines; 1147window.wallPersonalKind = wallPersonalKind; 1148window.showProGateOffer = showProGateOffer; 1149window.hideProGateOffer = hideProGateOffer; 1150 1151// Expose for use by other modules 1152window.runSimulation = runSimulation; 1153 1154/* ---- First-paint pre-baked render (example scenario) ---- */ 1155 1156// True while the pre-baked example scenario is on screen. Task 3's verdict
1157// copy reads this. Cleared on the first real run or any input change. 1158window.__qcPrebaked = false; 1159 1160/** 1161 * Render the committed default scenario (frontend/data/default-scenario.json) 1162 * on first paint so new visitors see a fully-populated result instead of 1163 * "--%". Renders through updateResults() directly â NOT runSimulation() â so: 1164 * - no simulation_run / simulation_success / pro_sim_run GA4 events 1165 * - no incrementDailyUsage() (quota indicator untouched) 1166 * - no savePlanBar reveal, no savings-hero / PRO-preview CTA 1167 * Fires exactly one additive GA4 event: default_scenario_shown. 1168 * 1169 * Skipped when: a saved-plan restore ran (it auto-runs a real simulation), 1170 * a ?key=/?license= activation load, or the current inputs no longer match 1171 * the snapshot's inputs (restored/seeded inputs would make the assumptions 1172 * panel misstate what produced these numbers). 1173 * 1174 * Never throws into init: every failure path resolves false and leaves 1175 * today's empty state untouched. 1176 */ 1177async function renderDefaultScenario(opts) { 1178 opts = opts || {}; 1179 if (opts.planRestored || opts.activationInProgress) return false; 1180 if (typeof updateResults !== 'function' || typeof AppState === 'undefined') return false; 1181 1182 let snapshot; 1183 try { 1184 // cache:'no-cache' â deploys give JSON assets year-long cache headers; 1185 // force revalidation so a regenerated snapshot propagates. 1186 const resp = await fetch('data/default-scenario.json', { cache: 'no-cache' }); 1187 if (!resp || !resp.ok) return false; 1188 snapshot = await resp.json(); 1189 } catch (e) { 1190 console.warn('[QuantCalc] default scenario unavailable:', e && e.message); 1191 return false; 1192 } 1193 1194 try { 1195 const results = snapshot && snapshot.results; 1196 if (!results || typeof results.success_rate !== 'number' || 1197 !results.percentiles || !results.yearly_projection) { 1198 return false; 1199 } 1200 if (!defaultScenarioMatchesInputs(snapshot.inputs)) return false; 1201 1202 // A real run finished while the snapshot was in flight (or before init 1203 // got here) â never overwrite a real result with the example. 1204 if (AppState.results) return false; 1205 1206 // Tag the snapshot as example data so personalization surfaces (the 1207 // upgrade popup's "Your median is $X" dollar-swing copy) never present 1208 // it as the user's own numbers. The tag rides on the results object 1209 // itself â window.__qcPrebaked is NOT a sufficient guard, because the 1210 // sim-select gate clears the chip (input-change dismiss) before the 1211 // popup opens. A real run clears it inherently: runSimulation calls 1212 // updateResults(response.results) with a fresh object built in 1213 // api.js, replacing AppState.results wholesale. 1214 results.__qcExample = true; 1215 1216 // updateResults (results.js) also corrects the "Based on N 1217 // simulations" label for the prebaked render â the snapshot ran at 1218 // higher precision than the UI's 100-sim default. 1219 updateResults(results, /* prebaked */ true); 1220 1221 window.__qcPrebaked = true; 1222 1223 const chip = document.getElementById('exampleScenarioChip'); 1224 if (chip) chip.style.display = ''; 1225 1226 // Any input edit makes the example stale â dismiss on the first one. 1227 document.addEventListener('change', dismissExampleOnInputChange, true); 1228 document.addEventListener('input', dismissExampleOnInputChange, true); 1229 1230 trackDefaultScenarioShown(0); 1231 return true; 1232 } catch (e) { 1233 console.warn('[QuantCalc] default scenario render failed:', e && e.message); 1234 return false; 1235 } 1236} 1237window.renderDefaultScenario = renderDefaultScenario; 1238 1239/** 1240 * The snapshot is only a truthful example if the form still shows the shipped 1241 * defaults it was computed from. Compares the snapshot's recorded request 1242 * against AppState.getSimulationRequest(), ignoring fields the generator 1243 * intentionally pins (sim count, seed, QMC). Extra request keys (taxAware, 1244 * lifeEvents, glide-path periods, custom CME, ...) also count as a mismatch. 1245 */
1246function defaultScenarioMatchesInputs(snapInputs) { 1247 try { 1248 if (!snapInputs || typeof AppState.getSimulationRequest !== 'function') return false; 1249 const req = AppState.getSimulationRequest(); 1250 const VOLATILE = { num_simulations: true, seed: true, useQMC: true }; 1251 1252 const snapKeys = Object.keys(snapInputs); 1253 for (let i = 0; i < snapKeys.length; i++) { 1254 const k = snapKeys[i]; 1255 if (VOLATILE[k]) continue; 1256 const a = snapInputs[k]; 1257 const b = req[k]; 1258 if (Array.isArray(a)) { 1259 if (!Array.isArray(b) || b.length !== a.length) return false; 1260 for (let j = 0; j < a.length; j++) { 1261 if (Math.abs(Number(a[j]) - Number(b[j])) > 1e-9) return false; 1262 } 1263 } else if (typeof a === 'number') { 1264 if (typeof b !== 'number' || Math.abs(a - b) > 1e-9) return false; 1265 } else if (a !== b) { 1266 return false; 1267 } 1268 } 1269 const reqKeys = Object.keys(req); 1270 for (let i = 0; i < reqKeys.length; i++) { 1271 const k = reqKeys[i]; 1272 if (VOLATILE[k]) continue; 1273 if (!(k in snapInputs)) return false; 1274 } 1275 return true; 1276 } catch (e) { 1277 return false; 1278 } 1279} 1280 1281/** 1282 * Hide the example-scenario chip and clear the prebaked flag. Idempotent. 1283 * Called on the first real runSimulation() success and on any input change. 1284 * The rendered numbers stay on screen until a real run replaces them. 1285 */ 1286function dismissExampleScenario() { 1287 window.__qcPrebaked = false; 1288 const chip = document.getElementById('exampleScenarioChip'); 1289 if (chip) chip.style.display = 'none'; 1290 document.removeEventListener('change', dismissExampleOnInputChange, true); 1291 document.removeEventListener('input', dismissExampleOnInputChange, true); 1292} 1293window.dismissExampleScenario = dismissExampleScenario; 1294 1295function dismissExampleOnInputChange(e) { 1296 if (!window.__qcPrebaked) return; 1297 const t = e.target; 1298 if (!t || typeof t.matches !== 'function') return; 1299 if (!t.matches('input, select, textarea')) return; 1300 // Modal fields (license key, lead-capture email) aren't scenario inputs. 1301 if (typeof t.closest === 'function' && t.closest('.modal')) return; 1302 dismissExampleScenario(); 1303} 1304 1305/** 1306 * GA4: default_scenario_shown (additive event, engagement plane). 1307 * analytics.js loads async â retry briefly instead of losing the event. 1308 */ 1309function trackDefaultScenarioShown(attempt) { 1310 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 1311 window.QuantCalcAnalytics.track('default_scenario_shown', { 1312 event_category: 'engagement', 1313 event_label: 'first_paint', 1314 // Same event name fires from the content-page prebakes; without 1315 // this the two sum and overstate app reach (~2x, GA4 2026-08-22). 1316 surface: 'app' 1317 }); 1318 return; 1319 } 1320 if (attempt < 10) { 1321 setTimeout(function () { trackDefaultScenarioShown(attempt + 1); }, 300); 1322 } 1323} 1324 1325/** 1326 * Silent connectivity check on page load. If the API is unreachable, render 1327 * a yellow banner linking to /diagnostic.html. Catches networks that silently 1328 * block AWS or our API domain before the user burns time on a simulation. 1329 * 1330 * Uses apiProbe (never throws) against the public CME sources endpoint. 1331 * Runs once per session â repeated navigation keeps the same verdict. 1332 */ 1333/** 1334 * Session-storage accessors that never throw. 1335 * 1336 * Every conversion surface below is invoked from the simulation SUCCESS path, 1337 * and each one used to touch sessionStorage directly. In a browser where 1338 * storage is blocked -- Safari private mode is the common one -- the very 1339 * first read throws, the exception unwinds through the success handler, and a 1340 * run that actually worked is reported to the user, and to GA4, as a failure. 1341 * Losing a dismissal flag is a triviality; losing the result is not. So these 1342 * fail open: a blocked read is simply "no flag set". 1343 */ 1344function sessionGet(key) { 1345 try { return sessionStorage.getItem(key); } catch (e) { return null; } 1346} 1347 1348function sessionSet(key, value) { 1349 try { sessionStorage.setItem(key, value); } catch (e) { /* fail open */ } 1350} 1351 1352async function runPreflightCheck() { 1353 if (typeof apiProbe !== 'function') return; 1354 if (sessionGet('quantcalc_preflight_ok') === '1') return; 1355 1356 const base = (AppState && AppState.apiUrl) || ''; 1357 const probe = await apiProbe(`${base}/api/cme/sources`); 1358 1359 if (probe.ok) { 1360 sessionSet('quantcalc_preflight_ok', '1'); 1361 return; 1362 } 1363 1364 showPreflightBanner(probe); 1365} 1366 1367function showPreflightBanner(probe) { 1368 if (document.getElementById('preflight-banner')) return; 1369 1370 let detail; 1371 if (probe.status) { 1372 detail = `The API returned HTTP ${probe.status}.`; 1373 } else if (probe.klass === 'NETWORK_TIMEOUT') { 1374 detail = 'The API is taking longer than expected to respond. ' 1375 + 'Heavy PRO runs can take a few minutes â if this persists, try again.'; 1376 } else { 1377 detail = 'The API could not be reached. This usually means a corporate firewall, antivirus, ' 1378 + 'or browser extension is blocking our endpoints.'; 1379 } 1380 1381 const banner = document.createElement('div'); 1382 banner.id = 'preflight-banner'; 1383 banner.setAttribute('role', 'alert'); 1384 banner.style.cssText = [ 1385 'position:sticky', 'top:0', 'z-index:1000', 1386 'background:color-mix(in srgb, var(--warn, #a05e03) 14%, var(--surface-card, #ffffff))', 1387 'border-bottom:1px solid var(--warn, #a05e03)', 1388 'color:var(--ink, #131a22)', 'padding:0.75rem 1rem', 1389 'font-size:0.9rem', 'line-height:1.4', 1390 'display:flex', 'align-items:center', 'gap:0.75rem', 'flex-wrap:wrap' 1391 ].join(';'); 1392 1393 const msg = document.createElement('span'); 1394 msg.innerHTML = `<strong>Connection issue detected.</strong> ${detail} ` 1395 + `<a href="/diagnostic.html" style="color:var(--accent, #1e56c8);text-decoration:underline;font-weight:600;">Run diagnostics â</a>`; 1396 1397 const close = document.createElement('button'); 1398 close.textContent = 'Ã'; 1399 close.setAttribute('aria-label', 'Dismiss'); 1400 close.style.cssText = 'margin-left:auto;background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--ink, #131a22);padding:0 0.5rem;'; 1401 close.addEventListener('click', () => banner.remove()); 1402 1403 banner.appendChild(msg); 1404 banner.appendChild(close); 1405 document.body.prepend(banner); 1406} 1407 1408function checkJustPurchased() { 1409 if (AppState.licenseKey) return; 1410 1411 const referrer = document.referrer || '';
1412 const urlParams = new URLSearchParams(window.location.search); 1413 const cameFromCheckout = referrer.includes('creem.io') 1414 || referrer.includes('/activate') 1415 || urlParams.get('ref') === 'creem' 1416 || urlParams.get('ref') === 'purchase'; 1417 if (!cameFromCheckout) return; 1418 if (sessionGet('quantcalc_purchase_hint_shown') === '1') return; 1419 1420 sessionSet('quantcalc_purchase_hint_shown', '1'); 1421 1422 const banner = document.createElement('div'); 1423 banner.id = 'purchase-hint'; 1424 banner.setAttribute('role', 'status'); 1425 banner.style.cssText = [ 1426 'position:sticky', 'top:0', 'z-index:1000', 1427 'background:#ecfdf5', 'border-bottom:1px solid #6ee7b7', 1428 'color:#065f46', 'padding:0.75rem 1rem', 1429 'font-size:0.9rem', 'line-height:1.4', 1430 'display:flex', 'align-items:center', 'gap:0.75rem', 'flex-wrap:wrap' 1431 ].join(';'); 1432 1433 const msg = document.createElement('span'); 1434 msg.innerHTML = '<strong>Thanks for your purchase!</strong> Check your email for your license key, then ' 1435 + '<a href="#" id="purchase-hint-link" style="color:#065f46;text-decoration:underline;font-weight:600;">activate it here â</a>'; 1436 1437 const close = document.createElement('button'); 1438 close.textContent = 'Ã'; 1439 close.setAttribute('aria-label', 'Dismiss'); 1440 close.style.cssText = 'margin-left:auto;background:none;border:none;font-size:1.25rem;cursor:pointer;color:#065f46;padding:0 0.5rem;'; 1441 close.addEventListener('click', () => banner.remove()); 1442 1443 banner.appendChild(msg); 1444 banner.appendChild(close); 1445 document.body.prepend(banner); 1446 1447 document.getElementById('purchase-hint-link').addEventListener('click', (e) => { 1448 e.preventDefault(); 1449 if (typeof showLicenseFromPopup === 'function') showLicenseFromPopup(); 1450 }); 1451} 1452 1453/** 1454 * Conversion nudge â shown after 2nd simulation to drive PRO upgrades. 1455 * Replaces the old "Already purchased?" nudge with a value-proposition CTA. 1456 */ 1457function showConversionNudge() { 1458 if (document.getElementById('conversion-nudge')) return; 1459 if (sessionGet('quantcalc_conversion_nudge_dismissed') === '1') return; 1460 1461 // GA4: soft pitch shown â distinct from upgrade_modal_view (hard modal) 1462 // so we can measure pitchâmodalâcheckout funnel cleanly. 1463 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 1464 window.QuantCalcAnalytics.track('upgrade_preview_shown', { 1465 event_category: 'conversion_funnel', 1466 upgrade_source: 'conversion_nudge', 1467 event_label: 'sim_count_trigger' 1468 }); 1469 } 1470 1471 const banner = document.createElement('div'); 1472 banner.id = 'conversion-nudge'; 1473 banner.setAttribute('role', 'status'); 1474 banner.style.cssText = [ 1475 'position:sticky', 'top:0', 'z-index:1000', 1476 'background:linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%)', 1477 'border-bottom:2px solid #2563eb', 1478 'color:#1e3a8a', 'padding:0.75rem 1rem', 1479 'font-size:0.9rem', 'line-height:1.4', 1480 'display:flex', 'align-items:center', 'gap:0.75rem', 'flex-wrap:wrap' 1481 ].join(';'); 1482 1483 const msg = document.createElement('span'); 1484 msg.innerHTML = '<strong>Want the full picture?</strong> ' 1485 + 'PRO unlocks your full year-by-year tax-aware withdrawal & Roth-conversion plan â one-time $' + QC_PRICING.personal.price_usd + ', yours forever. ' 1486 + '<a href="#" id="conversion-nudge-link" style="color:#2563eb;text-decoration:underline;font-weight:700;">Go PRO â $' + QC_PRICING.personal.price_usd + ' once â</a>'; 1487 1488 const licenseLink = document.createElement('a'); 1489 licenseLink.href = '#'; 1490 licenseLink.id = 'conversion-nudge-license'; 1491 licenseLink.style.cssText = 'color:#6b7280;font-size:0.8rem;text-decoration:underline;margin-left:0.5rem;'; 1492 licenseLink.textContent = 'Have a key?'; 1493 1494 const close = document.createElement('button'); 1495 close.textContent = 'Ã'; 1496 close.setAttribute('aria-label', 'Dismiss'); 1497 close.style.cssText = 'margin-left:auto;background:none;border:none;font-size:1.25rem;cursor:pointer;color:#1e3a8a;padding:0 0.5rem;'; 1498 close.addEventListener('click', () => {
1499 sessionSet('quantcalc_conversion_nudge_dismissed', '1'); 1500 banner.remove(); 1501 }); 1502 1503 banner.appendChild(msg); 1504 banner.appendChild(licenseLink); 1505 banner.appendChild(close); 1506 document.body.prepend(banner); 1507 1508 document.getElementById('conversion-nudge-link').addEventListener('click', (e) => { 1509 e.preventDefault(); 1510 banner.remove(); 1511 if (typeof showUpgradePopup === 'function') { 1512 showUpgradePopup({ context: 'Unlock your full year-by-year tax-aware withdrawal & Roth-conversion plan.', source: 'conversion_nudge' }); 1513 } 1514 }); 1515 1516 document.getElementById('conversion-nudge-license').addEventListener('click', (e) => { 1517 e.preventDefault(); 1518 if (typeof showLicenseFromPopup === 'function') showLicenseFromPopup(); 1519 }); 1520} 1521window.showConversionNudge = showConversionNudge; 1522 1523// Keep legacy name for backward compatibility 1524function showLicenseNudge() { showConversionNudge(); } 1525window.showLicenseNudge = showLicenseNudge; 1526 1527// React to fetch-error events from apiFetch: if a user manages to kick off a 1528// Run despite preflight passing (e.g., network dropped mid-session), surface 1529// the same banner so they know where to go. 1530window.addEventListener('quantcalc:fetch-error', (ev) => { 1531 const detail = ev.detail || {}; 1532 if (detail.klass === 'NETWORK_BLOCKED' || detail.klass === 'NETWORK_TIMEOUT') { 1533 showPreflightBanner({ ok: false, klass: detail.klass }); 1534 } 1535}); 1536 1537/** 1538 * Fire `upgrade_preview_shown` at most ONCE per session per upgrade_source. 1539 * 1540 * The soft-pitch surfaces are re-rendered on every qualifying simulation, so 1541 * the raw event counted *insertions*, not people: a weekly briefing read 1542 * "33 shown / 1 clicked" off a handful of users re-running sims, which makes 1543 * the pitch look far worse than it is and hides the real click-through. 1544 * Session-scoped dedup makes the metric answer "how many sessions saw the 1545 * pitch" â the denominator the click actually belongs to. 1546 * 1547 * `dedup: 'session'` is stamped on the event so the analyst can see in GA4 1548 * exactly which events carry the new semantics and from when. Event name and 1549 * upgrade_source are unchanged on purpose (series continuity). 1550 * 1551 * Call this AFTER the surface has actually landed in the DOM. 1552 */ 1553function trackUpgradePreviewOnce(source, label) { 1554 var key = 'qc_preview_shown_' + source; 1555 try { 1556 if (sessionGet(key) === '1') return; 1557 sessionSet(key, '1'); 1558 } catch (e) { /* storage blocked â fall through and report the display */ } 1559 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 1560 window.QuantCalcAnalytics.track('upgrade_preview_shown', { 1561 event_category: 'conversion_funnel', 1562 upgrade_source: source, 1563 event_label: label, 1564 dedup: 'session' 1565 }); 1566 } 1567} 1568 1569/** 1570 * PRO Preview Card â shown after every free simulation to showcase locked features. 1571 * Renders below the simulation results with blurred mockups of PRO-only outputs. 1572 */ 1573function showProPreviewCard() { 1574 // Remove existing card if present (prevents duplicates) 1575 const existing = document.getElementById('pro-preview-card'); 1576 if (existing) existing.remove(); 1577 1578 const card = document.createElement('div'); 1579 card.id = 'pro-preview-card'; 1580 card.className = 'pro-preview-card'; 1581 card.innerHTML = [ 1582 '<div class="pro-preview-card-header">', 1583 ' <span class="pro-preview-card-badge">PRO</span>', 1584 ' <span class="pro-preview-card-title">Unlock your full retirement tax plan</span>', 1585 ' <button class="pro-preview-card-close" aria-label="Dismiss">×</button>', 1586 '</div>', 1587 '<p class="pro-preview-card-pitch">PRO turns this into your full <strong>year-by-year tax-aware withdrawal & Roth-conversion plan</strong> â plus the portfolio optimizer, higher-precision runs, and PDF export. <strong>One-time $' + QC_PRICING.personal.price_usd + ', yours forever.</strong></p>', 1588 '<div class="pro-preview-card-grid">', 1589 ' <div class="pro-preview-card-item" data-feature="optimizer">', 1590 ' <div class="pro-preview-card-blur">', 1591 ' <div class="pro-preview-mock-chart">', 1592 ' <div class="mock-bar" style="height:70%;background:#2563eb"></div>', 1593 ' <div class="mock-bar" style="height:50%;background:#7c3aed"></div>', 1594 ' <div class="mock-bar" style="height:80%;background:#059669"></div>', 1595 ' <div class="mock-bar" style="height:30%;background:#d97706"></div>', 1596 ' <div class="mock-bar" style="height:15%;background:#6b7280"></div>', 1597 ' </div>', 1598 ' </div>', 1599 ' <span class="pro-preview-card-label">Portfolio Optimizer</span>', 1600 ' <span class="pro-preview-card-desc">Find your optimal allocation across 3 life phases</span>', 1601 ' </div>', 1602 ' <div class="pro-preview-card-item" data-feature="pdf">', 1603 ' <div class="pro-preview-card-blur">', 1604 ' <div class="pro-preview-mock-pdf">', 1605 ' <div class="mock-pdf-line" style="width:80%"></div>', 1606 ' <div class="mock-pdf-line" style="width:60%"></div>', 1607 ' <div class="mock-pdf-line" style="width:90%"></div>', 1608 ' <div class="mock-pdf-block"></div>', 1609 ' <div class="mock-pdf-line" style="width:70%"></div>', 1610 ' </div>', 1611 ' </div>', 1612 ' <span class="pro-preview-card-label">PDF Reports</span>', 1613 ' <span class="pro-preview-card-desc">Export white-label reports for clients</span>', 1614 ' </div>', 1615 ' <div class="pro-preview-card-item" data-feature="plan">', 1616 ' <div class="pro-preview-card-blur">', 1617 ' <div class="pro-preview-mock-sims">',
1618 ' <span class="mock-sim-number">30 yrs</span>', 1619 ' <span class="mock-sim-label">tax plan</span>', 1620 ' </div>', 1621 ' </div>', 1622 ' <span class="pro-preview-card-label">Full Year-by-Year Plan</span>', 1623 ' <span class="pro-preview-card-desc">Your complete withdrawal & Roth-conversion schedule</span>', 1624 ' </div>', 1625 '</div>', 1626 '<button type="button" class="primary-btn full-width pro-preview-card-cta">', 1627 ' See your full year-by-year plan â $' + QC_PRICING.personal.price_usd + ' once', 1628 '</button>', 1629 '<a href="#" class="pro-preview-card-license-link">Already have a key? Activate here</a>', 1630 ].join('\n'); 1631 1632 // Insert right after the results summary card (before the charts). 1633 // Anchored by id â the old '.panel-body .gauge-container' traversal came 1634 // from the classic layout and matches nothing in this markup. 1635 const resultsSummary = document.getElementById('resultsSummary'); 1636 if (resultsSummary && resultsSummary.parentNode) { 1637 resultsSummary.parentNode.insertBefore(card, resultsSummary.nextSibling); 1638 } else { 1639 // Fallback: append to the results column 1640 const main = document.querySelector('.main-results'); 1641 if (main) main.appendChild(card); 1642 } 1643 1644 // GA4: soft pitch shown after every free-tier sim. Use a distinct event 1645 // name from upgrade_modal_view so the hard-modal metric stays untainted 1646 // by the every-sim card display (~177 displays/30d in May 2026). 1647 // Fired here (after insertion, not before) so the event reflects the 1648 // card actually landing in the DOM rather than merely being requested, 1649 // and once per session (see trackUpgradePreviewOnce) so re-running a sim 1650 // doesn't inflate the "shown" denominator. `isConnected` covers the case 1651 // where both anchors are missing and the card never reaches the document. 1652 if (card.isConnected) { 1653 trackUpgradePreviewOnce('pro_preview_card', 'free_sim_success'); 1654 } 1655 1656 // Event listeners 1657 card.querySelector('.pro-preview-card-close').addEventListener('click', () => card.remove()); 1658 card.querySelector('.pro-preview-card-cta').addEventListener('click', () => { 1659 card.remove(); 1660 showUpgradePopup({ context: 'Your full year-by-year tax-aware withdrawal & Roth-conversion plan, the portfolio optimizer, and PDF export. One-time $' + QC_PRICING.personal.price_usd + ', yours forever.', source: 'pro_preview_card' }); 1661 }); 1662 var licenseLink = card.querySelector('.pro-preview-card-license-link'); 1663 if (licenseLink) { 1664 licenseLink.addEventListener('click', function(e) { 1665 e.preventDefault(); 1666 if (typeof showLicenseFromPopup === 'function') showLicenseFromPopup(); 1667 }); 1668 } 1669 card.querySelectorAll('.pro-preview-card-item').forEach(item => { 1670 item.style.cursor = 'pointer'; 1671 item.addEventListener('click', () => { 1672 const feature = item.dataset.feature; 1673 const messages = { 1674 'optimizer': 'Portfolio optimization finds your ideal allocation across life phases.', 1675 'pdf': 'Export presentation-ready PDF reports â white-label for advisors.', 1676 'plan': 'Your full year-by-year tax-aware withdrawal & Roth-conversion plan.' 1677 }; 1678 showUpgradePopup({ context: messages[feature] || 'Upgrade for full access.', source: 'pro_preview_card' }); 1679 }); 1680 }); 1681} 1682 1683/** 1684 * Savings-hero CTA â shown after a free user's BASIC (non-tax) sim. Invites 1685 * them to add account balances and see their lifetime tax savings for FREE. 1686 * This is the conversion hook + the "investment" that creates something-to-lose. 1687 * The full year-by-year schedule it reveals is value-gated (see updateWithdrawalTable). 1688 */ 1689function showSavingsHeroCTA() { 1690 if (document.getElementById('savings-hero-cta')) return; 1691 if (sessionGet('quantcalc_savings_hero_dismissed') === '1') return; 1692 if (AppState.inputs.taxAware) return; // tax mode already on â nothing to invite 1693 1694 const card = document.createElement('div'); 1695 card.id = 'savings-hero-cta'; 1696 card.className = 'savings-hero-cta'; 1697 card.innerHTML = [ 1698 '<button type="button" class="savings-hero-cta-close" aria-label="Dismiss">×</button>', 1699 '<div class="savings-hero-cta-body">', 1700 ' <span class="savings-hero-cta-emoji" aria-hidden="true">
1700ð¸</span>', 1701 ' <div class="savings-hero-cta-text">', 1702 ' <strong>See how much you could save in taxes</strong>', 1703 ' <span>Add your account balances and we\'ll estimate your lifetime tax savings from an optimized withdrawal & Roth-conversion plan â free.</span>', 1704 ' </div>', 1705 '</div>', 1706 '<button type="button" class="primary-btn full-width savings-hero-cta-btn">See my tax savings →</button>' 1707 ].join('\n'); 1708 1709 // Anchored by id (see showProPreviewCard) â the classic-layout traversal 1710 // matched nothing here, so this card previously never rendered in v2. 1711 const resultsSummary = document.getElementById('resultsSummary'); 1712 if (resultsSummary && resultsSummary.parentNode) { 1713 resultsSummary.parentNode.insertBefore(card, resultsSummary.nextSibling); 1714 } else { 1715 const main = document.querySelector('.main-results'); 1716 if (main) main.appendChild(card); 1717 } 1718 1719 // Reuse the existing soft-pitch event name; only the source is new. 1720 // Emitted AFTER insertion and only if the card is really in the document â 1721 // it used to fire at the top of this function, so it counted displays that 1722 // never happened when both anchors were missing. 1723 if (card.isConnected) { 1724 trackUpgradePreviewOnce('savings_hero_cta', 'post_basic_sim'); 1725 } 1726 1727 card.querySelector('.savings-hero-cta-close').addEventListener('click', () => { 1728 sessionSet('quantcalc_savings_hero_dismissed', '1'); 1729 card.remove(); 1730 }); 1731 card.querySelector('.savings-hero-cta-btn').addEventListener('click', () => { 1732 // GA4: this card is injected AFTER the sim, so analytics.js's 1733 // setupCTATracking() â which binds [data-track]/.pro-cta/.pricing-cta 1734 // once at page load â never sees this button and its clicks were 1735 // invisible. Tracked here so the display:click ratio is measurable. 1736 // Reporting only: the free tax-aware flow below is unchanged. 1737 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 1738 window.QuantCalcAnalytics.track('cta_click', { 1739 event_category: 'engagement', 1740 event_label: 'savings_hero_cta' 1741 }); 1742 } 1743 card.remove(); 1744 // Turn on tax-aware mode (auto-distributes an estimate from currentSavings), 1745 // reveal the balance inputs, and re-run so the savings number lands now. 1746 const toggle = document.getElementById('taxAwareToggle'); 1747 if (toggle) toggle.checked = true; 1748 AppState.inputs.taxAware = true; 1749 if (typeof toggleTaxPanel === 'function') toggleTaxPanel(true); 1750 if (typeof runSimulation === 'function') runSimulation();
1751 const panel = document.getElementById('taxSettingsPanel'); 1752 if (panel && panel.scrollIntoView) panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1753 }); 1754} 1755 1756/* ---- Life Event Impact (the post-result life-events paywall) ---- 1757 * 1758 * Where this gate sits and why. 1759 * 1760 * Adding a life event is free for everyone and every event entered is sent to 1761 * the engine, so a visitor who models four events gets a real result computed 1762 * from all four â success rate, median, charts, chart markers. That is the 1763 * whole product working, on their own numbers, before any money is mentioned. 1764 * 1765 * What PRO buys is the ATTRIBUTION: re-running the same plan with one chosen 1766 * event removed, to isolate what that single event does to the outcome. That 1767 * is genuinely new modelling (a counterfactual run per event, which is why it 1768 * needs the PRO simulation quota), it does not exist anywhere else in the app, 1769 * and it is the question a multi-event plan actually raises â "which of these 1770 * is deciding my outcome?" It can only be asked once a result exists, which is 1771 * exactly why the pitch now lives here rather than on the input form. 1772 */ 1773 1774/** 1775 * How many life events a FREE plan carries before the pitch appears. The value 1776 * lives in state.js (LIFE_EVENT_IMPACT_PITCH_AFTER); a top-level `const` there 1777 * is script-scoped, so it reaches us through the window binding. 1778 */ 1779function lifeEventPitchAfter() { 1780 var n = window.LIFE_EVENT_IMPACT_PITCH_AFTER; 1781 return (typeof n === 'number' && n >= 0) ? n : 2; 1782} 1783 1784/** Everything in the request except the life events, as a comparable string. */ 1785function planFingerprintWithoutEvents(request) { 1786 try { 1787 if (!request) return null; 1788 var clone = {}; 1789 Object.keys(request).forEach(function (k) { 1790 if (k === 'lifeEvents' || k === 'life_events') return; 1791 clone[k] = request[k]; 1792 }); 1793 return JSON.stringify(clone); 1794 } catch (e) { 1795 return null; 1796 } 1797} 1798 1799/** 1800 * The measured move between this run and the previous one, but ONLY when the 1801 * life-event list is the single thing that changed. Any other edit (or a 1802 * missing/incomparable fingerprint) returns null and the card stays silent â 1803 * a move we cannot attribute is one we must not claim. 1804 */ 1805function lifeEventMoveSincePreviousRun(request, results) { 1806 var prev = window.__qcLastPlanRun; 1807 if (!prev) return null; 1808 var fp = planFingerprintWithoutEvents(request); 1809 if (!fp || fp !== prev.fingerprint) return null; 1810 var count = ((request && request.lifeEvents) || []).length; 1811 if (count === prev.eventCount) return null; 1812 if (typeof prev.successRate !== 'number') return null; 1813 if (!results || typeof results.success_rate !== 'number') return null; 1814 return { 1815 fromRate: prev.successRate, 1816 toRate: results.success_rate, 1817 fromCount: prev.eventCount, 1818 toCount: count 1819 }; 1820} 1821 1822function rememberPlanRun(request, results) { 1823 window.__qcLastPlanRun = { 1824 fingerprint: planFingerprintWithoutEvents(request), 1825 eventCount: ((request && request.lifeEvents) || []).length, 1826 successRate: (results && typeof results.success_rate === 'number') 1827 ? results.success_rate : null 1828 }; 1829} 1830 1831/** Callers check the value is a finite number before formatting it. */ 1832function formatPlanMoney(v) { 1833 if (Math.abs(v) >= 1e6) return '$' + (v / 1e6).toFixed(1) + 'M'; 1834 if (Math.abs(v) >= 1e3) return '$' + Math.round(v / 1e3) + 'k'; 1835 return '$' + Math.round(v).toLocaleString('en-US'); 1836} 1837 1838/** 1839 * Life Event Impact card â rendered under the results summary after a run that 1840 * carried life events. PRO measures each event; FREE sees its own events named 1841 * with the measurement locked. 1842 */ 1843function showLifeEventImpactCard(request, results, move) { 1844 var existing = document.getElementById('life-event-impact-card'); 1845 if (existing) existing.remove(); 1846 try { 1847 if (sessionGet('quantcalc_life_event_impact_dismissed') === '1') return; 1848 } catch (e) { /* storage blocked â render anyway */ } 1849 1850 var canMeasure = !!(typeof AppState !== 'undefined' && 1851 AppState.canMeasureLifeEventImpact && AppState.canMeasureLifeEventImpact(
1851)); 1852 1853 // The engine echoes back exactly what it modelled â prefer that over the 1854 // request so the card can never name an event the run did not include. 1855 var events = (results && Array.isArray(results.lifeEvents) && results.lifeEvents.length) 1856 ? results.lifeEvents 1857 : ((request && request.lifeEvents) || []); 1858 if (!events.length) return; 1859 if (!canMeasure && events.length <= lifeEventPitchAfter()) return; 1860 1861 var card = document.createElement('div'); 1862 card.id = 'life-event-impact-card'; 1863 card.className = 'life-event-impact-card'; 1864 1865 var header = document.createElement('div'); 1866 header.className = 'lei-header'; 1867 if (!canMeasure) { 1868 var badge = document.createElement('span'); 1869 badge.className = 'lei-badge'; 1870 badge.textContent = 'PRO'; 1871 header.appendChild(badge); 1872 } 1873 var title = document.createElement('span'); 1874 title.className = 'lei-title'; 1875 title.textContent = 'Life event impact'; 1876 header.appendChild(title); 1877 var close = document.createElement('button'); 1878 close.type = 'button'; 1879 close.className = 'lei-close'; 1880 close.setAttribute('aria-label', 'Dismiss'); 1881 close.innerHTML = '×'; 1882 header.appendChild(close); 1883 card.appendChild(header); 1884 1885 var lede = document.createElement('p'); 1886 lede.className = 'lei-lede'; 1887 lede.textContent = 'Your result above is modelled with all ' + events.length + 1888 ' of your life events.'; 1889 if (move) { 1890 lede.textContent += ' Changing them moved your success rate from ' + 1891 Math.round(move.fromRate * 100) + '% to ' + Math.round(move.toRate * 100) + '%.'; 1892 } 1893 card.appendChild(lede); 1894 1895 var sub = document.createElement('p'); 1896 sub.className = 'lei-sub'; 1897 sub.textContent = canMeasure 1898 ? 'Measure any one of them to see what your plan looks like without it.' 1899 : 'PRO measures them one at a time â your success rate and median with a single event taken out â so you can see which one is deciding the outcome.'; 1900 card.appendChild(sub); 1901 1902 var list = document.createElement('ul'); 1903 list.className = 'lei-list'; 1904 events.forEach(function (ev, i) { 1905 var name = (ev && ev.label) ? String(ev.label) : 'Life event ' + (i + 1); 1906 var item = document.createElement('li'); 1907 item.className = 'lei-item'; 1908 1909 var nameEl = document.createElement('span'); 1910 nameEl.className = 'lei-name'; 1911 nameEl.textContent = name; 1912 item.appendChild(nameEl); 1913 1914 var out = document.createElement('span'); 1915 out.className = 'lei-result'; 1916 item.appendChild(out); 1917 1918 var btn = document.createElement('button'); 1919 btn.type = 'button'; 1920 // Deliberately NOT `.pro-locked-btn`: updateTierUI() strips that class 1921 // on an in-session upgrade, which would unlock the look of a button 1922 // whose handler is still the FREE one until the next run re-renders. 1923 btn.className = 'lei-measure' + (canMeasure ? '' : ' lei-locked'); 1924 btn.dataset.eventIndex = String(i); 1925 btn.textContent = canMeasure ? 'Measure' : 'Measure ð'; 1926 btn.addEventListener('click', function () { 1927 if (canMeasure) { 1928 measureLifeEventImpact(i, name, btn, out); 1929 } else if (typeof showUpgradePopup === 'function') { 1930 showUpgradePopup({ 1931 context: 'Life event impact: your result already includes "' + name + 1932 '". PRO runs your plan again without it and reports what changes â the success rate and median that one event is responsible for. One-time $' + 1933 QC_PRICING.personal.price_usd + ', yours forever.', 1934 source: 'life_events_impact_gate' 1935 }); 1936 } 1937 }); 1938 item.appendChild(btn); 1939 list.appendChild(item); 1940 }); 1941 card.appendChild(list); 1942 1943 if (!canMeasure) { 1944 var cta = document.createElement('button'); 1945 cta.type = 'button'; 1946 cta.className = 'primary-btn full-width lei-cta'; 1947 cta.textContent = 'Measure every life event in my plan â $' + 1948 QC_PRICING.personal.price_usd + ' once'; 1949 cta.addEventListener('click', function () { 1950 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 1951 window.QuantCalcAnalytics.track('cta_click', { 1952 event_category: 'engagement', 1953 event_label: 'life_event_impact_card' 1954 }); 1955 } 1956 if (typeof showUpgradePopup === 'function') { 1957 showUpgradePopup({ 1958 context: 'Life event impact: your plan already runs with all ' + 1959 events.length + ' of your life events. PRO measures each one on its own â the success rate and median when the plan runs without it. One-time $' + 1960 QC_PRICING.personal.price_usd + ', yours forever.', 1961 source: 'life_events_impact_gate' 1962 }); 1963 } 1964 }); 1965 card.appendChild(cta); 1966 } 1967 1968 var resultsSummary = document.getElementById('resultsSummary'); 1969 if (resultsSummary && resultsSummary.parentNode) { 1970 resultsSummary.parentNode.insertBefore(card, resultsSummary.nextSibling); 1971 } else { 1972 var main = document.querySelector('.main-results'); 1973 if (main) main.appendChild(card); 1974 } 1975 1976 close.addEventListener('click', function () { 1977 sessionSet('quantcalc_life_event_impact_dismissed', '1'); 1978 card.remove(); 1979 }); 1980 1981 // Report the display only once it has really landed, and once per session, 1982 // matching the other soft-pitch surfaces (see trackUpgradePreviewOnce). 1983 if (card.isConnected && !canMeasure) { 1984 trackUpgradePreviewOnce('life_events_impact_card', 'post_sim_life_events'); 1985 } 1986} 1987// TEST SHIM (inert): classic scripts already expose these declarations as 1988// globals in the browser; the explicit bindings let the vitest suite drive the 1989// life-event attribution helpers directly. No behaviour change. 1990window.showLifeEventImpactCard = showLifeEventImpactCard; 1991window.lifeEventPitchAfter = lifeEventPitchAfter; 1992window.planFingerprintWithoutEvents = planFingerprintWithoutEvents; 1993window.lifeEventMoveSincePreviousRun = lifeEventMoveSincePreviousRun; 1994window.rememberPlanRun = rememberPlanRun; 1995window.formatPlanMoney = formatPlanMoney; 1996 1997/** 1998 * PRO: re-run the current plan with one life event removed and report what 1999 * changed. Uses the ordinary simulate endpoint â no new backend surface â and 2000 * the PRO daily quota, which is what makes per-event attribution affordable. 2001 */ 2002async function measureLifeEventImpact(index, name, btn, out) { 2003 if (!(typeof AppState !== 'undefined' && AppState.canMeasureLifeEventImpact &&
2004 AppState.canMeasureLifeEventImpact())) return; 2005 var base = AppState.results; 2006 if (!base || typeof base.success_rate !== 'number') { 2007 out.textContent = 'Run your plan first.'; 2008 return; 2009 } 2010 var previousLabel = btn.textContent; 2011 btn.disabled = true; 2012 btn.textContent = 'Measuringâ¦'; 2013 out.textContent = ''; 2014 try { 2015 var req = AppState.getSimulationRequest(); 2016 var all = (req && req.lifeEvents) || []; 2017 req.lifeEvents = all.filter(function (_, i) { return i !== index; }); 2018 2019 var client = activeAPI; 2020 if (!client && typeof getAPIClient === 'function') client = await getAPIClient(); 2021 if (!client) throw new Error('No API client'); 2022 2023 var resp = await client.simulate(req); 2024 if (!resp || !resp.success || !resp.results || 2025 typeof resp.results.success_rate !== 'number') { 2026 throw new Error((resp && resp.error) || 'Simulation failed'); 2027 } 2028 2029 var withoutRate = resp.results.success_rate; 2030 // Positive = the plan does BETTER without the event, i.e. the event is 2031 // what costs those points. Negative = the event is carrying the plan. 2032 var pts = Math.round((withoutRate - base.success_rate) * 100); 2033 var verdict; 2034 if (pts > 0) { 2035 verdict = 'costs you ' + pts + ' point' + (pts === 1 ? '' : 's'); 2036 } else if (pts < 0) { 2037 verdict = 'adds ' + (-pts) + ' point' + (pts === -1 ? '' : 's'); 2038 } else { 2039 verdict = 'no measurable change'; 2040 } 2041 var line = 'Without it: ' + Math.round(withoutRate * 100) + '% (' + verdict + ')'; 2042 if (typeof resp.results.median_final_value === 'number' && 2043 typeof base.median_final_value === 'number') { 2044 line += ', median ' + formatPlanMoney(base.median_final_value) + ' â ' + 2045 formatPlanMoney(resp.results.median_final_value); 2046 } 2047 out.textContent = line; 2048 btn.textContent = 'Measured'; 2049 2050 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.trackProUsage) { 2051 window.QuantCalcAnalytics.trackProUsage('pro_life_event_impact', { 2052 event_label: 'life_event_measured' 2053 }); 2054 } 2055 } catch (e) { 2056 out.textContent = 'Could not measure that event right now â please try again.'; 2057 btn.disabled = false; 2058 btn.textContent = previousLabel; 2059 } 2060} 2061window.measureLifeEventImpact = measureLifeEventImpact; 2062 2063/* ---- Save-plan magic link (the "something to lose" lever) ---- */ 2064function showSavePlanModal() { 2065 const modal = document.getElementById('savePlanModal'); 2066 if (!modal) return; 2067 const result = document.getElementById('savePlanResult'); 2068 const form = document.getElementById('savePlanForm'); 2069 if (result) { result.classList.add('hidden'); result.innerHTML = ''; } 2070 if (form) form.classList.remove('hidden'); 2071 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 2072 window.QuantCalcAnalytics.track('save_plan_view', { event_category: 'engagement', event_label: 'save_plan_modal' }); 2073 } 2074 if (typeof openModal === 'function') openModal(modal); else modal.classList.remove('hidden'); 2075} 2076 2077function hideSavePlanModal() { 2078 const modal = document.getElementById('savePlanModal'); 2079 if (!modal) return; 2080 if (typeof closeModal === 'function') closeModal(modal); else modal.classList.add('hidden'); 2081} 2082 2083function initSavePlan() { 2084 const btn = document.getElementById('savePlanBtn'); 2085 if (btn) btn.addEventListener('click', showSavePlanModal); 2086 2087 const form = document.getElementById('savePlanForm'); 2088 if (!form) return; 2089 form.addEventListener('submit', async (e) => { 2090 e.preventDefault(); 2091 const emailEl = document.getElementById('savePlanEmail'); 2092 const submit = document.getElementById('savePlanSubmit'); 2093 const result = document.getElementById('savePlanResult'); 2094 const email = ((emailEl && emailEl.value) || '').trim(); 2095 if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 2096 if (result) { result.classList.remove('hidden'); result.innerHTML = '<span class="save-plan-err">Please enter a valid email.</span>'; } 2097 return; 2098 } 2099 if (submit) { submit.disabled = true; submit.textContent = 'Savingâ¦'; } 2100 try { 2101 // Include a small summary (the savings figure, if a tax-aware sim ran) 2102 // so the email can lead with the dollar value, not a bare link. 2103 var summary = null; 2104 try { 2105 var ts = AppState.results && AppState.results.taxSummary; 2106 if (ts && typeof ts.taxSavings === 'number' && ts.taxSavings > 0) { 2107 summary = { savings: ts.taxSavings }; 2108 } 2109 } catch (e) { /* summary is optional */ } 2110 var optInEl = document.getElementById('savePlanOptIn'); 2111 var optIn = !!(optInEl && optInEl.checked); 2112 const resp = await fetch('/_plans/save', { 2113 method: 'POST', 2114 headers: { 'content-type': 'application/json' }, 2115 body: JSON.stringify({ plan: AppState.inputs, email: email, summary: summary, optIn: optIn }) 2116 }); 2117 if (resp.ok) { 2118 const data = await resp.json(); 2119 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 2120 window.QuantCalcAnalytics.track('lead_capture_success', { event_category: 'conversion_funnel', upgrade_source: 'save_plan', event_label: 'save_plan' }); 2121 } 2122 if (form) form.classList.add('hidden'); 2123 if (result) { 2124 result.classList.remove('hidden'); 2125 const link = (data && data.link) ? data.link : ''; 2126 const emailedMsg = (data && data.emailed) 2127 ? 'We just emailed it to you too.' 2128 : 'Bookmark this link to come back to your plan.'; 2129 result.innerHTML = '<strong>Saved!</strong> ' + emailedMsg + 2130 (link ? '<br><a href="' + link + '" class="save-plan-link">' + link + '</a>' : ''); 2131 } 2132 } else if (result) {
2133 // KV not live yet (503) or other error â graceful, plan isn't lost (still in localStorage). 2134 result.classList.remove('hidden'); 2135 result.innerHTML = '<span class="save-plan-err">Saving isn\'t available right now â your plan is still here; please try again later.</span>'; 2136 } 2137 } catch (err) { 2138 if (result) { result.classList.remove('hidden'); result.innerHTML = '<span class="save-plan-err">Network error â please try again.</span>'; } 2139 } finally { 2140 if (submit) { submit.disabled = false; submit.textContent = 'Save & email me the link'; } 2141 } 2142 }); 2143} 2144 2145/** 2146 * Daily-limit lead capture (option A â capture-only, no auto-send). 2147 * On submit, POST to /_leads (CF Worker handler â R2 leads/YYYY-MM/{uuid}.json). 2148 * On success, swap the form for a thank-you message and remember in 2149 * localStorage so we don't ask the same browser again. 2150 * 2151 * 2026-08-13: the form was removed from #dailyLimitModal in app.html â its 2152 * "notified when your runs reset" promise had no sender behind it. The modal 2153 * now hands off to the save-plan magic link (a real, immediate email) instead. 2154 * This initializer is kept as a guarded no-op: the block's DOM ids no longer 2155 * exist on the live page, and js-classic still carries its own copy. 2156 */ 2157function initDailyLimitLeadCapture() { 2158 const block = document.getElementById('dailyLimitLeadCapture'); 2159 const form = document.getElementById('dailyLimitLeadForm'); 2160 const status = document.getElementById('dailyLimitLeadStatus'); 2161 if (!block || !form || !status) return; 2162 2163 // Already captured in this browser? Render the thank-you state immediately.
2164 let captured = false; 2165 try { captured = localStorage.getItem('quantcalc_lead_captured') === '1'; } catch (e) {} 2166 if (captured) { 2167 block.classList.add('submitted'); 2168 status.textContent = 'Thanks â you\'re on the list.'; 2169 status.classList.add('success'); 2170 return; 2171 } 2172 2173 form.addEventListener('submit', async (e) => { 2174 e.preventDefault(); 2175 const emailEl = document.getElementById('dailyLimitLeadEmail'); 2176 const submitEl = form.querySelector('.daily-limit-lead-submit'); 2177 const email = (emailEl.value || '').trim(); 2178 if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 2179 status.textContent = 'Please enter a valid email address.'; 2180 status.className = 'daily-limit-lead-status error'; 2181 return; 2182 } 2183 status.textContent = 'Sendingâ¦'; 2184 status.className = 'daily-limit-lead-status'; 2185 submitEl.disabled = true; 2186 2187 try { 2188 const resp = await fetch('/_leads', { 2189 method: 'POST', 2190 headers: { 'content-type': 'application/json' }, 2191 body: JSON.stringify({ email, source: 'daily_limit_modal' }), 2192 }); 2193 if (!resp.ok) { 2194 if (resp.status === 429) { 2195 status.textContent = 'Too many requests â try again in a bit.'; 2196 } else if (resp.status === 400) { 2197 status.textContent = 'That email doesn\'t look valid.'; 2198 } else { 2199 status.textContent = 'Couldn\'t save â please try again.'; 2200 } 2201 status.className = 'daily-limit-lead-status error'; 2202 submitEl.disabled = false; 2203 return; 2204 } 2205 // Success state: swap form for thank-you, mark in localStorage so 2206 // we don't pester this browser again, fire GA4 event. 2207 block.classList.add('submitted'); 2208 status.textContent = 'Thanks â we\'ll let you know.'; 2209 status.className = 'daily-limit-lead-status success'; 2210 try { localStorage.setItem('quantcalc_lead_captured', '1'); } catch (e) {} 2211 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 2212 window.QuantCalcAnalytics.track('lead_capture_success', { 2213 event_category: 'conversion_funnel', 2214 upgrade_source: 'daily_limit_modal', 2215 event_label: 'email_captured' 2216 }); 2217 } 2218 } catch (err) { 2219 status.textContent = 'Network error â please try again.'; 2220 status.className = 'daily-limit-lead-status error'; 2221 submitEl.disabled = false; 2222 } 2223 }); 2224} 2225 2226/** 2227 * Email-capture form inside the upgrade modal's close-detour block. 2228 * Mirrors initDailyLimitLeadCapture exactly â same /_leads endpoint, same 2229 * localStorage dedup key, same source tagging. The point is: if the user 2230 * closes the upgrade modal without buying, we still get a way to reach 2231 * them with future features instead of losing them entirely. 2232 */ 2233function initUpgradeLeadCapture() { 2234 const block = document.getElementById('upgradeLeadCapture'); 2235 const form = document.getElementById('upgradeLeadForm'); 2236 const status = document.getElementById('upgradeLeadStatus'); 2237 if (!block || !form || !status) return; 2238 2239 let captured = false; 2240 try { captured = localStorage.getItem('quantcalc_lead_captured') === '1'; } catch (e) {} 2241 if (captured) { 2242 block.classList.add('submitted'); 2243 status.textContent = 'Thanks â youâre on the list.'; 2244 status.classList.add('success'); 2245 return; 2246 } 2247 2248 form.addEventListener('submit', async (e) => { 2249 e.preventDefault(); 2250 const emailEl = document.getElementById('upgradeLeadEmail'); 2251 const submitEl = form.querySelector('.upgrade-lead-submit'); 2252 const email = (emailEl.value || '').trim(); 2253 if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 2254 status.textContent = 'Please enter a valid email address.'; 2255 status.className = 'upgrade-lead-status error'; 2256 return; 2257 } 2258 status.textContent = 'Sendingâ¦'; 2259 status.className = 'upgrade-lead-status'; 2260 submitEl.disabled = true; 2261 2262 try { 2263 const resp = await fetch('/_leads', { 2264 method: 'POST', 2265 headers: { 'content-type': 'application/json' }, 2266 body: JSON.stringify({ email, source: 'upgrade_modal_close' }), 2267 }); 2268 if (!resp.ok) { 2269 if (resp.status === 429) { 2270 status.textContent = 'Too many requests â try again in a bit.'; 2271 } else if (resp.status === 400) { 2272 status.textContent = 'That email doesnât look valid.'; 2273 } else { 2274 status.textContent = 'Couldnât save â please try again.'; 2275 } 2276 status.className = 'upgrade-lead-status error'; 2277 submitEl.disabled = false;
2278 return; 2279 } 2280 block.classList.add('submitted'); 2281 status.textContent = 'Thanks â weâll let you know.'; 2282 status.className = 'upgrade-lead-status success'; 2283 try { localStorage.setItem('quantcalc_lead_captured', '1'); } catch (e) {} 2284 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 2285 window.QuantCalcAnalytics.track('lead_capture_success', { 2286 event_category: 'conversion_funnel', 2287 upgrade_source: 'upgrade_modal_close', 2288 event_label: 'email_captured' 2289 }); 2290 } 2291 // Close the modal a beat after success so the user sees the 2292 // confirmation but doesn't have to manually click again. 2293 setTimeout(() => hideUpgradePopup({ skipDetour: true }), 1500); 2294 } catch (err) { 2295 status.textContent = 'Network error â please try again.'; 2296 status.className = 'upgrade-lead-status error'; 2297 submitEl.disabled = false; 2298 } 2299 }); 2300} 2301 2302/** 2303 * Initialize PRO lock overlays on free-tier-restricted panels. 2304 * Called on DOMContentLoaded, adds visual lock indicators for free users. 2305 */ 2306function initProLockOverlays() { 2307 if (typeof AppState !== 'undefined' && AppState.isPro()) return; 2308 2309 // Optimizer panel header â add PRO lock badge 2310 addProLockBadge('panel-body-7', 'Portfolio Optimizer', 'optimization'); 2311 2312 // PDF export button. A FREE user gets exactly ONE report (operator decision 2313 // 2026-09-02), so until it is used the button must read as an invitation, 2314 // not a lock â a "PRO" pill on a feature they can actually use is the 2315 // fastest way to make sure nobody discovers the free report exists. 2316 const freeReportLeft = (typeof freePdfAvailable === 'function') && freePdfAvailable(); 2317 const exportBtn = document.getElementById('exportPdfBtn'); 2318 if (exportBtn && !exportBtn.querySelector('.pro-lock-icon')) { 2319 const lock = document.createElement('span'); 2320 lock.className = 'pro-lock-icon'; 2321 lock.textContent = freeReportLeft ? ' 1 free' : ' PRO'; 2322 lock.setAttribute('aria-label', freeReportLeft ? 'One free report included, at full 10,000-path precision' : 'Requires PRO'); 2323 exportBtn.appendChild(lock); 2324 if (!freeReportLeft) exportBtn.classList.add('pro-locked-btn'); 2325 } 2326 2327 // PDF settings button â usable for the one free report too 2328 const pdfSettingsBtn = document.getElementById('pdfSettingsBtn'); 2329 if (pdfSettingsBtn && !freeReportLeft) { 2330 pdfSettingsBtn.classList.add('pro-locked-btn'); 2331 } 2332 2333 // Advanced Modeling panel â add PRO lock overlay 2334 const advPanel = document.getElementById('panel-body-3'); 2335 if (advPanel && !advPanel.querySelector('.pro-lock-overlay')) { 2336 addProLockOverlay(advPanel, 'Advanced Modeling', 'advanced_modeling'); 2337 } 2338 2339 // Make export actions visible for free users â locked only once the one 2340 // free report has been used. 2341 const exportActions = document.getElementById('exportActions'); 2342 if (exportActions) { 2343 exportActions.classList.remove('hidden'); 2344 if (!freeReportLeft) exportActions.classList.add('pro-locked-section'); 2345 } 2346} 2347 2348/** 2349 * Add a clickable PRO lock badge to a panel header 2350 */ 2351function addProLockBadge(panelBodyId, featureName, featureKey) { 2352 const panelBody = document.getElementById(panelBodyId); 2353 if (!panelBody) return; 2354 const panel = panelBody.closest('.panel'); 2355 if (!panel) return; 2356 const header = panel.querySelector('.panel-header h2'); 2357 if (!header || header.querySelector('.pro-lock-badge')) return; 2358 2359 const badge = document.createElement('span'); 2360 badge.className = 'pro-lock-badge'; 2361 badge.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg> PRO'; 2362 badge.style.cursor = 'pointer'; 2363 badge.addEventListener('click', (e) => { 2364 e.stopPropagation(); 2365 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 2366 window.QuantCalcAnalytics.track('pro_feature_blocked', { 2367 feature: featureKey, 2368 surface: 'lock_badge' 2369 }); 2370 } 2371 showUpgradePopup({ context: featureName + ' requires a PRO subscription.' }); 2372 }); 2373 header.appendChild(badge); 2374} 2375 2376/** 2377 * Add a semi-transparent PRO lock overlay on a panel body 2378 */ 2379function addProLockOverlay(container, featureName, featureKey) {
2380 const overlay = document.createElement('div'); 2381 overlay.className = 'pro-lock-overlay'; 2382 overlay.innerHTML = [ 2383 '<div class="pro-lock-overlay-content">', 2384 ' <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>', 2385 ' <span class="pro-lock-overlay-text">' + featureName + '</span>', 2386 ' <button type="button" class="pro-lock-overlay-btn">Unlock with PRO</button>', 2387 '</div>' 2388 ].join(''); 2389 2390 overlay.addEventListener('click', () => { 2391 if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.track) { 2392 window.QuantCalcAnalytics.track('pro_feature_blocked', { 2393 feature: featureKey, 2394 surface: 'lock_overlay' 2395 }); 2396 } 2397 showUpgradePopup({ context: featureName + ' requires a PRO subscription.' }); 2398 }); 2399 2400 container.style.position = 'relative'; 2401 container.appendChild(overlay); 2402}
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.