Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/data/pricing/tb-cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export const tbCloudData: CloudRegionData = {
ctaText: 'Start Free',
ctaHref: 'https://eu.thingsboard.cloud/signup',
ctaPrimary: false,
gtmId: 'Pricing_PE_Cloud_Maker',
gtmId: 'Pricing_PE_Cloud_Maker_EU',
features: [
{ text: '5 devices' },
{ text: '5 assets' },
Expand Down Expand Up @@ -221,7 +221,7 @@ export const tbCloudData: CloudRegionData = {
ctaText: 'Get started',
ctaHref: 'https://eu.thingsboard.cloud/signup',
ctaPrimary: false,
gtmId: 'Pricing_PE_Cloud_Prototype',
gtmId: 'Pricing_PE_Cloud_Prototype_EU',
features: [
{ text: '50 devices' },
{ text: '50 assets' },
Expand Down Expand Up @@ -251,7 +251,7 @@ export const tbCloudData: CloudRegionData = {
ctaHref: 'https://eu.thingsboard.cloud/signup',
ctaPrimary: true,
popular: true,
gtmId: 'Pricing_PE_Cloud_Pilot',
gtmId: 'Pricing_PE_Cloud_Pilot_EU',
features: [
{ text: '100 devices' },
{ text: '100 assets' },
Expand Down Expand Up @@ -284,7 +284,7 @@ export const tbCloudData: CloudRegionData = {
ctaText: 'Get started',
ctaHref: 'https://eu.thingsboard.cloud/signup',
ctaPrimary: false,
gtmId: 'Pricing_PE_Cloud_Startup',
gtmId: 'Pricing_PE_Cloud_Startup_EU',
features: [
{ text: '500 devices' },
{ text: '500 assets' },
Expand Down Expand Up @@ -319,7 +319,7 @@ export const tbCloudData: CloudRegionData = {
ctaText: 'Get started',
ctaHref: 'https://eu.thingsboard.cloud/signup',
ctaPrimary: false,
gtmId: 'Pricing_PE_Cloud_Business',
gtmId: 'Pricing_PE_Cloud_Business_EU',
features: [
{ text: '1,000 devices' },
{ text: '1,000 assets' },
Expand Down
15 changes: 13 additions & 2 deletions src/pages/pricing/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ const tbmqSubTabs = enrichTabs([
leftValue="monthly"
rightValue="annual"
id="tb-pc-billing"
leftGtmId="Pricing_PE_PrivateCloud_Monthly"
rightGtmId="Pricing_PE_PrivateCloud_Annual"
/>
<p class="pc-billing-hint">Save 10% on annual plans</p>
</div>
Expand Down Expand Up @@ -870,6 +872,8 @@ const tbmqSubTabs = enrichTabs([
targetItem.classList.add('expanded');
const questionBtn = targetItem.querySelector('.faq-question');
if (questionBtn) questionBtn.setAttribute('aria-expanded', 'true');
// Track FAQ open from deep link (same event as accordion-open handler).
window.dataLayer?.push({ event: 'faq_node_interaction', faq_node_id: targetItem.id });
setTimeout(() => targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' }), 100);
}

Expand Down Expand Up @@ -1228,6 +1232,8 @@ const tbmqSubTabs = enrichTabs([
e.preventDefault();
const faqId = copyLink.dataset.faqId;
if (!faqId?.trim()) return;
// Track copy-link clicks (GTM trigger + GA4 tag owned by marketing).
window.dataLayer?.push({ event: 'faq_copy_link', faq_node_id: copyLink.dataset.faqId });
const url = new URL(window.location.href);
url.hash = faqId;
navigator.clipboard
Expand All @@ -1245,8 +1251,13 @@ const tbmqSubTabs = enrichTabs([
if (question) {
const item = question.closest('.faq-item');
if (item) {
item.classList.toggle('expanded');
question.setAttribute('aria-expanded', String(item.classList.contains('expanded')));
const nowExpanded = item.classList.toggle('expanded');
question.setAttribute('aria-expanded', String(nowExpanded));
// Track FAQ opens only (not collapses). Marketing owns the
// GTM trigger + GA4 tag (event `faq_node_interaction`).
if (nowExpanded && item.id) {
window.dataLayer?.push({ event: 'faq_node_interaction', faq_node_id: item.id });
}
}
return;
}
Expand Down
128 changes: 128 additions & 0 deletions src/scripts/pricing/calc-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Shared analytics helpers for the pricing calculators. Centralizes the
// debounced `calculator_interaction` push (one timer per calculator) and a
// flush(), so the final — most complete — config isn't lost when the user
// closes the modal or leaves the page within the 3s debounce window.

// The 5 pricing calculators. Threaded through the helpers so a mistyped value
// fails to compile instead of silently mis-bucketing in GA4.
export type CalculatorType = 'tb_payg' | 'tb_pc' | 'tbmq_payg' | 'tbmq_pc' | 'tbmq_perp';

// Debounce settle for calculator_interaction — single home for the value.
const SETTLE_MS = 3000;

// Every live calculator's flush fn. Drained on page hide so a pending config
// is sent before unload.
const flushers = new Set<() => void>();
let globalBound = false;
function bindGlobalFlush(): void {
if (globalBound) return;
globalBound = true;
const flushAll = () => flushers.forEach((f) => f());
window.addEventListener('pagehide', flushAll);
document.addEventListener('visibilitychange', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

visibilitychangehidden fires on every tab switch, not only on unload — so a config that's still inside the 3s debounce window gets flushed the moment the user tabs away. If they then return and keep editing, a second calculator_interaction fires for what's really one session, which partly undercuts the per-config dedup the debounce is meant to provide. pagehide already covers the true unload case in modern browsers; this extra flush is mainly belt-and-suspenders for mobile/bfcache. Is the tab-away over-count acceptable for marketing's dedup, or would flushing only on pagehide be closer to the intended "one configuration counts once"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the visibilitychange → hidden flush by design. It's the more reliable "last chance to send" signal — on mobile / bfcache pagehide frequently doesn't fire (app backgrounding, OS tab discard), whereas visibilitychange → hidden does. Dropping it would systematically lose the final, highest-intent config for mobile users who bounce within the 3s debounce — and a never-sent event is unrecoverable.

The tab-away over-count you flagged is real but bounded and recoverable: every calculator_interaction carries the full config, so it's deduped downstream (count distinct config per session in GA4 / BigQuery). So we keep complete capture and dedup in reporting, rather than trade unrecoverable mobile data loss for client-side single-fire.

I've noted the downstream dedup requirement for marketing in the tracking ticket.

if (document.visibilityState === 'hidden') flushAll();
});
}

// One debounced pusher per calculator. push() re-arms a 3s timer (only the last
// settled config fires); flush() sends any pending push immediately.
export function makeInteractionPusher(calculatorType: CalculatorType) {
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Record<string, unknown> | null = null;
const flush = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (pending) {
window.dataLayer?.push(pending);
pending = null;
}
};
// calculator_type is injected here (typed once) — callers omit it.
const push = (payload: Record<string, unknown>) => {
pending = { ...payload, calculator_type: calculatorType };
if (timer) clearTimeout(timer);
timer = setTimeout(flush, SETTLE_MS);
};
flushers.add(flush);
bindGlobalFlush();
return { push, flush };
}

// Immediate (non-debounced) calculator event — opens, exports, CTA clicks.
export function pushCalculatorEvent(payload: Record<string, unknown>): void {
window.dataLayer?.push(payload);
}

// Delegated footer-CTA (`.calc-cta`) click tracking, bound once on a stable
// parent. getState() supplies the per-calculator extra fields (total, and plan
// for the ThingsBoard calcs) so the event reports the value live at click time.
export function bindCtaTracking(
root: HTMLElement,
calculatorType: CalculatorType,
getState: () => Record<string, unknown>,
): void {
root.addEventListener('click', (e) => {
const cta = e.target instanceof Element ? e.target.closest('.calc-cta') : null;
if (!cta) return;
pushCalculatorEvent({
event: 'calculator_cta_click',
calculator_type: calculatorType,
cta_label: cta.textContent?.trim(),
cta_href: cta.getAttribute('href'),
...getState(),
});
});
}

// Copy/Download summary export event — one shape for all 5 calculators.
export function pushExport(
calculatorType: CalculatorType,
method: 'copy' | 'download',
extra: Record<string, unknown>,
): void {
pushCalculatorEvent({
event: 'calculator_export',
calculator_type: calculatorType,
method,
...extra,
});
}

// Calculator-open event (the 2 modal calculators).
export function pushCalculatorOpen(calculatorType: CalculatorType): void {
pushCalculatorEvent({ event: 'calculator_open', calculator_type: calculatorType });
}

// Copy/Download summary buttons — one delegated binding per calculator.
// buildText() supplies the summary text at click time; getExtra() the event fields.
export function bindExportButtons(
root: HTMLElement,
calculatorType: CalculatorType,
opts: { buildText: () => string; filename: string; getExtra?: () => Record<string, unknown> },
): void {
const extra = () => (opts.getExtra ? opts.getExtra() : {});
root.querySelector('[data-calc-copy]')?.addEventListener('click', (e) => {
// Capture currentTarget before the async clipboard promise nulls it.
const btn = e.currentTarget as HTMLElement;
navigator.clipboard
.writeText(opts.buildText())
.then(() => {
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 2000);
})
.catch(() => {});
pushExport(calculatorType, 'copy', extra());
});
root.querySelector('[data-calc-download]')?.addEventListener('click', () => {
const blob = new Blob([opts.buildText()], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = opts.filename;
a.click();
URL.revokeObjectURL(url);
pushExport(calculatorType, 'download', extra());
});
}
91 changes: 40 additions & 51 deletions src/scripts/pricing/calc-tb-payg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// don't open it.

import { makeModalController } from '@root/scripts/pricing/modal-controller';
import { makeInteractionPusher, pushCalculatorOpen, bindCtaTracking, bindExportButtons, type CalculatorType } from '@root/scripts/pricing/calc-analytics';

declare function sliderProgress(slider: HTMLInputElement): void;
declare function initAllSliders(root?: HTMLElement | Document): void;
Expand Down Expand Up @@ -36,6 +37,8 @@ const SM_DESCS: Record<string, { prod: string; dev: string }> = {
// `openTbPaygCalc` work without re-running the (idempotent) init body.
let openImpl: (() => void) | null = null;

const CALC_TYPE: CalculatorType = 'tb_payg';

export function initTbPaygCalc() {
if (openImpl) return;
const modal = document.getElementById('tb-payg-calc');
Expand All @@ -60,27 +63,24 @@ export function initTbPaygCalc() {

let state = { devices: 10, prodInstances: 1, devInstances: 0, addons: { edge: { on: false, count: 1 }, trendz: { on: false }, mobile: { on: false } } };

let _smGtmTimer: ReturnType<typeof setTimeout> | null = null;
function sendSmGTM() {
if (_smGtmTimer) clearTimeout(_smGtmTimer);
_smGtmTimer = setTimeout(() => {
const plan = getPlan(state.devices);
const gtm: Record<string, any> = {
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.dataLayer?.push(gtm);
}, 3000);
// Last settled total + plan, updated wherever sendSmGTM runs, read by the
// footer CTA click handler so it reports the value live at click time.
let lastTotal: number | null = null;
let lastPlan = '';

const calcAnalytics = makeInteractionPusher(CALC_TYPE);
function sendSmGTM(total: number | null) {
const isEnterprise = state.devices >= SM_ENTERPRISE;
calcAnalytics.push({
event: 'calculator_interaction',
calculator_devices: state.devices,
calculator_plan: isEnterprise ? 'Enterprise' : getPlan(state.devices).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_total: isEnterprise ? null : total,
});
}

const fmt = (n: number) => '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).replace(/,/g, ' ');
Expand Down Expand Up @@ -163,8 +163,8 @@ export function initTbPaygCalc() {
}
}

function calculate() {
if (state.devices >= SM_ENTERPRISE) { renderEnterprise(); return; }
function calculate(opts?: { track?: boolean }) {
if (state.devices >= SM_ENTERPRISE) { lastTotal = null; lastPlan = 'Enterprise'; renderEnterprise(); if (opts?.track !== false) sendSmGTM(null); return; }
const plan = getPlan(state.devices);
updateUI(plan);

Expand Down Expand Up @@ -290,7 +290,8 @@ export function initTbPaygCalc() {

footer.innerHTML = `<div class="calc-total-row"><span class="calc-total-label">Total</span><span class="calc-total-amount">${fmt(total)}/month${tip(totalParts.join(' + '))}</span></div><a class="calc-cta" href="${ctaUrl}" target="_blank" rel="noopener noreferrer">Get started</a>`;

sendSmGTM();
lastTotal = total; lastPlan = plan.name;
if (opts?.track !== false) sendSmGTM(total);
}

// Delegated handler for [data-enable-addon] buttons rendered inside the
Expand All @@ -308,6 +309,11 @@ export function initTbPaygCalc() {
calculate();
});

// Footer CTA tracking. Footer re-renders every recalc, so delegate one click
// listener on the stable modal element. lastTotal/lastPlan are the settled
// values at click time.
bindCtaTracking(modal, CALC_TYPE, () => ({ calculator_total: lastTotal, calculator_plan: lastPlan }));

function renderEnterprise() {
let html = `<div class="calc-optimal-plan"><span class="calc-optimal-label">Deployment Summary</span></div>`;
html += `<p style="font-size:14px;color:var(--color-text-secondary);margin-bottom:16px;">You are building at an impressive scale.</p>`;
Expand Down Expand Up @@ -398,10 +404,12 @@ export function initTbPaygCalc() {
const { open: openModal } = makeModalController({
modal,
onOpen: () => {
pushCalculatorOpen(CALC_TYPE);
updateProgress();
requestAnimationFrame(() => initAllSliders(modal));
calculate();
calculate({ track: false });
},
onClose: () => calcAnalytics.flush(),
});

// Build clipboard text from current state
Expand Down Expand Up @@ -460,30 +468,11 @@ export function initTbPaygCalc() {
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);
// Copy + download export buttons.
bindExportButtons(modal, CALC_TYPE, {
buildText: buildSummaryText,
filename: 'self-managed-calculation.txt',
getExtra: () => ({ calculator_total: lastTotal, calculator_plan: lastPlan }),
});

// Reset
Expand All @@ -495,10 +484,10 @@ export function initTbPaygCalc() {
edgeCounter.classList.add('hidden');
edgeCount.value = '1';
($('#sm-edge-stepper').querySelector('[data-action="decrement"]') as HTMLButtonElement).disabled = true;
updateProgress(); calculate();
updateProgress(); calculate({ track: false });
});

updateProgress(); calculate();
updateProgress(); calculate({ track: false });
requestAnimationFrame(() => initAllSliders(modal));
openImpl = openModal;
}
Expand Down
Loading