From 4b457b301dd538afe7b2cca9c33e381c122e85b8 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 25 May 2026 16:09:59 +0300 Subject: [PATCH 01/13] Reduce pricing-page calculator INP on mobile Slider drag fired continuous `input` events that each rebuilt the full results-panel HTML, re-read two CSS custom properties via `getComputedStyle`, and re-bound a click listener per addon button. On mid-range Android this pushed the worst interaction past the 200 ms INP threshold (field: 220 ms). - Cache `--sl-color-text-accent` / `--sl-color-gray-5` once in `sliderProgress`; invalidate via MutationObserver on `data-theme`. State lives on `window` so the `is:inline` script can inline once per modal instance without redeclaration errors. - rAF-batch `calculate()` in all six calculators so continuous input events (slider drag, typing) trigger at most one rebuild per frame. Final-state events (`change`, `blur`) still commit immediately. - Replace per-render `[data-enable-addon]` listener rebinding in TbPayg and TbPrivateCloud calculators with a single delegated click listener on the results container. --- src/components/Pricing/CalculatorModal.astro | 25 +++++++++-- src/components/Pricing/TbPaygCalculator.astro | 45 ++++++++++++------- .../Pricing/TbPerpetualCalculator.astro | 17 +++++-- .../Pricing/TbPrivateCloudCalculator.astro | 42 ++++++++++------- .../Pricing/TbmqPaygCalculator.astro | 15 +++++-- .../Pricing/TbmqPerpetualCalculator.astro | 14 ++++-- .../Pricing/TbmqPrivateCloudCalculator.astro | 12 ++++- 7 files changed, 121 insertions(+), 49 deletions(-) diff --git a/src/components/Pricing/CalculatorModal.astro b/src/components/Pricing/CalculatorModal.astro index 440af8fc1..302e983f1 100644 --- a/src/components/Pricing/CalculatorModal.astro +++ b/src/components/Pricing/CalculatorModal.astro @@ -1379,12 +1379,31 @@ const { id } = Astro.props as Props; // ─── Shared slider utilities (global) ─── + // Cache the two theme-dependent CSS custom properties on `window` so + // continuous slider `input` events don't force a fresh style recalc per call. + // State lives on `window` because this `is:inline` script is inlined once per + // instance on the page — a block-scoped `let` would throw + // "already declared" on the 2nd/3rd inlining. Bust the cache when Starlight + // flips `data-theme` on . + function _readSliderColors() { + const cs = getComputedStyle(document.documentElement); + window._sliderColors = { + primary: cs.getPropertyValue('--sl-color-text-accent').trim() || '#3D50F5', + track: cs.getPropertyValue('--sl-color-gray-5').trim() || '#E0E1E2', + }; + return window._sliderColors; + } + if (!window._sliderColorsObserved) { + window._sliderColorsObserved = true; + new MutationObserver(function() { window._sliderColors = null; }) + .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + } + /** Update slider track fill via background linear-gradient */ window.sliderProgress = function(slider) { const progress = ((parseFloat(slider.value) - parseFloat(slider.min)) / (parseFloat(slider.max) - parseFloat(slider.min))) * 100; - const primary = getComputedStyle(document.documentElement).getPropertyValue('--sl-color-text-accent').trim() || '#3D50F5'; - const track = getComputedStyle(document.documentElement).getPropertyValue('--sl-color-gray-5').trim() || '#E0E1E2'; - slider.style.background = 'linear-gradient(to right, ' + primary + ' ' + progress + '%, ' + track + ' ' + progress + '%)'; + const c = window._sliderColors || _readSliderColors(); + slider.style.background = 'linear-gradient(to right, ' + c.primary + ' ' + progress + '%, ' + c.track + ' ' + progress + '%)'; }; /** Position tick marks using JS (compensates for thumb width) */ diff --git a/src/components/Pricing/TbPaygCalculator.astro b/src/components/Pricing/TbPaygCalculator.astro index bccadbff2..dd907b03e 100644 --- a/src/components/Pricing/TbPaygCalculator.astro +++ b/src/components/Pricing/TbPaygCalculator.astro @@ -379,23 +379,24 @@ import CalculatorModal from './CalculatorModal.astro'; footer.innerHTML = `
Total${fmt(total)}/month${tip(totalParts.join(' + '))}
Get started`; - // Bind events - results.querySelectorAll('[data-enable-addon]').forEach(btn => { - btn.addEventListener('click', () => { - const key = (btn as HTMLElement).dataset.enableAddon as string; - if (key in toggles) { - (toggles as any)[key].checked = true; - (state.addons as any)[key].on = true; - if (key === 'edge') { state.addons.edge.count = 1; edgeCounter.classList.remove('hidden'); } - cards[key as keyof typeof cards].classList.add('active'); - calculate(); - } - }); - }); - sendSmGTM(); } + // Delegated handler for [data-enable-addon] buttons rendered inside the + // results panel. Bound once here instead of per-button on every calculate(), + // which both leaked listeners and wasted CPU on each slider tick. + results.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement)?.closest('[data-enable-addon]') as HTMLElement | null; + if (!btn) return; + const key = btn.dataset.enableAddon as string; + if (!(key in toggles)) return; + (toggles as any)[key].checked = true; + (state.addons as any)[key].on = true; + if (key === 'edge') { state.addons.edge.count = 1; edgeCounter.classList.remove('hidden'); } + cards[key as keyof typeof cards].classList.add('active'); + calculate(); + }); + function renderEnterprise() { let html = `
Deployment Summary
`; html += `

You are building at an impressive scale.

`; @@ -415,10 +416,20 @@ import CalculatorModal from './CalculatorModal.astro'; footer.innerHTML = `Get Enterprise Quote`; } + // rAF-batch calculate() during continuous input (slider drag, typing) so we + // rebuild the results panel at most once per animation frame. Final-state + // events (change/blur) still call calculate() directly to commit. + let _calcQueued = false; + function scheduleCalculate() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calculate(); }); + } + // ─── Slider ─── - slider.addEventListener('input', () => { state.devices = sliderToDevices(parseFloat(slider.value)); devicesInput.value = String(state.devices); updateProgress(); calculate(); }); + slider.addEventListener('input', () => { state.devices = sliderToDevices(parseFloat(slider.value)); devicesInput.value = String(state.devices); updateProgress(); scheduleCalculate(); }); slider.addEventListener('change', () => { const v = parseFloat(slider.value); if (v <= 4) { const s = Math.round(v); slider.value = String(s); state.devices = sliderToDevices(s); devicesInput.value = String(state.devices); updateProgress(); calculate(); } }); - devicesInput.addEventListener('input', () => { const d = parseInt(devicesInput.value); if (!isNaN(d) && d > 0) { state.devices = Math.min(SM_MAX, d); slider.value = String(devicesToSlider(state.devices)); updateProgress(); calculate(); } }); + devicesInput.addEventListener('input', () => { const d = parseInt(devicesInput.value); if (!isNaN(d) && d > 0) { state.devices = Math.min(SM_MAX, d); slider.value = String(devicesToSlider(state.devices)); updateProgress(); scheduleCalculate(); } }); devicesInput.addEventListener('blur', () => { let d = Math.max(1, Math.min(SM_MAX, parseInt(devicesInput.value) || 1)); if (d <= 1000) d = SM_THRESHOLDS.find(t => d <= t) || 1000; state.devices = d; devicesInput.value = String(d); slider.value = String(devicesToSlider(d)); updateProgress(); calculate(); }); // ─── Steppers ─── @@ -441,7 +452,7 @@ import CalculatorModal from './CalculatorModal.astro'; if (key === 'prod') state.prodInstances = v; else if (key === 'dev') state.devInstances = v; else if (key === 'edge') state.addons.edge.count = v; - calculate(); + scheduleCalculate(); }); sInp?.addEventListener('blur', () => { const plan = getPlan(state.devices); diff --git a/src/components/Pricing/TbPerpetualCalculator.astro b/src/components/Pricing/TbPerpetualCalculator.astro index 6e6037027..a3f313494 100644 --- a/src/components/Pricing/TbPerpetualCalculator.astro +++ b/src/components/Pricing/TbPerpetualCalculator.astro @@ -313,9 +313,18 @@ import CalculatorModal from './CalculatorModal.astro'; return msg; } + // rAF-batch calculate() during continuous input (slider drag, typing); final + // state via change/blur still commits immediately. + let _calcQueued = false; + function scheduleCalculate() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calculate(); }); + } + // ─── Slider ─── - slider.addEventListener('input', () => { st.devices = sliderToReal(parseFloat(slider.value)); devInput.value = String(st.devices); sliderProgress(slider); calculate(); }); - devInput.addEventListener('input', () => { const v = parseInt(devInput.value); if (!isNaN(v) && v > 0) { st.devices = v; slider.value = String(Math.min(PERP_SLIDER_MAX, realToSlider(Math.min(PERP_REAL_MAX, v)))); sliderProgress(slider); calculate(); } }); + slider.addEventListener('input', () => { st.devices = sliderToReal(parseFloat(slider.value)); devInput.value = String(st.devices); sliderProgress(slider); scheduleCalculate(); }); + devInput.addEventListener('input', () => { const v = parseInt(devInput.value); if (!isNaN(v) && v > 0) { st.devices = v; slider.value = String(Math.min(PERP_SLIDER_MAX, realToSlider(Math.min(PERP_REAL_MAX, v)))); sliderProgress(slider); scheduleCalculate(); } }); devInput.addEventListener('blur', () => { const v = Math.max(1000, parseInt(devInput.value) || 1000); st.devices = v; devInput.value = String(v); slider.value = String(Math.min(PERP_SLIDER_MAX, realToSlider(Math.min(PERP_REAL_MAX, v)))); sliderProgress(slider); calculate(); }); // ─── Steppers ─── @@ -331,7 +340,7 @@ import CalculatorModal from './CalculatorModal.astro'; calculate(); }); }); - sInp.addEventListener('input', () => { const min = getMin(); const v = parseInt(sInp.value); if (!isNaN(v) && v >= min) { (st as any)[key] = v; ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calculate(); } }); + sInp.addEventListener('input', () => { const min = getMin(); const v = parseInt(sInp.value); if (!isNaN(v) && v >= min) { (st as any)[key] = v; ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; scheduleCalculate(); } }); sInp.addEventListener('blur', () => { const min = getMin(); const v = Math.max(min, parseInt(sInp.value) || min); (st as any)[key] = v; sInp.value = String(v); ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calculate(); }); } bindStepper('#perp-prod-stepper', 'prod', getMinProd); @@ -350,7 +359,7 @@ import CalculatorModal from './CalculatorModal.astro'; calculate(); }); }); - edgeInp.addEventListener('input', () => { const v = parseInt(edgeInp.value); if (!isNaN(v) && v >= 1) { st.addons.edge.count = v; calculate(); } }); + edgeInp.addEventListener('input', () => { const v = parseInt(edgeInp.value); if (!isNaN(v) && v >= 1) { st.addons.edge.count = v; scheduleCalculate(); } }); edgeInp.addEventListener('blur', () => { const min = PERP.edgeInstancesIncluded; const v = Math.max(min, parseInt(edgeInp.value) || min); st.addons.edge.count = v; edgeInp.value = String(v); ($('#perp-edge-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calculate(); }); // ─── Addon toggles ─── diff --git a/src/components/Pricing/TbPrivateCloudCalculator.astro b/src/components/Pricing/TbPrivateCloudCalculator.astro index ce38693cc..b4efaf297 100644 --- a/src/components/Pricing/TbPrivateCloudCalculator.astro +++ b/src/components/Pricing/TbPrivateCloudCalculator.astro @@ -555,12 +555,12 @@ import CalculatorModal from './CalculatorModal.astro'; p.devices = sliderToReal(Number(slider.value)); devInput.value = String(p.devices); sliderProgress(slider); - calculate(); + scheduleCalculate(); }); devInput?.addEventListener('input', () => { const v = Number(devInput.value); - if (!isNaN(v) && v >= 0) { p.devices = v; slider.value = String(Math.min(SLIDER_MAX, realToSlider(Math.min(REAL_MAX, v)))); sliderProgress(slider); calculate(); } + if (!isNaN(v) && v >= 0) { p.devices = v; slider.value = String(Math.min(SLIDER_MAX, realToSlider(Math.min(REAL_MAX, v)))); sliderProgress(slider); scheduleCalculate(); } }); devInput?.addEventListener('blur', () => { p.devices = Math.max(0, Number(devInput.value) || 0); @@ -578,7 +578,7 @@ import CalculatorModal from './CalculatorModal.astro'; input.addEventListener('input', () => { const field = (input as HTMLInputElement).dataset.input!; const v = Number((input as HTMLInputElement).value); - if (!isNaN(v) && v >= 1) { (p as any)[field] = v; calculate(); } + if (!isNaN(v) && v >= 1) { (p as any)[field] = v; scheduleCalculate(); } }); input.addEventListener('blur', () => { const field = (input as HTMLInputElement).dataset.input!; @@ -749,7 +749,7 @@ import CalculatorModal from './CalculatorModal.astro'; calculate(); }); }); - edgeCountInput.addEventListener('input', () => { const v = parseInt(edgeCountInput.value); if (!isNaN(v) && v >= 1) { state.addons.edge.count = v; calculate(); } }); + edgeCountInput.addEventListener('input', () => { const v = parseInt(edgeCountInput.value); if (!isNaN(v) && v >= 1) { state.addons.edge.count = v; scheduleCalculate(); } }); edgeCountInput.addEventListener('blur', () => { const plan = state.currentPlan || PLANS_DATA.plans[0]; const min = plan.edgeInstancesIncluded; const v = Math.max(min, parseInt(edgeCountInput.value) || min); state.addons.edge.count = v; edgeCountInput.value = String(v); edgeDecBtn.disabled = v <= min; calculate(); }); // DevQA stepper @@ -762,7 +762,7 @@ import CalculatorModal from './CalculatorModal.astro'; calculate(); }); }); - devCountInput.addEventListener('input', () => { const v = parseInt(devCountInput.value); if (!isNaN(v) && v >= 1) { state.addons.devqa.count = v; calculate(); } }); + devCountInput.addEventListener('input', () => { const v = parseInt(devCountInput.value); if (!isNaN(v) && v >= 1) { state.addons.devqa.count = v; scheduleCalculate(); } }); devCountInput.addEventListener('blur', () => { const v = Math.max(1, parseInt(devCountInput.value) || 1); state.addons.devqa.count = v; devCountInput.value = String(v); devDecBtn.disabled = v <= 1; calculate(); }); // ═══════════════════════════════════════════ @@ -784,6 +784,15 @@ import CalculatorModal from './CalculatorModal.astro'; } pcGetInputs = getInputs; + // rAF-batch calculate() for continuous input events (slider drag, typing). + // Final-state events (blur, change) still call calculate() directly. + let _calcQueued = false; + function scheduleCalculate() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calculate(); }); + } + function calculate() { const inp = getInputs(); const matching = PLANS_DATA.plans.filter(p => inp.totalDP <= p.maxMsgPerMin); @@ -943,7 +952,6 @@ import CalculatorModal from './CalculatorModal.astro'; const totalEl = footer.querySelector('.calc-total-row'); if (totalEl) totalEl.innerHTML = `Total${fmtC(finalTotal)}/month${tip(totalTip)}`; - bindResultEvents(); buildClipboard(r, inp, finalTotal); } @@ -999,17 +1007,17 @@ import CalculatorModal from './CalculatorModal.astro'; return `
${name}
`; } - function bindResultEvents() { - results.querySelectorAll('[data-enable-addon]').forEach(btn => { - btn.addEventListener('click', () => { - const key = (btn as HTMLElement).dataset.enableAddon!; - if (key in toggles) { - (toggles as any)[key].checked = true; - handleAddon(key, true); - } - }); - }); - } + // Delegated handler for [data-enable-addon] buttons that the results + // template re-renders on every calculate(). Bound once instead of + // re-attached per button on every slider tick. + results.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement)?.closest('[data-enable-addon]') as HTMLElement | null; + if (!btn) return; + const key = btn.dataset.enableAddon!; + if (!(key in toggles)) return; + (toggles as any)[key].checked = true; + handleAddon(key, true); + }); function buildClipboard(r: any, inp: any, finalTotal: number) { const plan = r.plan; diff --git a/src/components/Pricing/TbmqPaygCalculator.astro b/src/components/Pricing/TbmqPaygCalculator.astro index fa90e4cad..d5aee2ff4 100644 --- a/src/components/Pricing/TbmqPaygCalculator.astro +++ b/src/components/Pricing/TbmqPaygCalculator.astro @@ -88,6 +88,15 @@ import CalculatorInline from './CalculatorInline.astro'; function tip(t: string) { return ` ${infoSvg}${t}`; } function row(l: string, v: string, t?: string) { return `
${l}:${v}${t ? tip(t) : ''}
`; } + // rAF-batch calc() during continuous input (slider drag, typing). Final- + // state events (blur, change) still call calc() directly. + let _calcQueued = false; + function scheduleCalc() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calc(); }); + } + function calc() { let total = MQ_PLAN.basePrice; const eS = Math.max(0, st.sessions - MQ_PLAN.includedSessions); const sCost = eS * MQ_PLAN.extraSessionsPrice; total += sCost; @@ -178,8 +187,8 @@ import CalculatorInline from './CalculatorInline.astro'; } function bindSlider(sl: HTMLInputElement, inp: HTMLInputElement, marks: number[], key: 'sessions' | 'throughput') { - sl.addEventListener('input', () => { (st as any)[key] = s2v(parseFloat(sl.value), marks); inp.value = String((st as any)[key]); sliderProgress(sl); calc(); }); - inp.addEventListener('input', () => { const v = parseInt(inp.value); if (!isNaN(v) && v > 0) { (st as any)[key] = v; sl.value = String(Math.min(parseFloat(sl.max), v2s(v, marks))); sliderProgress(sl); calc(); } }); + sl.addEventListener('input', () => { (st as any)[key] = s2v(parseFloat(sl.value), marks); inp.value = String((st as any)[key]); sliderProgress(sl); scheduleCalc(); }); + inp.addEventListener('input', () => { const v = parseInt(inp.value); if (!isNaN(v) && v > 0) { (st as any)[key] = v; sl.value = String(Math.min(parseFloat(sl.max), v2s(v, marks))); sliderProgress(sl); scheduleCalc(); } }); inp.addEventListener('blur', () => { const v = Math.max(marks[0], parseInt(inp.value) || marks[0]); (st as any)[key] = v; inp.value = String(v); sl.value = String(v2s(v, marks)); sliderProgress(sl); calc(); }); } bindSlider(sS, sI, MQ_S_MARKS, 'sessions'); @@ -190,7 +199,7 @@ import CalculatorInline from './CalculatorInline.astro'; $(id).querySelectorAll('.calc-stepper-btn').forEach(btn => { btn.addEventListener('click', () => { if ((btn as HTMLButtonElement).disabled) return; (st as any)[key] = (btn as HTMLElement).dataset.action === 'increment' ? (st as any)[key] + 1 : Math.max(min, (st as any)[key] - 1); sInp.value = String((st as any)[key]); ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = (st as any)[key] <= min; calc(); }); }); - sInp.addEventListener('input', () => { const v = parseInt(sInp.value); if (!isNaN(v) && v >= min) { (st as any)[key] = v; ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calc(); } }); + sInp.addEventListener('input', () => { const v = parseInt(sInp.value); if (!isNaN(v) && v >= min) { (st as any)[key] = v; ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; scheduleCalc(); } }); sInp.addEventListener('blur', () => { const v = Math.max(min, parseInt(sInp.value) || min); (st as any)[key] = v; sInp.value = String(v); ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calc(); }); } bindStepper('#mq-prod-stepper', 'prod', 1); diff --git a/src/components/Pricing/TbmqPerpetualCalculator.astro b/src/components/Pricing/TbmqPerpetualCalculator.astro index 467afe33a..552463210 100644 --- a/src/components/Pricing/TbmqPerpetualCalculator.astro +++ b/src/components/Pricing/TbmqPerpetualCalculator.astro @@ -59,6 +59,14 @@ import CalculatorInline from './CalculatorInline.astro'; function tip(t: string) { return ` ${infoSvg}${t}`; } function row(l: string, v: string, t?: string) { return `
${l}:${v}${t ? tip(t) : ''}
`; } + // rAF-batch calc() during continuous input; blur/change still call directly. + let _calcQueued = false; + function scheduleCalc() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calc(); }); + } + function calc() { let total = MQP.basePrice; const eS = Math.max(0, st.sessions - MQP.includedSessions), sCost = eS * MQP.extraSessionsPrice; total += sCost; @@ -93,8 +101,8 @@ import CalculatorInline from './CalculatorInline.astro'; } function bindSlider(sl: HTMLInputElement, inp: HTMLInputElement, key: 'sessions' | 'throughput', max: number) { - sl.addEventListener('input', () => { (st as any)[key] = parseInt(sl.value); inp.value = sl.value; sliderProgress(sl); calc(); }); - inp.addEventListener('input', () => { const v = parseInt(inp.value); if (!isNaN(v) && v > 0) { (st as any)[key] = v; sl.value = String(Math.min(parseInt(sl.max), v)); sliderProgress(sl); calc(); } }); + sl.addEventListener('input', () => { (st as any)[key] = parseInt(sl.value); inp.value = sl.value; sliderProgress(sl); scheduleCalc(); }); + inp.addEventListener('input', () => { const v = parseInt(inp.value); if (!isNaN(v) && v > 0) { (st as any)[key] = v; sl.value = String(Math.min(parseInt(sl.max), v)); sliderProgress(sl); scheduleCalc(); } }); inp.addEventListener('blur', () => { const v = Math.max(parseInt(sl.min), Math.min(max, parseInt(inp.value) || parseInt(sl.min))); (st as any)[key] = v; inp.value = String(v); sl.value = String(Math.min(v, parseInt(sl.max))); sliderProgress(sl); calc(); }); } bindSlider(sS, sI, 'sessions', 1000000); bindSlider(tS, tI, 'throughput', 1000000); @@ -104,7 +112,7 @@ import CalculatorInline from './CalculatorInline.astro'; $(id).querySelectorAll('.calc-stepper-btn').forEach(btn => { btn.addEventListener('click', () => { if ((btn as HTMLButtonElement).disabled) return; (st as any)[key] = (btn as HTMLElement).dataset.action === 'increment' ? (st as any)[key] + 1 : Math.max(min, (st as any)[key] - 1); sInp.value = String((st as any)[key]); ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = (st as any)[key] <= min; calc(); }); }); - sInp.addEventListener('input', () => { const v = parseInt(sInp.value); if (!isNaN(v) && v >= min) { (st as any)[key] = v; ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calc(); } }); + sInp.addEventListener('input', () => { const v = parseInt(sInp.value); if (!isNaN(v) && v >= min) { (st as any)[key] = v; ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; scheduleCalc(); } }); sInp.addEventListener('blur', () => { const v = Math.max(min, parseInt(sInp.value) || min); (st as any)[key] = v; sInp.value = String(v); ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calc(); }); } bindStepper('#mqp-prod-stepper', 'prod', 1); bindStepper('#mqp-dev-stepper', 'dev', 0); diff --git a/src/components/Pricing/TbmqPrivateCloudCalculator.astro b/src/components/Pricing/TbmqPrivateCloudCalculator.astro index 9eac8405d..cb6acfd50 100644 --- a/src/components/Pricing/TbmqPrivateCloudCalculator.astro +++ b/src/components/Pricing/TbmqPrivateCloudCalculator.astro @@ -85,6 +85,14 @@ import CalculatorInline from './CalculatorInline.astro'; function tip(t: string) { return ` ${infoSvg}${t}`; } function row(l: string, v: string, t?: string) { return `
${l}:${v}${t ? tip(t) : ''}
`; } + // rAF-batch calc() during continuous input; blur/change still call directly. + let _calcQueued = false; + function scheduleCalc() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calc(); }); + } + function calc() { const isAnnual = st.billingPeriod === 'annual'; let total = MQPC.basePrice; @@ -219,8 +227,8 @@ import CalculatorInline from './CalculatorInline.astro'; } function bindSlider(sl: HTMLInputElement, inp: HTMLInputElement, marks: number[], key: 'sessions' | 'throughput') { - sl.addEventListener('input', () => { (st as any)[key] = s2v(parseFloat(sl.value), marks); inp.value = String((st as any)[key]); sliderProgress(sl); calc(); }); - inp.addEventListener('input', () => { const v = parseInt(inp.value); if (!isNaN(v) && v > 0) { (st as any)[key] = v; sl.value = String(Math.min(parseFloat(sl.max), v2s(v, marks))); sliderProgress(sl); calc(); } }); + sl.addEventListener('input', () => { (st as any)[key] = s2v(parseFloat(sl.value), marks); inp.value = String((st as any)[key]); sliderProgress(sl); scheduleCalc(); }); + inp.addEventListener('input', () => { const v = parseInt(inp.value); if (!isNaN(v) && v > 0) { (st as any)[key] = v; sl.value = String(Math.min(parseFloat(sl.max), v2s(v, marks))); sliderProgress(sl); scheduleCalc(); } }); inp.addEventListener('blur', () => { const v = Math.max(marks[0], parseInt(inp.value) || marks[0]); (st as any)[key] = v; inp.value = String(v); sl.value = String(v2s(v, marks)); sliderProgress(sl); calc(); }); } bindSlider(sS, sI, MQPC_S, 'sessions'); bindSlider(tS, tI, MQPC_T, 'throughput'); From 150a5367229c3d737813e26bb6cc60e8954ba6b9 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 25 May 2026 16:11:23 +0300 Subject: [PATCH 02/13] Defer YourGPT widget until interaction on the current page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget injects its own Google Fonts CSS (Inter @ 5 weights from fonts.gstatic.com). PageSpeed's critical request chain on /pricing flagged it as a 2.97 s LCP-blocking dependency, even though we already defer the widget script via `__deferOnInteraction`. Root cause: `__deferOnInteraction` short-circuits to immediate execution when `sessionStorage['tb:user-engaged']` is set — a flag flipped by the *first* pointerdown / keydown / scroll / touchstart anywhere on the site. So once a returning visitor scrolls one page, every subsequent navigation in the session loads the chat widget (and its fonts) on the critical path. - Add `window.__deferUntilInteraction` — same shape, but ignores the session-engagement shortcut. Always waits for an interaction on the current page (or the fallback timer). - Switch `YourGptWidget` to the stricter API, keeping the existing 10 s fallback so the widget still eventually appears even for users who don't interact at all. `__deferOnInteraction` is unchanged for everything else (analytics, prefetch) — those are cheap enough that the engagement shortcut is still the right default. --- src/components/DeferredLoadTrigger.astro | 24 ++++++++++++++++++++++++ src/components/YourGptWidget.astro | 6 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/components/DeferredLoadTrigger.astro b/src/components/DeferredLoadTrigger.astro index 150d66179..517cc1344 100644 --- a/src/components/DeferredLoadTrigger.astro +++ b/src/components/DeferredLoadTrigger.astro @@ -25,12 +25,36 @@ runCallback(cb); }; + // Default API: if the user already interacted earlier in the session, + // run immediately; otherwise wait for interaction on this page or the + // fallback timer. window.__deferOnInteraction = function (cb, fallbackMs) { if (fired) return runCallback(cb); const ms = typeof fallbackMs === 'number' ? fallbackMs : 2500; pending.set(cb, setTimeout(() => runOne(cb), ms)); }; + // Stricter sibling: ignore the session-engagement shortcut. Always wait + // for an interaction *on this page* (or the fallback timer). Use this + // for payloads heavy enough that even returning visitors shouldn't get + // them on the critical path — e.g. third-party chat widgets that + // inject their own font CSS. + window.__deferUntilInteraction = function (cb, fallbackMs) { + const ms = typeof fallbackMs === 'number' ? fallbackMs : 2500; + pending.set(cb, setTimeout(() => runOne(cb), ms)); + if (fired) { + // Already-engaged users still get the widget after a real + // interaction on this page; but since `fire` already ran and + // removed its listeners, re-arm a single lightweight listener + // just to flush whatever is now pending. + const flush = () => { + events.forEach((e) => window.removeEventListener(e, flush)); + for (const cb of [...pending.keys()]) runOne(cb); + }; + events.forEach((e) => window.addEventListener(e, flush, { once: true, passive: true })); + } + }; + const events = ['pointerdown', 'keydown', 'scroll', 'touchstart']; const fire = () => { if (fired) return; diff --git a/src/components/YourGptWidget.astro b/src/components/YourGptWidget.astro index 3bc55c654..01cc67ae9 100644 --- a/src/components/YourGptWidget.astro +++ b/src/components/YourGptWidget.astro @@ -13,5 +13,9 @@ (window.requestIdleCallback || ((cb) => setTimeout(cb, 200)))(injectWidget); }; - (window.__deferOnInteraction || ((cb) => cb()))(loadWidget, 10000); + // Use the stricter `__deferUntilInteraction` (no session-engagement + // shortcut). The widget injects its own Google Fonts CSS (Inter @ 5 + // weights), which otherwise lands on the critical request chain for + // every returning-session navigation — see DeferredLoadTrigger.astro. + (window.__deferUntilInteraction || window.__deferOnInteraction || ((cb) => cb()))(loadWidget, 10000); From 803646e994c3acd42bfe207178348727e19e392d Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 25 May 2026 17:24:18 +0300 Subject: [PATCH 03/13] Eager-load YourGPT widget for users who actually opened chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit defers the widget until interaction on the current page (or its 10 s fallback). Trade-off: returning visitors who used the chatbot once now wait several seconds on every subsequent nav before the bubble re-appears. Track a stronger intent signal: set `sessionStorage['tb:chatbot-opened']` the first time the user clicks anywhere inside the widget. The detector is a one-shot capture-phase `pointerdown` listener on document, scoped to `#yourgpt-chatbot, [class*="ygpt-"], [class*="yourgpt-"]` — covers regular DOM and Shadow DOM hosts (composed events retarget at the shadow boundary). On subsequent page navs: - If the flag is set → eager `__deferOnInteraction` path; bubble appears immediately. - Otherwise → strict `__deferUntilInteraction` path; keeps Inter off the LCP-critical chain for first impressions. A user who scrolled the homepage but never opened chat doesn't qualify as "wants chat pre-warmed." A user who actually opened the panel does. --- src/components/YourGptWidget.astro | 40 +++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/components/YourGptWidget.astro b/src/components/YourGptWidget.astro index 01cc67ae9..2896c9af4 100644 --- a/src/components/YourGptWidget.astro +++ b/src/components/YourGptWidget.astro @@ -1,6 +1,8 @@ From a7cced2906de3d19a4484713f37e06a3d8de039b Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 25 May 2026 18:34:56 +0300 Subject: [PATCH 04/13] Lazy-load ThingsBoard modal calculators Lighthouse's "Minimize main-thread work" diagnostic on /pricing/ flagged ~1.4 s of script evaluation. Three modal calculators (TbPayg / TbPrivateCloud / TbPerpetual) totalling ~68 KiB of JS were eagerly downloaded + parsed on every nav, even though most users never open them. Their _ric() wrapper only deferred *execution*, not download. Extract each calculator's body into a tree-shakeable ESM module under src/scripts/pricing/. The .astro component now renders the modal HTML at SSR time and ships a tiny inline loader (~700 B) that defines window.openTbXCalc as an async function. First click dynamically imports the chunk on demand; subsequent clicks reuse the memoised promise. When the URL has ?calculator= and points at the relevant sub-tab, the loader kicks off the import during page-load so the auto-open path doesn't race the network. Each module is idempotent (`if (openImpl) return`) so the loader's astro:page-load re-installation across View Transitions does not re-bind listeners against the persisted modal DOM. --- src/components/Pricing/TbPaygCalculator.astro | 509 +--------- .../Pricing/TbPerpetualCalculator.astro | 427 +-------- .../Pricing/TbPrivateCloudCalculator.astro | 879 +----------------- src/scripts/pricing/calc-tb-payg.ts | 505 ++++++++++ src/scripts/pricing/calc-tb-pc.ts | 840 +++++++++++++++++ src/scripts/pricing/calc-tb-perp.ts | 413 ++++++++ 6 files changed, 1806 insertions(+), 1767 deletions(-) create mode 100644 src/scripts/pricing/calc-tb-payg.ts create mode 100644 src/scripts/pricing/calc-tb-pc.ts create mode 100644 src/scripts/pricing/calc-tb-perp.ts diff --git a/src/components/Pricing/TbPaygCalculator.astro b/src/components/Pricing/TbPaygCalculator.astro index dd907b03e..f8148dfab 100644 --- a/src/components/Pricing/TbPaygCalculator.astro +++ b/src/components/Pricing/TbPaygCalculator.astro @@ -99,496 +99,25 @@ import CalculatorModal from './CalculatorModal.astro';
+ diff --git a/src/components/Pricing/TbPerpetualCalculator.astro b/src/components/Pricing/TbPerpetualCalculator.astro index a3f313494..b95dd93e9 100644 --- a/src/components/Pricing/TbPerpetualCalculator.astro +++ b/src/components/Pricing/TbPerpetualCalculator.astro @@ -38,420 +38,21 @@ import CalculatorModal from './CalculatorModal.astro'; diff --git a/src/components/Pricing/TbPrivateCloudCalculator.astro b/src/components/Pricing/TbPrivateCloudCalculator.astro index b4efaf297..6ae03d5e3 100644 --- a/src/components/Pricing/TbPrivateCloudCalculator.astro +++ b/src/components/Pricing/TbPrivateCloudCalculator.astro @@ -300,871 +300,22 @@ import CalculatorModal from './CalculatorModal.astro'; + diff --git a/src/scripts/pricing/calc-tb-payg.ts b/src/scripts/pricing/calc-tb-payg.ts new file mode 100644 index 000000000..c27506007 --- /dev/null +++ b/src/scripts/pricing/calc-tb-payg.ts @@ -0,0 +1,505 @@ +// Lazy-loaded module for the ThingsBoard PAYG (self-managed) calculator. +// The .astro component renders the modal markup at SSR time; this module is +// dynamically imported on first call to `window.openTbPaygCalc()`, so the +// ~20 KiB of calculator JS never lands on the critical path for users who +// don't open it. + +declare function sliderProgress(slider: HTMLInputElement): void; +declare function initAllSliders(root?: HTMLElement | Document): void; +declare function calcModalOpen(): void; +declare function calcModalClose(): void; + +const SM_PLANS = { + mobileApp: 99, mobileAppSetup: 1000, + plans: [ + { name: 'Maker', price: 10, includedDevices: 10, includedProdInstances: 1, extraProdInstancePrice: 100, devQaExtraInstancePrice: 50, edgeMonthPrice: 0, edgeInstancesIncluded: 1, trendzMonthPrice: 0, wl: false, productId: 'b5a35ce0-f5ea-11f0-8e58-abbac8d0a38a', planId: 'fe493b90-f5ea-11f0-8e58-abbac8d0a38a' }, + { name: 'Prototype', price: 39, includedDevices: 50, includedProdInstances: 1, extraProdInstancePrice: 100, devQaExtraInstancePrice: 50, edgeMonthPrice: 7, edgeInstancesIncluded: 1, extraEdgePrice: 39, trendzMonthPrice: 12, wl: false, productId: 'b5a35ce0-f5ea-11f0-8e58-abbac8d0a38a', planId: '648c95a0-f5eb-11f0-8e58-abbac8d0a38a' }, + { name: 'Pilot', price: 99, includedDevices: 100, includedProdInstances: 1, extraProdInstancePrice: 100, devQaExtraInstancePrice: 50, edgeMonthPrice: 19, edgeInstancesIncluded: 1, extraEdgePrice: 39, trendzMonthPrice: 29, wl: true, productId: 'b5a35ce0-f5ea-11f0-8e58-abbac8d0a38a', planId: '87f3b1e0-f5eb-11f0-8e58-abbac8d0a38a' }, + { name: 'Startup', price: 299, includedDevices: 500, includedProdInstances: 2, extraProdInstancePrice: 100, devQaExtraInstancePrice: 50, edgeMonthPrice: 49, edgeInstancesIncluded: 2, extraEdgePrice: 39, trendzMonthPrice: 89, wl: true, productId: 'b5a35ce0-f5ea-11f0-8e58-abbac8d0a38a', planId: 'b8ad2500-f5eb-11f0-8e58-abbac8d0a38a' }, + { name: 'Business', price: 499, includedDevices: 1000, extraDevicePrice: 0.1, includedProdInstances: 3, extraProdInstancePrice: 100, devQaExtraInstancePrice: 50, edgeMonthPrice: 89, edgeInstancesIncluded: 3, extraEdgePrice: 39, trendzMonthPrice: 149, trendzExtraDevicePrice: 0.03, wl: true, productId: 'b5a35ce0-f5ea-11f0-8e58-abbac8d0a38a', planId: 'f4b90050-f5eb-11f0-8e58-abbac8d0a38a' }, + ], +}; + +const SM_THRESHOLDS = [10, 50, 100, 500, 1000]; +const SM_MAX = 150000; +const SM_ENTERPRISE = 50000; + +const SM_DESCS: Record = { + Maker: { prod: 'Maker includes 1 instance. Choose Prototype plan to add more for HA and reliability.', dev: 'Choose Prototype plan to add dev instances. Safely test new features without impacting your live data.' }, + Prototype: { prod: 'Your plan includes 1 instance. Add a 2nd instance for HA to prevent downtime.', dev: 'Add dedicated instances for your dev, test, and CI/CD workflows.' }, + Pilot: { prod: 'Your plan includes 1 instance. Add a 2nd instance for HA to prevent downtime.', dev: 'Add dedicated instances for your dev, test, and CI/CD workflows.' }, + Startup: { prod: 'Your 2 instances provide HA. Add more to scale your application.', dev: 'Add dedicated instances for your dev, test, and CI/CD workflows.' }, + Business: { prod: 'Your plan includes 3 instances for HA. Add more to horizontally scale.', dev: 'Add dedicated instances for your dev, test, and CI/CD workflows.' }, +}; + +// Module-scoped reference to the open function created during init. Lets +// `openTbPaygCalc` work without re-running the (idempotent) init body. +let openImpl: (() => void) | null = null; + +export function initTbPaygCalc() { + if (openImpl) return; + const modal = document.getElementById('tb-payg-calc'); + if (!modal) return; + const $ = (sel: string) => modal.querySelector(sel) as HTMLElement; + + const devicesInput = $('#sm-devices') as HTMLInputElement; + const slider = $('#sm-slider') as HTMLInputElement; + const prodInput = $('#sm-prod') as HTMLInputElement; + const devInput = $('#sm-dev') as HTMLInputElement; + const edgeCount = $('#sm-edge-count') as HTMLInputElement; + const results = $('[data-calc-results]'); + const footer = $('[data-calc-footer]'); + const prodDesc = $('#sm-prod-desc'); + const devDesc = $('#sm-dev-desc'); + const edgeDesc = $('#sm-edge-desc'); + const edgeCounter = $('#sm-edge-counter'); + const wlPrompt = $('#sm-wl-prompt'); + + const toggles = { edge: $('#sm-edge-toggle') as HTMLInputElement, trendz: $('#sm-trendz-toggle') as HTMLInputElement, mobile: $('#sm-mobile-toggle') as HTMLInputElement }; + const cards = { edge: $('#sm-edge-card'), trendz: $('#sm-trendz-card'), mobile: $('#sm-mobile-card') }; + + let state = { devices: 10, prodInstances: 1, devInstances: 0, addons: { edge: { on: false, count: 1 }, trendz: { on: false }, mobile: { on: false } } }; + + let _smGtmTimer: ReturnType | null = null; + function sendSmGTM() { + if (_smGtmTimer) clearTimeout(_smGtmTimer); + _smGtmTimer = setTimeout(() => { + const plan = getPlan(state.devices); + const gtm: Record = { + event: 'calculator_interaction', + calculator_devices: state.devices, + calculator_plan: plan.name, + calculator_instances: state.prodInstances, + calculator_addon_dev_area: state.devInstances > 0, + calculator_addon_trendz_bot_area: state.addons.trendz.on, + calculator_addon_bot_area: state.addons.edge.on, + calculator_messages: null, + calculator_messages_unit: null, + calculator_instances_monthly: null, + calculator_extra_storage_cost: null, + }; + for (let i = 0; i <= 9; i++) gtm[`calculator_profile_${i}_json`] = null; + (window as any).dataLayer?.push(gtm); + }, 3000); + } + + const fmt = (n: number) => '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).replace(/,/g, ' '); + const fmtN = (n: number) => n.toLocaleString('en-US').replace(/,/g, ' '); + + function getPlan(d: number) { + if (d <= 10) return SM_PLANS.plans[0]; + if (d <= 50) return SM_PLANS.plans[1]; + if (d <= 100) return SM_PLANS.plans[2]; + if (d <= 500) return SM_PLANS.plans[3]; + return SM_PLANS.plans[4]; + } + + function sliderToDevices(v: number): number { + if (v <= 4) return SM_THRESHOLDS[Math.min(Math.round(v), SM_THRESHOLDS.length - 1)]; + return Math.round(1000 + (v - 4) * (SM_MAX - 1000)); + } + + function devicesToSlider(d: number): number { + if (d <= 1000) { const idx = SM_THRESHOLDS.findIndex(t => d <= t); return idx !== -1 ? idx : 4; } + return 4 + (d - 1000) / (SM_MAX - 1000); + } + + function updateProgress() { + sliderProgress(slider); + } + + function setStepper(container: HTMLElement, input: HTMLInputElement, value: number, min: number, disabled: boolean) { + (container.querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = disabled || value <= min; + (container.querySelector('[data-action="increment"]') as HTMLButtonElement).disabled = disabled; + input.disabled = disabled; + input.value = String(value); + } + + const infoSvg = ''; + + function tip(text: string) { + return ` ${infoSvg}${text}`; + } + + function row(label: string, value: string, tooltip?: string) { + const t = tooltip ? tip(tooltip) : ''; + return `
${label}:${value}${t}
`; + } + + function updateUI(plan: any) { + const isMaker = plan.name === 'Maker'; + const d = SM_DESCS[plan.name] || SM_DESCS.Business; + prodDesc.textContent = d.prod; + devDesc.textContent = d.dev; + + if (isMaker) { state.prodInstances = 1; state.devInstances = 0; } + else { state.prodInstances = Math.max(state.prodInstances, plan.includedProdInstances); } + + setStepper($('#sm-prod-stepper'), prodInput, state.prodInstances, plan.includedProdInstances, isMaker); + setStepper($('#sm-dev-stepper'), devInput, state.devInstances, 0, isMaker); + wlPrompt.classList.toggle('hidden', plan.wl === true); + updateAddons(plan, isMaker); + } + + function updateAddons(plan: any, isMaker: boolean) { + if (isMaker) { + cards.edge.classList.add('addon-free', 'active'); toggles.edge.checked = true; toggles.edge.disabled = true; + edgeCounter.classList.add('hidden'); edgeDesc.textContent = 'Process data where it is collected.'; state.addons.edge.count = 1; + cards.trendz.classList.add('addon-free', 'active'); toggles.trendz.checked = true; toggles.trendz.disabled = true; + cards.mobile.classList.add('addon-disabled'); toggles.mobile.checked = false; toggles.mobile.disabled = true; state.addons.mobile.on = false; + } else { + cards.edge.classList.remove('addon-free'); toggles.edge.disabled = false; toggles.edge.checked = state.addons.edge.on; + cards.edge.classList.toggle('active', state.addons.edge.on); + if (state.addons.edge.on) { + state.addons.edge.count = Math.max(state.addons.edge.count, plan.edgeInstancesIncluded); + edgeCount.value = String(state.addons.edge.count); edgeCounter.classList.remove('hidden'); + edgeDesc.textContent = `${plan.edgeInstancesIncluded} Edge instance${plan.edgeInstancesIncluded > 1 ? 's are' : ' is'} included.`; + ($('#sm-edge-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = state.addons.edge.count <= plan.edgeInstancesIncluded; + } else { edgeCounter.classList.add('hidden'); edgeDesc.textContent = 'Process data where it is collected.'; } + cards.trendz.classList.remove('addon-free'); toggles.trendz.disabled = false; toggles.trendz.checked = state.addons.trendz.on; + cards.trendz.classList.toggle('active', state.addons.trendz.on); + cards.mobile.classList.remove('addon-disabled'); toggles.mobile.disabled = false; toggles.mobile.checked = state.addons.mobile.on; + cards.mobile.classList.toggle('active', state.addons.mobile.on); + } + } + + function calculate() { + if (state.devices >= SM_ENTERPRISE) { renderEnterprise(); return; } + const plan = getPlan(state.devices); + updateUI(plan); + + const isMaker = plan.name === 'Maker'; + const isBusiness = plan.name === 'Business'; + let total = plan.price; + + let extraDev = 0, extraDevCost = 0; + if (isBusiness && state.devices > plan.includedDevices) { + extraDev = state.devices - plan.includedDevices; + extraDevCost = extraDev * (plan.extraDevicePrice || 0); + total += extraDevCost; + } + + const extraProd = !isMaker ? Math.max(0, state.prodInstances - plan.includedProdInstances) : 0; + const extraProdCost = extraProd * plan.extraProdInstancePrice; + total += extraProdCost; + + const devCost = !isMaker ? state.devInstances * plan.devQaExtraInstancePrice : 0; + total += devCost; + + let edgeCost = 0; + if (state.addons.edge.on || isMaker) { + if (!isMaker) { + const extraEdges = Math.max(0, state.addons.edge.count - plan.edgeInstancesIncluded); + edgeCost = plan.edgeMonthPrice + extraEdges * (plan.extraEdgePrice || 0); + total += edgeCost; + } + } + + let trendzCost = 0; + if (state.addons.trendz.on || isMaker) { + if (!isMaker) { + const trendzExtra = (isBusiness && extraDev > 0 && plan.trendzExtraDevicePrice) ? extraDev * plan.trendzExtraDevicePrice : 0; + trendzCost = plan.trendzMonthPrice + trendzExtra; + total += trendzCost; + } + } + + let mobileCost = 0; + if (state.addons.mobile.on && !isMaker) { mobileCost = SM_PLANS.mobileApp; total += mobileCost; } + + // Results + + let html = `
Subscription plan:${plan.name}
`; + + // Plan section (collapsible) + html += `
${plan.name}${fmt(plan.price)}${tip('Base plan price')}
`; + html += row('Included Devices', fmtN(plan.includedDevices), `Devices included in the ${plan.name} plan.`); + html += row('Included Prod Instances', fmtN(plan.includedProdInstances), `Production instances included in the ${plan.name} plan.`); + html += row('White Labeling', plan.wl ? 'Enabled' : 'Disabled'); + html += row('Base Price', `${fmt(plan.price)}`, 'Monthly subscription fee for the selected plan.'); + if (extraDev > 0) html += row('Extra Devices', fmtN(extraDev), 'Devices beyond what’s included in your plan.'); + if (extraDevCost > 0) html += row('Extra Device Cost', fmt(extraDevCost), `${fmtN(extraDev)} extra devices × $${plan.extraDevicePrice}/device`); + if (extraProd > 0) html += row('Extra Prod Instances', fmtN(extraProd), 'Additional production instances beyond what’s included.'); + if (extraProdCost > 0) html += row('Extra Prod Instances Cost', fmt(extraProdCost), `${fmtN(extraProd)} × ${fmt(plan.extraProdInstancePrice)}/instance`); + if (state.devInstances > 0) html += row('Extra Dev Instances', fmtN(state.devInstances), 'Development/QA instances for testing workflows.'); + if (devCost > 0) html += row('Extra Dev Instances Cost', fmt(devCost), `${fmtN(state.devInstances)} × ${fmt(plan.devQaExtraInstancePrice)}/instance`); + html += `
`; + + // Add-ons (skip for Maker — free add-ons don't need display) + if (!isMaker) { + html += `
Add-ons
`; + + // Edge + if (state.addons.edge.on && edgeCost > 0) { + html += `
Edge Computing${fmt(edgeCost)}${tip('Edge Computing add-on total')}
`; + html += row('Add-on Base Price', fmt(plan.edgeMonthPrice), 'Monthly base price for Edge Computing'); + html += row('Included Edges', fmtN(plan.edgeInstancesIncluded), 'Edge instances included with this plan'); + const extraEdges = Math.max(0, state.addons.edge.count - plan.edgeInstancesIncluded); + html += row('Extra Edges', fmtN(extraEdges), 'Additional edge instances beyond included'); + if (extraEdges > 0) html += row('Extra Edges Cost', fmt(extraEdges * (plan.extraEdgePrice || 0)), `${fmtN(extraEdges)} × ${fmt(plan.extraEdgePrice || 0)}`); + html += `
`; + } else { + html += `
Edge Computing
`; + } + + // Trendz + if (state.addons.trendz.on && trendzCost > 0) { + html += `
Trendz Analytics${fmt(trendzCost)}${tip('Trendz Analytics add-on total')}
`; + } else { + html += `
Trendz Analytics
`; + } + + // Mobile + if (state.addons.mobile.on && mobileCost > 0) { + html += `
White-labeled Mobile App${fmt(mobileCost)}${tip(`Monthly: ${fmt(SM_PLANS.mobileApp)}
One-time setup: ${fmt(SM_PLANS.mobileAppSetup)}`)}
`; + } else { + html += `
White-labeled Mobile App
`; + } + } + + const _st2 = results.parentElement?.scrollTop || 0; results.innerHTML = html; if (results.parentElement) results.parentElement.scrollTop = _st2; + + // Footer — build total tooltip + const totalParts = [`${fmt(plan.price)} (base plan)`]; + if (extraDevCost > 0) totalParts.push(`${fmt(extraDevCost)} (extra devices)`); + if (extraProdCost > 0) totalParts.push(`${fmt(extraProdCost)} (extra prod instances)`); + if (devCost > 0) totalParts.push(`${fmt(devCost)} (dev instances)`); + if (edgeCost > 0) totalParts.push(`${fmt(edgeCost)} (Edge)`); + if (trendzCost > 0) totalParts.push(`${fmt(trendzCost)} (Trendz)`); + if (mobileCost > 0) totalParts.push(`${fmt(mobileCost)} (Mobile App)`); + + // Build license portal URL with items + const items: Record = {}; + if (extraDev > 0) items.extraDeviceCount = extraDev; + if (extraProd > 0) items.extraInstanceCount = extraProd; + if (state.devInstances > 0) items.extraDevInstanceCount = state.devInstances; + if (state.addons.edge.on) { + items.edgeEnabled = true; + const extraEdges = Math.max(0, state.addons.edge.count - plan.edgeInstancesIncluded); + if (extraEdges > 0) items.extraEdgeCount = extraEdges; + } + if (state.addons.trendz.on) items.trendzEnabled = true; + if (state.addons.mobile.on) items.whiteLabelingAddonEnabled = true; + let ctaUrl = `https://license.thingsboard.io/signup?createSubscription=true&productId=${plan.productId}&planId=${plan.planId}`; + const itemsStr = JSON.stringify(items); + if (itemsStr !== '{}') ctaUrl += '&items=' + encodeURIComponent(itemsStr); + const utmRaw = localStorage.getItem('utm'); + if (utmRaw) { try { const u = JSON.parse(utmRaw); Object.keys(u).forEach(k => { ctaUrl += '&' + k + '=' + encodeURIComponent(u[k]); }); } catch { /* ignore malformed utm */ } } + const fpr = localStorage.getItem('fpr'); + if (fpr) ctaUrl += '&fpr=' + encodeURIComponent(fpr); + + footer.innerHTML = `
Total${fmt(total)}/month${tip(totalParts.join(' + '))}
Get started`; + + sendSmGTM(); + } + + // Delegated handler for [data-enable-addon] buttons rendered inside the + // results panel. Bound once here instead of per-button on every calculate(), + // which both leaked listeners and wasted CPU on each slider tick. + results.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement)?.closest('[data-enable-addon]') as HTMLElement | null; + if (!btn) return; + const key = btn.dataset.enableAddon as string; + if (!(key in toggles)) return; + (toggles as any)[key].checked = true; + (state.addons as any)[key].on = true; + if (key === 'edge') { state.addons.edge.count = 1; edgeCounter.classList.remove('hidden'); } + cards[key as keyof typeof cards].classList.add('active'); + calculate(); + }); + + function renderEnterprise() { + let html = `
Deployment Summary
`; + html += `

You are building at an impressive scale.

`; + html += row('Devices', fmtN(state.devices)); + html += row('Production Instances', fmtN(state.prodInstances)); + html += row('Development Instances', fmtN(state.devInstances)); + if (state.addons.edge.on) html += row('Edge Instances', fmtN(state.addons.edge.count)); + if (state.addons.trendz.on) html += row('Trendz Analytics', 'Enabled'); + if (state.addons.mobile.on) html += row('White-labeled Mobile App', 'Enabled'); + html += `

You have reached a tier where economies of scale apply. Let’s talk about aligning our pricing with your specific rollout schedule and volume requirements.

`; + const _st2 = results.parentElement?.scrollTop || 0; results.innerHTML = html; if (results.parentElement) results.parentElement.scrollTop = _st2; + + const msg = `Enterprise Request\n- Devices: ${fmtN(state.devices)}\n- Prod Instances: ${fmtN(state.prodInstances)}\n- Dev Instances: ${fmtN(state.devInstances)}` + + (state.addons.edge.on ? `\n- Edge Instances: ${fmtN(state.addons.edge.count)}` : '') + + (state.addons.trendz.on ? `\n- Trendz Analytics: Enabled` : '') + + (state.addons.mobile.on ? `\n- Mobile App: Enabled` : ''); + footer.innerHTML = `Get Enterprise Quote`; + } + + // rAF-batch calculate() during continuous input (slider drag, typing) so we + // rebuild the results panel at most once per animation frame. Final-state + // events (change/blur) still call calculate() directly to commit. + let _calcQueued = false; + function scheduleCalculate() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calculate(); }); + } + + // ─── Slider ─── + slider.addEventListener('input', () => { state.devices = sliderToDevices(parseFloat(slider.value)); devicesInput.value = String(state.devices); updateProgress(); scheduleCalculate(); }); + slider.addEventListener('change', () => { const v = parseFloat(slider.value); if (v <= 4) { const s = Math.round(v); slider.value = String(s); state.devices = sliderToDevices(s); devicesInput.value = String(state.devices); updateProgress(); calculate(); } }); + devicesInput.addEventListener('input', () => { const d = parseInt(devicesInput.value); if (!isNaN(d) && d > 0) { state.devices = Math.min(SM_MAX, d); slider.value = String(devicesToSlider(state.devices)); updateProgress(); scheduleCalculate(); } }); + devicesInput.addEventListener('blur', () => { let d = Math.max(1, Math.min(SM_MAX, parseInt(devicesInput.value) || 1)); if (d <= 1000) d = SM_THRESHOLDS.find(t => d <= t) || 1000; state.devices = d; devicesInput.value = String(d); slider.value = String(devicesToSlider(d)); updateProgress(); calculate(); }); + + // ─── Steppers ─── + function bindStepper(containerId: string, key: 'prod' | 'dev' | 'edge') { + const sInp = $(containerId).querySelector('input[type="number"]') as HTMLInputElement; + $(containerId).querySelectorAll('.calc-stepper-btn').forEach(btn => { + btn.addEventListener('click', () => { + if ((btn as HTMLButtonElement).disabled) return; + const action = (btn as HTMLElement).dataset.action; + const plan = getPlan(state.devices); + if (key === 'prod') { state.prodInstances = action === 'increment' ? state.prodInstances + 1 : Math.max(plan.includedProdInstances, state.prodInstances - 1); } + else if (key === 'dev') { state.devInstances = action === 'increment' ? state.devInstances + 1 : Math.max(0, state.devInstances - 1); } + else if (key === 'edge') { const min = plan.edgeInstancesIncluded; state.addons.edge.count = action === 'increment' ? state.addons.edge.count + 1 : Math.max(min, state.addons.edge.count - 1); } + calculate(); + }); + }); + sInp?.addEventListener('input', () => { + const v = parseInt(sInp.value); + if (isNaN(v) || v < 0) return; + if (key === 'prod') state.prodInstances = v; + else if (key === 'dev') state.devInstances = v; + else if (key === 'edge') state.addons.edge.count = v; + scheduleCalculate(); + }); + sInp?.addEventListener('blur', () => { + const plan = getPlan(state.devices); + let v = parseInt(sInp.value) || 0; + if (key === 'prod') { v = Math.max(plan.includedProdInstances, v); state.prodInstances = v; } + else if (key === 'dev') { v = Math.max(0, v); state.devInstances = v; } + else if (key === 'edge') { v = Math.max(plan.edgeInstancesIncluded, v); state.addons.edge.count = v; } + sInp.value = String(v); + calculate(); + }); + } + bindStepper('#sm-prod-stepper', 'prod'); + bindStepper('#sm-dev-stepper', 'dev'); + bindStepper('#sm-edge-stepper', 'edge'); + + // ─── Addon toggles ─── + (['edge', 'trendz', 'mobile'] as const).forEach(key => { + toggles[key].addEventListener('change', () => { + if (toggles[key].disabled) return; + (state.addons as any)[key].on = toggles[key].checked; + if (key === 'edge' && !toggles[key].checked) state.addons.edge.count = 1; + calculate(); + }); + }); + + // Choose pilot + $('#sm-choose-pilot').addEventListener('click', () => { + state.devices = 100; devicesInput.value = '100'; slider.value = String(devicesToSlider(100)); updateProgress(); calculate(); + }); + + // ─── Modal ─── + function closeModal() { calcModalClose(); setTimeout(() => { modal!.style.display = 'none'; }, 300); } + $('[data-calc-close]').addEventListener('click', closeModal); + modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); }); + + // Build clipboard text from current state + function buildSummaryText(): string { + if (state.devices >= SM_ENTERPRISE) { + let msg = `Enterprise Request\n- Devices: ${fmtN(state.devices)}\n- Prod Instances: ${fmtN(state.prodInstances)}\n- Dev Instances: ${fmtN(state.devInstances)}`; + if (state.addons.edge.on) msg += `\n- Edge Instances: ${fmtN(state.addons.edge.count)}`; + if (state.addons.trendz.on) msg += `\n- Trendz Analytics: Enabled`; + if (state.addons.mobile.on) msg += `\n- Mobile App: Enabled`; + return msg; + } + const plan = getPlan(state.devices); + const isMaker = plan.name === 'Maker'; + const isBusiness = plan.name === 'Business'; + let extraDev = 0, extraDevCost = 0; + if (isBusiness && state.devices > plan.includedDevices) { + extraDev = state.devices - plan.includedDevices; + extraDevCost = extraDev * (plan.extraDevicePrice || 0); + } + const extraProd = !isMaker ? Math.max(0, state.prodInstances - plan.includedProdInstances) : 0; + const extraProdCost = extraProd * plan.extraProdInstancePrice; + const devCost = !isMaker ? state.devInstances * plan.devQaExtraInstancePrice : 0; + let edgeCost = 0; + if ((state.addons.edge.on || isMaker) && !isMaker) { + const extraEdges = Math.max(0, state.addons.edge.count - plan.edgeInstancesIncluded); + edgeCost = plan.edgeMonthPrice + extraEdges * (plan.extraEdgePrice || 0); + } + let trendzCost = 0; + if ((state.addons.trendz.on || isMaker) && !isMaker) { + const trendzExtra = (isBusiness && extraDev > 0 && plan.trendzExtraDevicePrice) ? extraDev * plan.trendzExtraDevicePrice : 0; + trendzCost = plan.trendzMonthPrice + trendzExtra; + } + const mobileCost = (state.addons.mobile.on && !isMaker) ? SM_PLANS.mobileApp : 0; + const total = plan.price + extraDevCost + extraProdCost + devCost + edgeCost + trendzCost + mobileCost; + + let msg = `Subscription Plan: ${plan.name} (${fmt(plan.price)})\n`; + msg += `- Devices: ${fmtN(state.devices)}\n`; + msg += `- Included Devices: ${fmtN(plan.includedDevices)}\n`; + msg += `- Included Prod Instances: ${fmtN(plan.includedProdInstances)}\n`; + if (plan.wl) msg += `- White Labeling: Enabled\n`; + if (extraDev > 0) msg += `- Extra Devices: ${fmtN(extraDev)} (${fmt(extraDevCost)})\n`; + if (extraProd > 0) msg += `- Extra Prod Instances: ${fmtN(extraProd)} (${fmt(extraProdCost)})\n`; + if (state.devInstances > 0) msg += `- Dev Instances: ${fmtN(state.devInstances)} (${fmt(devCost)})\n`; + + const hasAddons = edgeCost > 0 || trendzCost > 0 || mobileCost > 0 || isMaker; + if (hasAddons) { + msg += `\nAdd-ons:\n`; + if (isMaker) { msg += `- Edge Computing: Free\n- Trendz Analytics: Free\n`; } + else { + if (edgeCost > 0) msg += `- Edge Computing: ${fmt(edgeCost)} (${fmtN(state.addons.edge.count)} instances)\n`; + if (trendzCost > 0) msg += `- Trendz Analytics: ${fmt(trendzCost)}\n`; + if (mobileCost > 0) msg += `- White-labeled Mobile App: ${fmt(mobileCost)} (one-time setup: ${fmt(SM_PLANS.mobileAppSetup)})\n`; + } + } + msg += `\nTotal Monthly Cost: ${fmt(total)}`; + return msg; + } + + // Copy. Capture currentTarget BEFORE the promise — browsers null + // `e.currentTarget` once the click handler returns synchronously, so + // reading it inside `.then()` throws TypeError on `classList`. + modal.querySelector('[data-calc-copy]')?.addEventListener('click', (e) => { + const btn = e.currentTarget as HTMLElement; + const text = buildSummaryText(); + const flashCopied = () => { + btn.classList.add('copied'); + setTimeout(() => btn.classList.remove('copied'), 2000); + }; + // Swallow rejections (e.g. Safari/Firefox `NotAllowedError: Document is not focused`) + // so they don't surface as unhandled promise rejections; silent failure is intentional. + navigator.clipboard.writeText(text).then(flashCopied).catch(() => {}); + }); + + // Download + modal.querySelector('[data-calc-download]')?.addEventListener('click', () => { + const blob = new Blob([buildSummaryText()], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'self-managed-calculation.txt'; + a.click(); + URL.revokeObjectURL(url); + }); + + // Reset + $('[data-calc-reset]').addEventListener('click', () => { + state = { devices: 10, prodInstances: 1, devInstances: 0, addons: { edge: { on: false, count: 1 }, trendz: { on: false }, mobile: { on: false } } }; + devicesInput.value = '10'; slider.value = '0'; + toggles.edge.checked = false; toggles.trendz.checked = false; toggles.mobile.checked = false; + cards.edge.classList.remove('active', 'addon-free'); cards.trendz.classList.remove('active', 'addon-free'); cards.mobile.classList.remove('active', 'addon-disabled'); + edgeCounter.classList.add('hidden'); + edgeCount.value = '1'; + ($('#sm-edge-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = true; + updateProgress(); calculate(); + }); + + updateProgress(); calculate(); + requestAnimationFrame(() => initAllSliders(modal)); + openImpl = () => { modal.style.display = ''; calcModalOpen(); updateProgress(); requestAnimationFrame(() => initAllSliders(modal)); calculate(); }; +} + +export function openTbPaygCalc() { + if (!openImpl) initTbPaygCalc(); + openImpl?.(); +} diff --git a/src/scripts/pricing/calc-tb-pc.ts b/src/scripts/pricing/calc-tb-pc.ts new file mode 100644 index 000000000..25e7f241f --- /dev/null +++ b/src/scripts/pricing/calc-tb-pc.ts @@ -0,0 +1,840 @@ +// Lazy-loaded module for the ThingsBoard Private Cloud calculator. +// See `calc-tb-payg.ts` for the lazy-load pattern rationale. + +declare function sliderProgress(slider: HTMLInputElement): void; +declare function initTickMarks(container: HTMLElement): void; +declare function initAllSliders(root?: HTMLElement | Document): void; +declare function calcModalOpen(): void; +declare function calcModalClose(): void; + +// ═══════════════════════════════════════════ +// PRIVATE CLOUD CALCULATOR — Full port +// ═══════════════════════════════════════════ + +const PLANS_DATA = { + enterpriseBytesPerDataPoint: 36, + extraStorageCostPerGB: 0.5, + mobileApp: 99, + mobileAppSetup: 1000, + plans: [ + { name: 'Launch', price: 1499, includedDevices: 5000, maxMsgPerMin: 50000, storage: 512, extraDevicePrice: 0.1, database: 'NoSQL', replicationFactor: 3, bytesPerDataPoint: 30, devQaExtraInstancePrice: 300, edgeMonthPrice: 249, edgeInstancesIncluded: 10, extraEdgePrice: 39, trendzMonthPrice: 449, trendzExtraDevicePrice: 0.03 }, + { name: 'Growth', price: 2199, includedDevices: 25000, maxMsgPerMin: 100000, storage: 1024, extraDevicePrice: 0.09, database: 'NoSQL', replicationFactor: 3, bytesPerDataPoint: 30, devQaExtraInstancePrice: 300, edgeMonthPrice: 379, edgeInstancesIncluded: 20, extraEdgePrice: 39, trendzMonthPrice: 659, trendzExtraDevicePrice: 0.03 }, + { name: 'Scale', price: 3999, includedDevices: 50000, maxMsgPerMin: 500000, storage: 2048, extraDevicePrice: 0.08, database: 'NoSQL', replicationFactor: 3, bytesPerDataPoint: 30, devQaExtraInstancePrice: 300, edgeMonthPrice: 679, edgeInstancesIncluded: 30, extraEdgePrice: 39, trendzMonthPrice: 1199, trendzExtraDevicePrice: 0.03 }, + ], +}; + +const SLIDER_BP = 100000; +const SLIDER_MAX = 120000; +const REAL_MAX = 400000; +const sliderToReal = (v: number) => v <= SLIDER_BP ? v : Math.round(SLIDER_BP + (v - SLIDER_BP) * ((REAL_MAX - SLIDER_BP) / (SLIDER_MAX - SLIDER_BP))); +const realToSlider = (v: number) => v <= SLIDER_BP ? v : SLIDER_BP + (v - SLIDER_BP) * ((SLIDER_MAX - SLIDER_BP) / (REAL_MAX - SLIDER_BP)); + +const fmtC = (n: number) => '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).replace(/,/g, ' '); +const fmtN = (n: number, d = 0) => n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }).replace(/,/g, ' '); + +const TICK_NUMS = [0, 5000, 25000, 50000, 100000, SLIDER_MAX]; +const TICK_LABELS = ['1', '5K', '25K', '50K', '100K', '∞']; + +// Tabler `tabler:infinity` icon inlined — this function runs in the browser +// at slider-init time so it can't use Astro's component. +const INFINITY_ICON = ''; + +function buildTicks() { + return TICK_NUMS.map((v, i) => { + const isInfinity = TICK_LABELS[i] === '∞'; + const content = isInfinity ? INFINITY_ICON : TICK_LABELS[i]; + return `${content}`; + }).join(''); +} + +let nextProfileId = 1; + +interface ProfileData { + id: number; + devices: number; + messages: number; + messageUnit: string; + dataPoints: number; + retention: number; +} + +const state = { + isAdvanced: false, + billingPeriod: 'monthly' as 'monthly' | 'annual', + profiles: [] as ProfileData[], + addons: { + edge: { on: false, count: 10 }, + trendz: { on: false }, + mobile: { on: false }, + devqa: { on: false, count: 1 }, + }, + currentPlan: null as any, + clipboardMsg: '', +}; + +let _pcGtmTimer: ReturnType | null = null; +function sendPcGTM() { + if (_pcGtmTimer) clearTimeout(_pcGtmTimer); + _pcGtmTimer = setTimeout(() => { + const inp = pcGetInputs?.(); + if (!inp) return; + const gtm: Record = { + event: 'calculator_interaction', + calculator_devices: inp.totalDevices, + calculator_plan: state.currentPlan?.name || '', + calculator_instances: null, + calculator_addon_dev_area: state.addons.devqa.on, + calculator_addon_trendz_bot_area: state.addons.trendz.on, + calculator_addon_bot_area: state.addons.edge.on, + calculator_messages: null, + calculator_messages_unit: null, + calculator_instances_monthly: null, + calculator_extra_storage_cost: null, + }; + if (state.currentPlan) { + const plan = state.currentPlan; + const storageGB = inp.profiles.reduce((acc: number, p: any) => { + const retMin = p.retention * 30 * 24 * 60; + return acc + (plan.bytesPerDataPoint * retMin * p.dataPointsPerMinute * plan.replicationFactor) / 1073741824; + }, 0); + const extraStorageGB = Math.max(0, storageGB - plan.storage); + gtm.calculator_extra_storage_cost = extraStorageGB * PLANS_DATA.extraStorageCostPerGB; + } + for (let i = 0; i <= 9; i++) gtm[`calculator_profile_${i}_json`] = null; + state.profiles.forEach((p: any, i: number) => { + if (i > 9) return; + gtm[`calculator_profile_${i}_json`] = JSON.stringify({ + devices: p.devices || 0, + msgs: p.messages || 0, + unit: (p.messageUnit || 'minute').charAt(0), + points: p.dataPoints || 0, + retention: p.retention || 0, + }); + }); + (window as any).dataLayer?.push(gtm); + }, 3000); +} + +let pcGetInputs: (() => any) | null = null; +let openImpl: (() => void) | null = null; + +export function initTbPcCalc() { + if (openImpl) return; + const modal = document.getElementById('tb-pc-calc'); + if (!modal) return; + const $ = (sel: string) => modal.querySelector(sel) as HTMLElement; + const $$ = (sel: string) => Array.from(modal.querySelectorAll(sel)) as HTMLElement[]; + const container = $('#pc-profiles-container'); + const results = $('[data-calc-results]'); + const footer = $('[data-calc-footer]'); + const toggles = { + edge: $('#pc-edge-toggle') as HTMLInputElement, + trendz: $('#pc-trendz-toggle') as HTMLInputElement, + mobile: $('#pc-mobile-toggle') as HTMLInputElement, + devqa: $('#pc-devqa-toggle') as HTMLInputElement, + }; + const cards = { edge: $('#pc-edge-card'), trendz: $('#pc-trendz-card'), mobile: $('#pc-mobile-card'), devqa: $('#pc-devqa-card') }; + const edgeCounter = $('#pc-edge-counter'); + const devqaCounter = $('#pc-devqa-counter'); + const edgeCountInput = $('#pc-edge-count') as HTMLInputElement; + const devCountInput = $('#pc-dev-count') as HTMLInputElement; + const edgeIncludedEl = $('#pc-edge-included'); + const edgeStepper = $('#pc-edge-stepper'); + const devStepper = $('#pc-dev-stepper'); + const edgeDecBtn = edgeStepper.querySelector('[data-action="decrement"]') as HTMLButtonElement; + const devDecBtn = devStepper.querySelector('[data-action="decrement"]') as HTMLButtonElement; + + // ─── Create profile HTML ─── + function createProfileEl(p: ProfileData): HTMLElement { + const div = document.createElement('div'); + div.className = 'calc-profile' + (state.isAdvanced ? ' advanced' : ''); + div.dataset.profileId = String(p.id); + div.innerHTML = ` +

Device profile

+ +
+
+ + +
+
+ +
${buildTicks()}
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+
+
+ `; + return div; + } + + // ─── Add profile button (single, always at bottom) ─── + const addProfileBtn = document.createElement('div'); + addProfileBtn.className = 'calc-add-profile'; + addProfileBtn.innerHTML = ''; + container.after(addProfileBtn); + + addProfileBtn.addEventListener('click', () => { + addProfile(false); + }); + + function addProfile(init = false) { + const id = nextProfileId++; + const p: ProfileData = { id, devices: 5000, messages: 1, messageUnit: 'hour', dataPoints: 3, retention: 12 }; + state.profiles.push(p); + const el = createProfileEl(p); + container.appendChild(el); + + el.querySelector('.calc-slider')?.dispatchEvent(new Event('input')); + bindProfileEvents(el, p); + updateProfileUI(); + if (!init) calculate(); + } + + function removeProfile(id: number) { + const idx = state.profiles.findIndex(p => p.id === id); + if (idx === -1 || state.profiles.length <= 1) return; + state.profiles.splice(idx, 1); + const el = container.querySelector(`[data-profile-id="${id}"]`); + el?.remove(); + updateProfileUI(); + calculate(); + } + + function updateProfileUI() { + container.querySelectorAll('.calc-profile').forEach((el, i) => { + const title = el.querySelector('.calc-profile-title') as HTMLElement; + if (title) title.textContent = `Device profile ${i + 1}`; + }); + addProfileBtn.classList.toggle('visible', state.isAdvanced); + container.querySelectorAll('.calc-profile').forEach(el => { + el.classList.toggle('advanced', state.isAdvanced); + const rmBtn = el.querySelector('.calc-profile-remove') as HTMLElement; + if (rmBtn) rmBtn.style.display = (state.isAdvanced && state.profiles.length > 1) ? 'flex' : 'none'; + }); + } + + function bindProfileEvents(el: HTMLElement, p: ProfileData) { + const slider = el.querySelector('[data-slider="devices"]') as HTMLInputElement; + const devInput = el.querySelector('[data-input="devices"]') as HTMLInputElement; + const msgSelect = el.querySelector('[data-select="messageUnit"]') as HTMLSelectElement; + + slider?.addEventListener('input', () => { + p.devices = sliderToReal(Number(slider.value)); + devInput.value = String(p.devices); + sliderProgress(slider); + scheduleCalculate(); + }); + + devInput?.addEventListener('input', () => { + const v = Number(devInput.value); + if (!isNaN(v) && v >= 0) { p.devices = v; slider.value = String(Math.min(SLIDER_MAX, realToSlider(Math.min(REAL_MAX, v)))); sliderProgress(slider); scheduleCalculate(); } + }); + devInput?.addEventListener('blur', () => { + p.devices = Math.max(0, Number(devInput.value) || 0); + devInput.value = String(p.devices); + slider.value = String(Math.min(SLIDER_MAX, realToSlider(Math.min(REAL_MAX, p.devices)))); + sliderProgress(slider); + calculate(); + }); + + msgSelect?.addEventListener('change', () => { p.messageUnit = msgSelect.value; calculate(); }); + + el.querySelectorAll('[data-input]').forEach(input => { + if ((input as HTMLInputElement).dataset.input === 'devices') return; + input.addEventListener('input', () => { + const field = (input as HTMLInputElement).dataset.input!; + const v = Number((input as HTMLInputElement).value); + if (!isNaN(v) && v >= 1) { (p as any)[field] = v; scheduleCalculate(); } + }); + input.addEventListener('blur', () => { + const field = (input as HTMLInputElement).dataset.input!; + (p as any)[field] = Math.max(1, Number((input as HTMLInputElement).value) || 1); + (input as HTMLInputElement).value = String((p as any)[field]); + calculate(); + }); + }); + + el.querySelectorAll('.calc-stepper-btn').forEach(btn => { + btn.addEventListener('click', () => { + if ((btn as HTMLButtonElement).disabled) return; + const field = (btn as HTMLElement).dataset.field!; + const input = el.querySelector(`[data-input="${field}"]`) as HTMLInputElement; + const min = Number(input.min) || 1; + let v = Number(input.value) || 1; + v = (btn as HTMLElement).dataset.action === 'increment' ? v + 1 : Math.max(min, v - 1); + input.value = String(v); + (p as any)[field] = v; + const stepper = btn.closest('.calc-stepper')!; + (stepper.querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; + calculate(); + }); + }); + + el.querySelector('.calc-profile-remove')?.addEventListener('click', () => removeProfile(p.id)); + el.querySelector('[data-payload-btn]')?.addEventListener('click', () => openPayloadModal(p.id)); + + sliderProgress(slider); + + el.querySelectorAll('.calc-stepper').forEach(stepper => { + const input = stepper.querySelector('input[type="number"]') as HTMLInputElement; + const min = Number(input.min) || 1; + const val = Number(input.value) || 1; + (stepper.querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = val <= min; + }); + + requestAnimationFrame(() => { + const sliderContainer = el.querySelector('.calc-slider-container') as HTMLElement | null; + if (sliderContainer) initTickMarks(sliderContainer); + }); + } + + // ─── Payload modal ─── + const payloadModal = document.getElementById('pc-payload-modal') as HTMLElement; + const jsonInput = document.getElementById('pc-json-input') as HTMLTextAreaElement; + const jsonError = document.getElementById('pc-json-error') as HTMLElement; + const jsonDpCount = document.getElementById('pc-json-dp-count') as HTMLElement; + let payloadTargetProfileId = 0; + + function openPayloadModal(profileId: number) { + payloadTargetProfileId = profileId; + jsonInput.value = ''; + jsonError.textContent = ''; + jsonDpCount.textContent = '0'; + payloadModal.style.display = ''; + } + + jsonInput?.addEventListener('input', () => { + const v = jsonInput.value.trim(); + jsonError.textContent = ''; + jsonInput.classList.remove('error'); + if (!v) { jsonDpCount.textContent = '0'; return; } + try { + const parsed = JSON.parse(v); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + jsonDpCount.textContent = String(Object.keys(parsed).length); + } else { + jsonDpCount.textContent = '0'; + jsonError.textContent = 'Please enter a valid JSON object.'; + } + } catch { + jsonDpCount.textContent = '0'; + jsonError.textContent = 'Please enter a valid JSON object.'; + jsonInput.classList.add('error'); + } + }); + + document.getElementById('pc-apply-payload')?.addEventListener('click', () => { + const count = Number(jsonDpCount.textContent); + if (!count || jsonError.textContent) return; + const p = state.profiles.find(pr => pr.id === payloadTargetProfileId); + if (!p) return; + p.dataPoints = count; + const el = container.querySelector(`[data-profile-id="${payloadTargetProfileId}"]`); + const inp = el?.querySelector('[data-input="dataPoints"]') as HTMLInputElement | null; + if (inp) inp.value = String(count); + payloadModal.style.display = 'none'; + calculate(); + }); + + document.getElementById('pc-payload-close')?.addEventListener('click', () => { payloadModal.style.display = 'none'; }); + payloadModal?.addEventListener('click', (e) => { if (e.target === payloadModal) payloadModal.style.display = 'none'; }); + + // ─── Mode toggle ─── + $$('.calc-mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + const adv = (btn as HTMLElement).dataset.mode === 'advanced'; + state.isAdvanced = adv; + $$('.calc-mode-btn').forEach(b => { + const isActive = (b as HTMLElement).dataset.mode === (adv ? 'advanced' : 'basic'); + b.classList.toggle('active', isActive); + b.setAttribute('aria-pressed', String(isActive)); + }); + if (!adv && state.profiles.length > 1) { + const first = { ...state.profiles[0] }; + container.innerHTML = ''; + state.profiles = []; + nextProfileId = 1; + addProfile(true); + const p = state.profiles[0]; + Object.assign(p, { devices: first.devices, messages: first.messages, messageUnit: first.messageUnit, dataPoints: first.dataPoints, retention: first.retention }); + const el = container.querySelector(`[data-profile-id="${p.id}"]`); + if (el) { + (el.querySelector('[data-input="devices"]') as HTMLInputElement).value = String(p.devices); + (el.querySelector('[data-slider="devices"]') as HTMLInputElement).value = String(realToSlider(p.devices)); + (el.querySelector('[data-input="messages"]') as HTMLInputElement).value = String(p.messages); + (el.querySelector('[data-select="messageUnit"]') as HTMLSelectElement).value = p.messageUnit; + (el.querySelector('[data-input="dataPoints"]') as HTMLInputElement).value = String(p.dataPoints); + (el.querySelector('[data-input="retention"]') as HTMLInputElement).value = String(p.retention); + sliderProgress(el.querySelector('[data-slider="devices"]') as HTMLInputElement); + } + } + updateProfileUI(); + calculate(); + }); + }); + + // ─── Addon toggles ─── + function handleAddon(key: string, on: boolean) { + (state.addons as any)[key].on = on; + cards[key as keyof typeof cards].classList.toggle('active', on); + if (key === 'edge') { + edgeCounter.classList.toggle('hidden', !on); + if (on) { + const plan = state.currentPlan || PLANS_DATA.plans[0]; + state.addons.edge.count = Math.max(state.addons.edge.count, plan.edgeInstancesIncluded); + edgeCountInput.value = String(state.addons.edge.count); + } + } + if (key === 'devqa') { + devqaCounter.classList.toggle('hidden', !on); + if (on) { state.addons.devqa.count = 1; devCountInput.value = '1'; } + } + calculate(); + } + + (['edge', 'trendz', 'mobile', 'devqa'] as const).forEach(key => { + toggles[key].addEventListener('change', () => handleAddon(key, toggles[key].checked)); + }); + + // Edge stepper + edgeStepper.querySelectorAll('.calc-stepper-btn').forEach(btn => { + btn.addEventListener('click', () => { + if ((btn as HTMLButtonElement).disabled) return; + const plan = state.currentPlan || PLANS_DATA.plans[0]; + const min = plan.edgeInstancesIncluded; + state.addons.edge.count = (btn as HTMLElement).dataset.action === 'increment' ? state.addons.edge.count + 1 : Math.max(min, state.addons.edge.count - 1); + edgeCountInput.value = String(state.addons.edge.count); + edgeDecBtn.disabled = state.addons.edge.count <= min; + calculate(); + }); + }); + edgeCountInput.addEventListener('input', () => { const v = parseInt(edgeCountInput.value); if (!isNaN(v) && v >= 1) { state.addons.edge.count = v; scheduleCalculate(); } }); + edgeCountInput.addEventListener('blur', () => { const plan = state.currentPlan || PLANS_DATA.plans[0]; const min = plan.edgeInstancesIncluded; const v = Math.max(min, parseInt(edgeCountInput.value) || min); state.addons.edge.count = v; edgeCountInput.value = String(v); edgeDecBtn.disabled = v <= min; calculate(); }); + + // DevQA stepper + devStepper.querySelectorAll('.calc-stepper-btn').forEach(btn => { + btn.addEventListener('click', () => { + if ((btn as HTMLButtonElement).disabled) return; + state.addons.devqa.count = (btn as HTMLElement).dataset.action === 'increment' ? state.addons.devqa.count + 1 : Math.max(1, state.addons.devqa.count - 1); + devCountInput.value = String(state.addons.devqa.count); + devDecBtn.disabled = state.addons.devqa.count <= 1; + calculate(); + }); + }); + devCountInput.addEventListener('input', () => { const v = parseInt(devCountInput.value); if (!isNaN(v) && v >= 1) { state.addons.devqa.count = v; scheduleCalculate(); } }); + devCountInput.addEventListener('blur', () => { const v = Math.max(1, parseInt(devCountInput.value) || 1); state.addons.devqa.count = v; devCountInput.value = String(v); devDecBtn.disabled = v <= 1; calculate(); }); + + // ═══════════════════════════════════════════ + // CALCULATION + // ═══════════════════════════════════════════ + + function getInputs() { + let totalDP = 0, totalDevices = 0; + const profiles = state.profiles.map(p => { + let mpm = p.messages; + if (p.messageUnit === 'hour') mpm /= 60; + else if (p.messageUnit === 'day') mpm /= 1440; + const dp = p.devices * mpm * p.dataPoints; + totalDP += dp; + totalDevices += p.devices; + return { ...p, messagesPerMinute: mpm, dataPointsPerMinute: dp }; + }); + return { profiles, totalDP, totalDevices, addons: state.addons }; + } + pcGetInputs = getInputs; + + // rAF-batch calculate() for continuous input events. + let _calcQueued = false; + function scheduleCalculate() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calculate(); }); + } + + function calculate() { + const inp = getInputs(); + const matching = PLANS_DATA.plans.filter(p => inp.totalDP <= p.maxMsgPerMin); + + if (matching.length === 0) { + displayEnterprise(inp); + sendPcGTM(); + return; + } + + const planResults = matching.map(plan => { + const extraDev = Math.max(0, inp.totalDevices - plan.includedDevices); + const extraDevCost = extraDev * plan.extraDevicePrice; + + const storageGB = inp.profiles.reduce((acc, p) => { + const retMin = p.retention * 30 * 24 * 60; + return acc + (plan.bytesPerDataPoint * retMin * p.dataPointsPerMinute * plan.replicationFactor) / 1073741824; + }, 0); + const extraStorageGB = Math.max(0, storageGB - plan.storage); + const extraStorageCost = extraStorageGB * PLANS_DATA.extraStorageCostPerGB; + + let addonsCost = 0; + const ab: any = {}; + + if (inp.addons.edge.on) { + const extra = Math.max(0, inp.addons.edge.count - plan.edgeInstancesIncluded); + const cost = plan.edgeMonthPrice + extra * plan.extraEdgePrice; + addonsCost += cost; + ab.edge = { base: plan.edgeMonthPrice, included: plan.edgeInstancesIncluded, total: inp.addons.edge.count, extra, extraCost: extra * plan.extraEdgePrice, cost }; + } + if (inp.addons.trendz.on) { + const trendzExtra = extraDev * plan.trendzExtraDevicePrice; + const cost = plan.trendzMonthPrice + trendzExtra; + addonsCost += cost; + ab.trendz = { base: plan.trendzMonthPrice, includedDev: plan.includedDevices, extraDev, extraCost: trendzExtra, cost }; + } + if (inp.addons.mobile.on) { + addonsCost += PLANS_DATA.mobileApp; + ab.mobile = { cost: PLANS_DATA.mobileApp, setup: PLANS_DATA.mobileAppSetup }; + } + if (inp.addons.devqa.on) { + const cost = inp.addons.devqa.count * plan.devQaExtraInstancePrice; + addonsCost += cost; + ab.devqa = { instances: inp.addons.devqa.count, perInstance: plan.devQaExtraInstancePrice, cost }; + } + + return { plan, extraDev, extraDevCost, storageGB, extraStorageGB, extraStorageCost, addonsCost, ab, total: plan.price + extraDevCost + extraStorageCost + addonsCost, dp: inp.totalDP }; + }); + + const best = planResults.reduce((a, b) => a.total < b.total ? a : b); + + if (state.currentPlan?.name !== best.plan.name) { + edgeIncludedEl.textContent = String(best.plan.edgeInstancesIncluded); + if (state.addons.edge.on && state.addons.edge.count < best.plan.edgeInstancesIncluded) { + state.addons.edge.count = best.plan.edgeInstancesIncluded; + edgeCountInput.value = String(state.addons.edge.count); + } + edgeDecBtn.disabled = state.addons.edge.count <= best.plan.edgeInstancesIncluded; + } + state.currentPlan = best.plan; + + if (best.total > 10000) { + displayEnterprise(inp); + sendPcGTM(); + return; + } + + displayResults(best, inp); + sendPcGTM(); + } + + function displayResults(r: any, inp: any) { + const isAnnual = state.billingPeriod === 'annual'; + const finalTotal = isAnnual ? r.total * 0.9 : r.total; + const plan = r.plan; + + let html = `
Optimal plan:${plan.name}
`; + + let storeTip = 'Storage is calculated for each profile based on data points and retention, then summed up.'; + inp.profiles.forEach((p: any, i: number) => { + const retMin = p.retention * 30 * 24 * 60; + const s = (plan.bytesPerDataPoint * retMin * p.dataPointsPerMinute * plan.replicationFactor) / 1073741824; + storeTip += `
Profile ${i + 1}: ${fmtN(s, 2)} GB`; + }); + + html += `
${plan.name}${fmtC(plan.price)}${tip('Base plan price')}
`; + html += row('Included Devices', fmtN(plan.includedDevices)); + html += row('Data Points Rate', `${fmtN(Math.round(r.dp))} points/min`, 'The rate at which your devices generate data points that need to be processed and stored.'); + html += row('Time-Series Database', plan.database, 'NoSQL (Cassandra) with a replication factor of 3 to store time-series data. Cassandra storage is more efficient — each data point occupies on average five times less space before replication.'); + html += row('Included Storage', `${fmtN(plan.storage)} GB`, `Storage included in the ${plan.name} plan.`); + html += row('White Labeling', 'Enabled'); + html += row('Extra Devices', fmtN(r.extraDev), 'Devices beyond what’s included in your plan.'); + if (r.extraDevCost > 0) html += row('Extra Device Cost', fmtC(r.extraDevCost), `${fmtC(r.extraDevCost)} = ${fmtN(r.extraDev)} extra devices × $${plan.extraDevicePrice}/device`); + html += row('Storage Used', `${fmtN(r.storageGB, 2)} GB`, storeTip); + html += row('Extra Storage', `${fmtN(r.extraStorageGB, 2)} GB`, `${fmtN(r.extraStorageGB, 2)} GB = ${fmtN(r.storageGB, 2)} GB used − ${fmtN(plan.storage)} GB included`); + if (r.extraStorageCost > 0) html += row('Extra Storage Cost', fmtC(r.extraStorageCost), `${fmtC(r.extraStorageCost)} = ${fmtN(r.extraStorageGB, 2)} GB × $${PLANS_DATA.extraStorageCostPerGB}/GB`); + html += `
`; + + html += `
Add-ons
`; + html += addonSection('Edge Computing', 'edge', r.ab.edge, plan.edgeMonthPrice, 'Edge Computing add-on total', () => { + if (!r.ab.edge) return ''; + const e = r.ab.edge; + return row('Add-on Base Price', fmtC(e.base), 'Monthly base price for Edge Computing') + row('Included Edges', fmtN(e.included), 'Edge instances included with this plan') + row('Extra Edges', fmtN(e.extra), 'Additional edge instances beyond included') + row('Extra Edges Cost', fmtC(e.extraCost), `${fmtN(e.extra)} × ${fmtC(plan.extraEdgePrice)}`); + }); + html += addonSection('Trendz Analytics', 'trendz', r.ab.trendz, plan.trendzMonthPrice, 'Trendz Analytics add-on total', () => { + if (!r.ab.trendz) return ''; + const t = r.ab.trendz; + return row('Add-on Base Price', fmtN(t.base), 'Monthly base price for Trendz Analytics') + row('Included Devices', fmtN(t.includedDev), 'Devices included with this plan') + row('Extra Devices', fmtN(t.extraDev), 'Devices beyond plan’s included count') + row('Extra Devices Cost', fmtC(t.extraCost), `${fmtN(t.extraDev)} × ${fmtC(plan.trendzExtraDevicePrice)}`); + }); + html += simpleAddonRow('White-labeled Mobile App', 'mobile', r.ab.mobile, PLANS_DATA.mobileApp, `Monthly: ${fmtC(PLANS_DATA.mobileApp)}
One-time setup: ${fmtC(PLANS_DATA.mobileAppSetup)}`); + html += addonSection('Dev & QA Instances', 'devqa', r.ab.devqa, plan.devQaExtraInstancePrice, 'Dev & QA Instances total', () => { + if (!r.ab.devqa) return ''; + return row('Extra Instances', fmtN(r.ab.devqa.instances), 'Number of dev/QA instances') + row('Extra Instances Cost', fmtC(r.ab.devqa.cost), `${fmtN(r.ab.devqa.instances)} × ${fmtC(r.ab.devqa.perInstance)}`); + }); + + const _st = results.parentElement?.scrollTop || 0; results.innerHTML = html; if (results.parentElement) results.parentElement.scrollTop = _st; + + if (!footer.querySelector('.calc-billing-row')) { + footer.innerHTML = ` +
+ Monthly + + Annual (Save 10%) +
+
Total$0/month
+ Contact Us`; + const toggle = footer.querySelector('#pc-bill-toggle') as HTMLInputElement; + toggle?.addEventListener('change', () => { state.billingPeriod = toggle.checked ? 'annual' : 'monthly'; calculate(); }); + footer.querySelectorAll('.calc-billing-label').forEach(l => { + l.addEventListener('click', () => { state.billingPeriod = (l as HTMLElement).dataset.billing === 'annual' ? 'annual' : 'monthly'; const t = footer.querySelector('#pc-bill-toggle') as HTMLInputElement; if (t) t.checked = state.billingPeriod === 'annual'; calculate(); }); + }); + } + + footer.querySelectorAll('.calc-billing-label').forEach(l => { + const isMo = (l as HTMLElement).dataset.billing === 'monthly'; + l.classList.toggle('active', isMo ? !isAnnual : isAnnual); + }); + + const totalParts = [`${fmtC(plan.price)} (base plan)`]; + if (r.extraDevCost > 0) totalParts.push(`${fmtC(r.extraDevCost)} (extra devices)`); + if (r.extraStorageCost > 0) totalParts.push(`${fmtC(r.extraStorageCost)} (extra storage)`); + if (r.ab.edge) totalParts.push(`${fmtC(r.ab.edge.cost)} (Edge)`); + if (r.ab.trendz) totalParts.push(`${fmtC(r.ab.trendz.cost)} (Trendz)`); + if (r.ab.mobile) totalParts.push(`${fmtC(r.ab.mobile.cost)} (Mobile App)`); + if (r.ab.devqa) totalParts.push(`${fmtC(r.ab.devqa.cost)} (Dev & QA)`); + let totalTip = totalParts.join(' + '); + if (isAnnual) totalTip = `(${totalTip}) − 10% Annual Discount`; + + const totalEl = footer.querySelector('.calc-total-row'); + if (totalEl) totalEl.innerHTML = `Total${fmtC(finalTotal)}/month${tip(totalTip)}`; + + buildClipboard(r, inp, finalTotal); + } + + function displayEnterprise(inp: any) { + const totalStorage = inp.profiles.reduce((acc: number, p: any) => { + const retMin = p.retention * 30 * 24 * 60; + return acc + (PLANS_DATA.enterpriseBytesPerDataPoint * retMin * p.dataPointsPerMinute) / 1073741824; + }, 0); + + let html = `
Enterprise Scale
`; + html += `

You are building at an impressive scale.

`; + html += row('Devices', fmtN(inp.totalDevices)); + html += row('Data Points Rate', `${fmtN(Math.round(inp.totalDP))} points/min`); + html += row('Estimated Storage', `${fmtN(totalStorage, 2)} GB`); + html += `

You have reached a tier where economies of scale apply. Let’s talk about aligning our pricing with your specific rollout schedule and volume requirements.

`; + const _st = results.parentElement?.scrollTop || 0; results.innerHTML = html; if (results.parentElement) results.parentElement.scrollTop = _st; + + let msg = `Enterprise Request\n- Devices: ${fmtN(inp.totalDevices)}\n- Data Points Rate: ${fmtN(Math.round(inp.totalDP))} points/min\n- Estimated Storage: ${fmtN(totalStorage, 2)} GB`; + if (state.addons.edge.on) msg += `\n- Edge Instances: ${fmtN(state.addons.edge.count)}`; + if (state.addons.trendz.on) msg += `\n- Trendz Analytics: Enabled`; + if (state.addons.mobile.on) msg += `\n- Mobile App: Enabled`; + if (state.addons.devqa.on) msg += `\n- Dev & QA Instances: ${fmtN(state.addons.devqa.count)}`; + inp.profiles.forEach((p: any, i: number) => { + msg += `\nProfile ${i + 1}: ${fmtN(p.devices)} devices, ${fmtN(p.messages)} msg/${p.messageUnit}, ${fmtN(p.dataPoints)} dp, ${fmtN(p.retention)} mo retention`; + }); + state.clipboardMsg = msg; + footer.innerHTML = `Get Enterprise Quote`; + } + + const infoSvg = ''; + + function tip(text: string) { + return ` ${infoSvg}${text}`; + } + + function row(label: string, value: string, tooltip?: string) { + const t = tooltip ? tip(tooltip) : ''; + return `
${label}:${value}${t}
`; + } + + function addonSection(name: string, key: string, data: any, basePrice: number, priceTip: string, detailsFn: () => string) { + if (data) { + return `
${name}${fmtC(data.cost)}${tip(priceTip)}
${detailsFn()}
`; + } + return `
${name}
`; + } + + function simpleAddonRow(name: string, key: string, data: any, price: number, priceTip?: string) { + if (data) { + const t = priceTip ? tip(priceTip) : ''; + return `
${name}${fmtC(data.cost)}${t}
`; + } + return `
${name}
`; + } + + // Delegated handler for [data-enable-addon] buttons that the results + // template re-renders on every calculate(). Bound once instead of + // re-attached per button on every slider tick. + results.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement)?.closest('[data-enable-addon]') as HTMLElement | null; + if (!btn) return; + const key = btn.dataset.enableAddon!; + if (!(key in toggles)) return; + (toggles as any)[key].checked = true; + handleAddon(key, true); + }); + + function buildClipboard(r: any, inp: any, finalTotal: number) { + const plan = r.plan; + const isAnnual = state.billingPeriod === 'annual'; + const billingText = isAnnual ? 'Annual (10% discount applied)' : 'Monthly'; + + let msg = `Optimal Plan: ${plan.name} (${fmtC(plan.price)})\n`; + msg += `- Billing: ${billingText}\n`; + msg += `- Data Points Rate: ${fmtN(Math.round(r.dp))} points/min\n`; + msg += `- Included Devices: ${fmtN(plan.includedDevices)}\n`; + msg += `- Total Devices: ${fmtN(inp.totalDevices)}\n`; + msg += `- Extra Devices: ${fmtN(r.extraDev)}\n`; + msg += `- Extra Device Cost: ${fmtC(r.extraDevCost)}\n`; + msg += `- Included Storage: ${fmtN(plan.storage)} GB\n`; + msg += `- Storage Used: ${fmtN(r.storageGB, 2)} GB\n`; + msg += `- Extra Storage: ${fmtN(r.extraStorageGB, 2)} GB\n`; + msg += `- Extra Storage Cost: ${fmtC(r.extraStorageCost)}\n`; + + const ab = r.ab; + const hasAddons = ab.edge || ab.trendz || ab.mobile || ab.devqa; + if (hasAddons) { + msg += `\nAdd-ons:\n`; + if (ab.edge) { + msg += `- Edge Computing: ${fmtC(ab.edge.cost)}\n`; + msg += ` - Instances: ${ab.edge.total} (${ab.edge.included} included)\n`; + if (ab.edge.extra > 0) msg += ` - Extra Instances Cost: ${fmtC(ab.edge.extraCost)}\n`; + } + if (ab.trendz) { + msg += `- Trendz Analytics: ${fmtC(ab.trendz.cost)}\n`; + if (ab.trendz.extraDev > 0) msg += ` - Extra Devices Cost: ${fmtC(ab.trendz.extraCost)}\n`; + } + if (ab.mobile) { + msg += `- White-labeled Mobile App: ${fmtC(ab.mobile.cost)} (monthly)\n`; + msg += ` - One-time setup fee: ${fmtC(ab.mobile.setup)}\n`; + } + if (ab.devqa) { + msg += `- Dev & QA Instances: ${fmtC(ab.devqa.cost)}\n`; + msg += ` - Instances: ${ab.devqa.instances}\n`; + } + } + + msg += `\nTotal Monthly Cost: ${fmtC(finalTotal)}`; + + msg += `\n\nCalculator Input:`; + inp.profiles.forEach((p: any, i: number) => { + msg += `\nProfile ${i + 1}:`; + msg += `\n - Devices: ${fmtN(p.devices)}`; + msg += `\n - Messages: ${fmtN(p.messages)} per ${p.messageUnit}`; + msg += `\n - Data Points: ${fmtN(p.dataPoints)}`; + msg += `\n - Retention: ${fmtN(p.retention)} months`; + }); + + state.clipboardMsg = msg; + const contactBtn = footer.querySelector('.calc-cta') as HTMLAnchorElement | null; + if (contactBtn) contactBtn.href = `/contact-us/?subject=${encodeURIComponent('Private Cloud')}&message=${encodeURIComponent(msg)}`; + } + + // ─── Modal controls ─── + function closeModal() { calcModalClose(); setTimeout(() => { modal!.style.display = 'none'; }, 300); } + $('[data-calc-close]').addEventListener('click', closeModal); + modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); }); + + modal.querySelector('[data-calc-copy]')?.addEventListener('click', (e) => { + const btn = e.currentTarget as HTMLElement; + const text = state.clipboardMsg; + const flashCopied = () => { + btn.classList.add('copied'); + setTimeout(() => btn.classList.remove('copied'), 2000); + }; + navigator.clipboard.writeText(text).then(flashCopied).catch(() => {}); + }); + + modal.querySelector('[data-calc-download]')?.addEventListener('click', () => { + const blob = new Blob([state.clipboardMsg], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'private-cloud-calculation.txt'; + a.click(); + URL.revokeObjectURL(url); + }); + + // ─── Render footer once ─── + footer.innerHTML = ` +
+ Monthly + + Annual (Save 10%) +
+
Total$0/month
+ Contact Us`; + + const billingToggle = footer.querySelector('#pc-bill-toggle') as HTMLInputElement; + billingToggle?.addEventListener('change', () => { + state.billingPeriod = billingToggle.checked ? 'annual' : 'monthly'; + calculate(); + }); + footer.querySelectorAll('.calc-billing-label').forEach(l => { + l.addEventListener('click', () => { + state.billingPeriod = (l as HTMLElement).dataset.billing === 'annual' ? 'annual' : 'monthly'; + if (billingToggle) billingToggle.checked = state.billingPeriod === 'annual'; + calculate(); + }); + }); + + // Reset + $('[data-calc-reset]').addEventListener('click', () => { + container.innerHTML = ''; + state.profiles = []; + nextProfileId = 1; + state.isAdvanced = false; + $$('.calc-mode-btn').forEach(b => { b.classList.toggle('active', (b as HTMLElement).dataset.mode === 'basic'); b.setAttribute('aria-pressed', String((b as HTMLElement).dataset.mode === 'basic')); }); + state.billingPeriod = 'monthly'; + if (billingToggle) billingToggle.checked = false; + footer.querySelectorAll('.calc-billing-label').forEach(l => l.classList.toggle('active', (l as HTMLElement).dataset.billing === 'monthly')); + state.addons = { edge: { on: false, count: 10 }, trendz: { on: false }, mobile: { on: false }, devqa: { on: false, count: 1 } }; + state.currentPlan = null; + toggles.edge.checked = false; toggles.trendz.checked = false; toggles.mobile.checked = false; toggles.devqa.checked = false; + cards.edge.classList.remove('active'); cards.trendz.classList.remove('active'); cards.mobile.classList.remove('active'); cards.devqa.classList.remove('active'); + edgeCounter.classList.add('hidden'); devqaCounter.classList.add('hidden'); + edgeCountInput.value = '10'; devCountInput.value = '1'; + edgeIncludedEl.textContent = '10'; + edgeDecBtn.disabled = true; + devDecBtn.disabled = true; + addProfile(true); + calculate(); + }); + + // ─── Init ─── + addProfile(true); + openImpl = () => { + modal.style.display = ''; + calcModalOpen(); + calculate(); + requestAnimationFrame(() => initAllSliders(modal)); + }; +} + +export function openTbPcCalc() { + if (!openImpl) initTbPcCalc(); + openImpl?.(); +} diff --git a/src/scripts/pricing/calc-tb-perp.ts b/src/scripts/pricing/calc-tb-perp.ts new file mode 100644 index 000000000..c850a9fb4 --- /dev/null +++ b/src/scripts/pricing/calc-tb-perp.ts @@ -0,0 +1,413 @@ +// Lazy-loaded module for the ThingsBoard Perpetual License calculator. +// See `calc-tb-payg.ts` for the lazy-load pattern rationale. + +declare function sliderProgress(slider: HTMLInputElement): void; +declare function initAllSliders(root?: HTMLElement | Document): void; +declare function calcModalOpen(): void; +declare function calcModalClose(): void; + +const PERP = { + price: 4999, includedDevices: 1000, extraDevicePrice: 1, + includedProdInstances: 1, extraProdInstancePrice: 4999, devQaExtraInstancePrice: 999, + edgeMonthPrice: 849, edgeInstancesIncluded: 2, extraEdgePrice: 399, + trendzMonthPrice: 1499, trendzExtraDevicePrice: 0.3, + offlineModePrice: 19999 +}; + +const PERP_SLIDER_BP = 20000; +const PERP_SLIDER_MAX = 25000; +const PERP_REAL_MAX = 1000000; +const sliderToReal = (v: number) => v <= PERP_SLIDER_BP ? v : Math.round(PERP_SLIDER_BP + (v - PERP_SLIDER_BP) * ((PERP_REAL_MAX - PERP_SLIDER_BP) / (PERP_SLIDER_MAX - PERP_SLIDER_BP))); +const realToSlider = (v: number) => v <= PERP_SLIDER_BP ? v : PERP_SLIDER_BP + (v - PERP_SLIDER_BP) * ((PERP_SLIDER_MAX - PERP_SLIDER_BP) / (PERP_REAL_MAX - PERP_SLIDER_BP)); + +let openImpl: (() => void) | null = null; + +export function initTbPerpCalc() { + if (openImpl) return; + const modal = document.getElementById('tb-perp-calc'); + if (!modal) return; + const $ = (s: string) => modal.querySelector(s) as HTMLElement; + const devInput = $('#perp-devices') as HTMLInputElement; + const slider = $('#perp-devices-slider') as HTMLInputElement; + const results = $('[data-calc-results]'); + const footer = $('[data-calc-footer]'); + + const toggles = { + edge: $('#perp-edge-toggle') as HTMLInputElement, + trendz: $('#perp-trendz-toggle') as HTMLInputElement, + offline: $('#perp-offline-toggle') as HTMLInputElement, + }; + const cards = { + edge: $('#perp-edge-card'), + trendz: $('#perp-trendz-card'), + offline: $('#perp-offline-card'), + }; + + let st = { + devices: 1000, _prevDevices: 1000, prod: 1, dev: 0, + addons: { edge: { on: false, count: 2 }, trendz: { on: false }, offline: { on: false } } + }; + + let _perpGtmTimer: ReturnType | null = null; + function sendPerpGTM() { + if (_perpGtmTimer) clearTimeout(_perpGtmTimer); + _perpGtmTimer = setTimeout(() => { + const gtm: Record = { + event: 'calculator_interaction', + calculator_devices: st.devices, + calculator_plan: 'Platform', + calculator_instances: st.prod, + calculator_addon_dev_area: st.dev > 0, + calculator_addon_trendz_bot_area: st.addons.trendz.on, + calculator_addon_bot_area: st.addons.edge.on, + calculator_messages: null, + calculator_messages_unit: null, + calculator_instances_monthly: null, + calculator_extra_storage_cost: null, + }; + for (let i = 0; i <= 9; i++) gtm[`calculator_profile_${i}_json`] = null; + (window as any).dataLayer?.push(gtm); + }, 3000); + } + + const getComplimentary = () => Math.floor(Math.max(0, st.devices - PERP.includedDevices) / 5000); + const getMinProd = () => PERP.includedProdInstances + getComplimentary(); + const prodDesc = $('#perp-prod-desc') as HTMLElement; + const prodInp = $('#perp-prod') as HTMLInputElement; + const prodStepper = $('#perp-prod-stepper'); + + function updateProdForDevices() { + const complimentary = getComplimentary(); + const minProd = getMinProd(); + const prevMinProd = PERP.includedProdInstances + Math.floor(Math.max(0, st._prevDevices - PERP.includedDevices) / 5000); + if (st.prod <= prevMinProd || st.prod < minProd) { + st.prod = minProd; + } + prodInp.value = String(st.prod); + (prodStepper.querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = st.prod <= minProd; + if (complimentary > 0) { + prodDesc.textContent = `1 included + ${complimentary} complimentary for ${fN(complimentary * 5000)} extra devices.`; + } else { + prodDesc.textContent = '1 included. Add a 2nd for high availability (HA). For every 5,000 extra devices, one production instance is added at no charge.'; + } + st._prevDevices = st.devices; + } + + const fmt = (n: number) => '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).replace(/,/g, ' '); + const fN = (n: number) => n.toLocaleString('en-US').replace(/,/g, ' '); + + const infoSvg = ''; + function tip(t: string) { return ` ${infoSvg}${t}`; } + function row(l: string, v: string, t?: string) { return `
${l}:${v}${t ? tip(t) : ''}
`; } + + function calculate() { + updateProdForDevices(); + let total = PERP.price; + + const extraDev = Math.max(0, st.devices - PERP.includedDevices); + const extraDevCost = extraDev * PERP.extraDevicePrice; + total += extraDevCost; + + const complimentaryProd = getComplimentary(); + const extraProd = Math.max(0, st.prod - PERP.includedProdInstances - complimentaryProd); + const extraProdCost = extraProd * PERP.extraProdInstancePrice; + total += extraProdCost; + + const devCost = st.dev * PERP.devQaExtraInstancePrice; + total += devCost; + + let edgeCost = 0; + if (st.addons.edge.on) { + const extraEdges = Math.max(0, st.addons.edge.count - PERP.edgeInstancesIncluded); + edgeCost = PERP.edgeMonthPrice + extraEdges * PERP.extraEdgePrice; + total += edgeCost; + } + + let trendzCost = 0; + if (st.addons.trendz.on) { + const trendzExtra = extraDev > 0 ? extraDev * PERP.trendzExtraDevicePrice : 0; + trendzCost = PERP.trendzMonthPrice + trendzExtra; + total += trendzCost; + } + + let offlineCost = 0; + if (st.addons.offline.on) { + offlineCost = PERP.offlineModePrice; + total += offlineCost; + } + + // Results — plan section + let html = `
Platform${fmt(PERP.price)}${tip('Total one-time perpetual license cost including extra resources.')}
`; + html += row('Included Devices', fN(PERP.includedDevices)); + html += row('Included Prod Instances', fN(PERP.includedProdInstances), 'Number of production instances covered by the perpetual license base price.'); + if (complimentaryProd > 0) html += row('Complimentary Prod Instances', fN(complimentaryProd), '1 Production Instance provided at no charge for every 5,000 extra devices.'); + if (extraProd > 0) html += row('Extra Prod Instances', fN(extraProd), 'Additional instances'); + if (extraProdCost > 0) html += row('Extra Prod Instances Cost', fmt(extraProdCost), `${fN(extraProd)} × ${fmt(PERP.extraProdInstancePrice)}`); + html += row('White Labeling', 'Enabled', 'Customization of the platform interface with your corporate branding.'); + html += row('Base Price', fmt(PERP.price), 'One-time license fee before extras and add-ons.'); + if (extraDev > 0) html += row('Extra Devices', fN(extraDev), 'Devices beyond included'); + if (extraDevCost > 0) html += row('Extra Devices Cost', fmt(extraDevCost), `${fN(extraDev)} × ${fmt(PERP.extraDevicePrice)}`); + if (st.dev > 0) html += row('Extra Dev Instances', fN(st.dev)); + if (devCost > 0) html += row('Extra Dev Instances Cost', fmt(devCost), `${fN(st.dev)} × ${fmt(PERP.devQaExtraInstancePrice)}`); + html += `
`; + + // Add-ons + html += `
Add-ons
`; + + // Edge + if (st.addons.edge.on) { + const extraEdges = Math.max(0, st.addons.edge.count - PERP.edgeInstancesIncluded); + html += `
Edge Computing${fmt(edgeCost)}${tip(`Total one-time Edge Computing cost. Calculation: ${fmt(edgeCost)} = ${fmt(PERP.edgeMonthPrice)} (base) + ${fmt(extraEdges * PERP.extraEdgePrice)} (extra edges)`)}
`; + html += row('Included Edges', fN(PERP.edgeInstancesIncluded), 'Number of Edge instances covered by the Edge Computing add-on base price.'); + html += row('Add-on Base Price', fmt(PERP.edgeMonthPrice), 'One-time base fee for the Edge Computing add-on.'); + if (extraEdges > 0) html += row('Extra Edges', fN(extraEdges)); + if (extraEdges > 0) html += row('Extra Edges Cost', fmt(extraEdges * PERP.extraEdgePrice), `${fN(extraEdges)} × ${fmt(PERP.extraEdgePrice)}`); + html += `
`; + } else { + html += `
Edge Computing
`; + } + + // Trendz + if (st.addons.trendz.on) { + const hasExtra = extraDev > 0 && PERP.trendzExtraDevicePrice > 0; + const trendzExtra = extraDev > 0 ? extraDev * PERP.trendzExtraDevicePrice : 0; + html += `
Trendz Analytics${fmt(trendzCost)}${tip(`Total one-time Trendz cost. ${fmt(trendzCost)} = ${fmt(PERP.trendzMonthPrice)} (base price) + ${fmt(trendzExtra)} (extra devices)`)}
`; + html += row('Included Devices', fN(PERP.includedDevices), 'Number of devices covered by the Trendz perpetual license base price.'); + html += row('Add-on Base Price', fmt(PERP.trendzMonthPrice), 'Base cost for the Trendz Analytics add-on.'); + if (hasExtra) { + html += row('Extra Devices', fN(extraDev)); + html += row('Extra Devices Cost', fmt(extraDev * PERP.trendzExtraDevicePrice), `${fN(extraDev)} × ${fmt(PERP.trendzExtraDevicePrice)}`); + } + html += `
`; + } else { + html += `
Trendz Analytics
`; + } + + // Offline + if (st.addons.offline.on) { + html += `
Offline Mode${fmt(offlineCost)}${tip('Enables full platform functionality in environments without internet connection.')}
`; + } else { + html += `
Offline Mode
`; + } + + const _st = results.parentElement?.scrollTop || 0; results.innerHTML = html; if (results.parentElement) results.parentElement.scrollTop = _st; + + // Total tooltip + const totalParts = [`${fmt(PERP.price)} (base)`]; + if (extraDevCost > 0) totalParts.push(`${fmt(extraDevCost)} (extra devices)`); + if (extraProdCost > 0) totalParts.push(`${fmt(extraProdCost)} (extra prod)`); + if (devCost > 0) totalParts.push(`${fmt(devCost)} (dev)`); + if (edgeCost > 0) totalParts.push(`${fmt(edgeCost)} (Edge)`); + if (trendzCost > 0) totalParts.push(`${fmt(trendzCost)} (Trendz)`); + if (offlineCost > 0) totalParts.push(`${fmt(offlineCost)} (Offline)`); + + footer.innerHTML = `
Total${fmt(total)}${tip(totalParts.join(' + '))}
Contact Us`; + + sendPerpGTM(); + } + + // Delegated handler for the [data-enable-perp-addon] buttons rendered + // inside the results panel. Bound once instead of re-bound per calculate(). + results.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement)?.closest('[data-enable-perp-addon]') as HTMLElement | null; + if (!btn) return; + const key = btn.dataset.enablePerpAddon as string; + if (key === 'edge') { st.addons.edge.on = true; st.addons.edge.count = Math.max(st.addons.edge.count, PERP.edgeInstancesIncluded); toggles.edge.checked = true; cards.edge.classList.add('active'); $('#perp-edge-counter').classList.remove('hidden'); } + if (key === 'trendz') { st.addons.trendz.on = true; toggles.trendz.checked = true; cards.trendz.classList.add('active'); } + if (key === 'offline') { st.addons.offline.on = true; toggles.offline.checked = true; cards.offline.classList.add('active'); } + calculate(); + }); + + function buildSummary(total: number, extraDev: number, extraDevCost: number, extraProd: number, extraProdCost: number, devCost: number, edgeCost: number, trendzCost: number, offlineCost: number): string { + const comp = getComplimentary(); + + let msg = `Perpetual License: Platform\n\n`; + + msg += `Platform: ${fmt(PERP.price)}\n`; + msg += `- Included Devices: ${fN(PERP.includedDevices)}\n`; + msg += `- Included Prod Instances: ${fN(PERP.includedProdInstances)}\n`; + if (comp > 0) msg += `- Complimentary Prod Instances: ${fN(comp)}\n`; + if (extraProd > 0) { + msg += `- Extra Prod Instances: ${fN(extraProd)}\n`; + msg += `- Extra Prod Instances Cost: ${fmt(extraProdCost)}\n`; + } + msg += `- White Labeling: Enabled\n`; + msg += `- Base Price: ${fmt(PERP.price)}\n`; + if (extraDev > 0) { + msg += `- Extra Devices: ${fN(extraDev)}\n`; + msg += `- Extra Devices Cost: ${fmt(extraDevCost)}\n`; + } + if (st.dev > 0) { + msg += `- Extra Dev Instances: ${fN(st.dev)}\n`; + msg += `- Extra Dev Instances Cost: ${fmt(devCost)}\n`; + } + + const hasAddons = st.addons.edge.on || st.addons.trendz.on || st.addons.offline.on; + if (hasAddons) { + msg += `\nAdd-ons:\n`; + + if (st.addons.edge.on) { + const extraEdges = Math.max(0, st.addons.edge.count - PERP.edgeInstancesIncluded); + msg += `\nEdge Computing: ${fmt(edgeCost)}\n`; + msg += `- Included Edges: ${fN(PERP.edgeInstancesIncluded)}\n`; + msg += `- Add-on Base Price: ${fmt(PERP.edgeMonthPrice)}\n`; + if (extraEdges > 0) { + msg += `- Extra Edges: ${fN(extraEdges)}\n`; + msg += `- Extra Edges Cost: ${fmt(extraEdges * PERP.extraEdgePrice)}\n`; + } + } + + if (st.addons.trendz.on) { + const trendzExtra = extraDev > 0 ? extraDev * PERP.trendzExtraDevicePrice : 0; + msg += `\nTrendz Analytics: ${fmt(trendzCost)}\n`; + msg += `- Add-on Base Price: ${fmt(PERP.trendzMonthPrice)}\n`; + if (extraDev > 0 && trendzExtra > 0) { + msg += `- Extra Devices: ${fN(extraDev)}\n`; + msg += `- Extra Devices Cost: ${fmt(trendzExtra)}\n`; + } + } + + if (st.addons.offline.on) { + msg += `\nOffline Mode: ${fmt(offlineCost)}\n`; + } + } + + msg += `\nTotal: ${fmt(total)}`; + return msg; + } + + // rAF-batch calculate() during continuous input; blur/change still call directly. + let _calcQueued = false; + function scheduleCalculate() { + if (_calcQueued) return; + _calcQueued = true; + requestAnimationFrame(() => { _calcQueued = false; calculate(); }); + } + + // ─── Slider ─── + slider.addEventListener('input', () => { st.devices = sliderToReal(parseFloat(slider.value)); devInput.value = String(st.devices); sliderProgress(slider); scheduleCalculate(); }); + devInput.addEventListener('input', () => { const v = parseInt(devInput.value); if (!isNaN(v) && v > 0) { st.devices = v; slider.value = String(Math.min(PERP_SLIDER_MAX, realToSlider(Math.min(PERP_REAL_MAX, v)))); sliderProgress(slider); scheduleCalculate(); } }); + devInput.addEventListener('blur', () => { const v = Math.max(1000, parseInt(devInput.value) || 1000); st.devices = v; devInput.value = String(v); slider.value = String(Math.min(PERP_SLIDER_MAX, realToSlider(Math.min(PERP_REAL_MAX, v)))); sliderProgress(slider); calculate(); }); + + // ─── Steppers ─── + function bindStepper(id: string, key: 'prod' | 'dev', getMin: () => number) { + const sInp = $(id).querySelector('input[type="number"]') as HTMLInputElement; + $(id).querySelectorAll('.calc-stepper-btn').forEach(btn => { + btn.addEventListener('click', () => { + if ((btn as HTMLButtonElement).disabled) return; + const min = getMin(); + (st as any)[key] = (btn as HTMLElement).dataset.action === 'increment' ? (st as any)[key] + 1 : Math.max(min, (st as any)[key] - 1); + sInp.value = String((st as any)[key]); + ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = (st as any)[key] <= min; + calculate(); + }); + }); + sInp.addEventListener('input', () => { const min = getMin(); const v = parseInt(sInp.value); if (!isNaN(v) && v >= min) { (st as any)[key] = v; ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; scheduleCalculate(); } }); + sInp.addEventListener('blur', () => { const min = getMin(); const v = Math.max(min, parseInt(sInp.value) || min); (st as any)[key] = v; sInp.value = String(v); ($(id).querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calculate(); }); + } + bindStepper('#perp-prod-stepper', 'prod', getMinProd); + bindStepper('#perp-dev-stepper', 'dev', () => 0); + + // ─── Edge stepper ─── + const edgeInp = $('#perp-edge') as HTMLInputElement; + const edgeCounter = $('#perp-edge-counter'); + $('#perp-edge-stepper').querySelectorAll('.calc-stepper-btn').forEach(btn => { + btn.addEventListener('click', () => { + if ((btn as HTMLButtonElement).disabled) return; + const min = PERP.edgeInstancesIncluded; + st.addons.edge.count = (btn as HTMLElement).dataset.action === 'increment' ? st.addons.edge.count + 1 : Math.max(min, st.addons.edge.count - 1); + edgeInp.value = String(st.addons.edge.count); + ($('#perp-edge-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = st.addons.edge.count <= min; + calculate(); + }); + }); + edgeInp.addEventListener('input', () => { const v = parseInt(edgeInp.value); if (!isNaN(v) && v >= 1) { st.addons.edge.count = v; scheduleCalculate(); } }); + edgeInp.addEventListener('blur', () => { const min = PERP.edgeInstancesIncluded; const v = Math.max(min, parseInt(edgeInp.value) || min); st.addons.edge.count = v; edgeInp.value = String(v); ($('#perp-edge-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = v <= min; calculate(); }); + + // ─── Addon toggles ─── + toggles.edge.addEventListener('change', () => { + st.addons.edge.on = toggles.edge.checked; + cards.edge.classList.toggle('active', st.addons.edge.on); + edgeCounter.classList.toggle('hidden', !st.addons.edge.on); + if (st.addons.edge.on) st.addons.edge.count = Math.max(st.addons.edge.count, PERP.edgeInstancesIncluded); + $('#perp-edge-desc').textContent = st.addons.edge.on ? `${PERP.edgeInstancesIncluded} Edge instances included.` : 'Process data where it is collected.'; + calculate(); + }); + toggles.trendz.addEventListener('change', () => { st.addons.trendz.on = toggles.trendz.checked; cards.trendz.classList.toggle('active', st.addons.trendz.on); calculate(); }); + toggles.offline.addEventListener('change', () => { st.addons.offline.on = toggles.offline.checked; cards.offline.classList.toggle('active', st.addons.offline.on); calculate(); }); + + function getCurrentSummary(): string { + const extraDev = Math.max(0, st.devices - PERP.includedDevices); + const extraDevCost = extraDev * PERP.extraDevicePrice; + const compProd = getComplimentary(); + const extraProd = Math.max(0, st.prod - PERP.includedProdInstances - compProd); + const extraProdCost = extraProd * PERP.extraProdInstancePrice; + const devCostVal = st.dev * PERP.devQaExtraInstancePrice; + let edgeCostVal = 0; + if (st.addons.edge.on) edgeCostVal = PERP.edgeMonthPrice + Math.max(0, st.addons.edge.count - PERP.edgeInstancesIncluded) * PERP.extraEdgePrice; + let trendzCostVal = 0; + if (st.addons.trendz.on) trendzCostVal = PERP.trendzMonthPrice + (extraDev > 0 ? extraDev * PERP.trendzExtraDevicePrice : 0); + const offlineCostVal = st.addons.offline.on ? PERP.offlineModePrice : 0; + const totalVal = PERP.price + extraDevCost + extraProdCost + devCostVal + edgeCostVal + trendzCostVal + offlineCostVal; + return buildSummary(totalVal, extraDev, extraDevCost, extraProd, extraProdCost, devCostVal, edgeCostVal, trendzCostVal, offlineCostVal); + } + + // ─── Copy ─── + modal.querySelector('[data-calc-copy]')?.addEventListener('click', (e) => { + const btn = e.currentTarget as HTMLElement; + const text = getCurrentSummary(); + const flashCopied = () => { + btn.classList.add('copied'); + setTimeout(() => btn.classList.remove('copied'), 2000); + }; + navigator.clipboard.writeText(text).then(flashCopied).catch(() => {}); + }); + + // ─── Download ─── + modal.querySelector('[data-calc-download]')?.addEventListener('click', () => { + const blob = new Blob([getCurrentSummary()], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'perpetual-license-calculation.txt'; a.click(); + URL.revokeObjectURL(url); + }); + + // ─── Modal open ─── + function closeCalcModal() { calcModalClose(); setTimeout(() => { modal!.style.display = 'none'; }, 300); } + $('[data-calc-close]').addEventListener('click', closeCalcModal); + modal.addEventListener('click', (e) => { if (e.target === modal) closeCalcModal(); }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display !== 'none') closeCalcModal(); }); + + // ─── Reset ─── + $('[data-calc-reset]').addEventListener('click', () => { + st = { devices: 1000, _prevDevices: 1000, prod: 1, dev: 0, addons: { edge: { on: false, count: 2 }, trendz: { on: false }, offline: { on: false } } }; + prodDesc.textContent = '1 included. Add a 2nd for high availability (HA). For every 5,000 extra devices, one production instance is added at no charge.'; + devInput.value = '1000'; slider.value = '1000'; + ($('#perp-prod') as HTMLInputElement).value = '1'; + ($('#perp-dev') as HTMLInputElement).value = '0'; + edgeInp.value = '2'; + toggles.edge.checked = false; toggles.trendz.checked = false; toggles.offline.checked = false; + cards.edge.classList.remove('active'); cards.trendz.classList.remove('active'); cards.offline.classList.remove('active'); + edgeCounter.classList.add('hidden'); + $('#perp-edge-desc').textContent = 'Process data where it is collected.'; + ($('#perp-prod-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = true; + ($('#perp-dev-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = true; + ($('#perp-edge-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = true; + sliderProgress(slider); calculate(); + }); + + sliderProgress(slider); calculate(); + + openImpl = () => { + modal.style.display = ''; + calcModalOpen(); + sliderProgress(slider); + requestAnimationFrame(() => initAllSliders(modal)); + calculate(); + }; +} + +export function openTbPerpCalc() { + if (!openImpl) initTbPerpCalc(); + openImpl?.(); +} From aa8f723c807552fcb3a229de8e2d7649000aad9f Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 25 May 2026 18:50:42 +0300 Subject: [PATCH 05/13] Lazy-load inline TBMQ calculators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three inline TBMQ calculators (PAYG / Perpetual / Private Cloud, ~36 KiB JS combined) previously downloaded and parsed on every /pricing/ nav even though their init was already gated on the TBMQ product tab becoming active. Extract each body into `src/scripts/pricing/calc-tbmq-*.ts` and replace the component + diff --git a/src/components/Pricing/TbmqPerpetualCalculator.astro b/src/components/Pricing/TbmqPerpetualCalculator.astro index 552463210..93e6b768e 100644 --- a/src/components/Pricing/TbmqPerpetualCalculator.astro +++ b/src/components/Pricing/TbmqPerpetualCalculator.astro @@ -40,143 +40,13 @@ import CalculatorInline from './CalculatorInline.astro'; diff --git a/src/components/Pricing/TbmqPrivateCloudCalculator.astro b/src/components/Pricing/TbmqPrivateCloudCalculator.astro index cb6acfd50..f9c106df4 100644 --- a/src/components/Pricing/TbmqPrivateCloudCalculator.astro +++ b/src/components/Pricing/TbmqPrivateCloudCalculator.astro @@ -54,333 +54,15 @@ import CalculatorInline from './CalculatorInline.astro';