PageSourceSearch

https://quantcalc.app/js/app.js?v=a4a94e4a

js quantcalc.app collected 2026-10-02 01:15:23 UTC 110,647 bytes, 2,402 lines download raw bytes

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 &amp; 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">&times;</button>',
1586        '</div>',
1587        '<p class="pro-preview-card-pitch">PRO turns this into your full <strong>year-by-year tax-aware withdrawal &amp; 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 &amp; 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">&times;</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 &amp; 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 &rarr;</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 = '&times;';
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.