Skip to content

feat(pricing): instrument pricing calculators and FAQ with analytics events#509

Merged
vvlladd28 merged 2 commits into
thingsboard:developfrom
rusikv:pricing-analytics
Jun 25, 2026
Merged

feat(pricing): instrument pricing calculators and FAQ with analytics events#509
vvlladd28 merged 2 commits into
thingsboard:developfrom
rusikv:pricing-analytics

Conversation

@rusikv

@rusikv rusikv commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds consistent, deduplicated analytics across the pricing page: all 5 calculators now emit a calculator_interaction event with a calculator_type discriminator and calculator_total, plus new conversion/engagement events for CTA clicks, modal opens, summary exports, and FAQ interactions. The 3 TBMQ calculators (PAYG, Private Cloud, Perpetual) were previously uninstrumented and are now covered. A shared helper centralizes the debounce/flush logic so a partially-typed config isn't lost when the user closes the modal or leaves the page mid-debounce.

What changed

Shared helper (src/scripts/pricing/calc-analytics.ts, new)

  • makeInteractionPusher() — one 3s-debounced calculator_interaction pusher per calculator (only the last settled config fires), with flush() to send immediately.
  • Global pagehide / visibilitychange (hidden) listeners drain all pending pushes before unload.
  • pushCalculatorEvent() for immediate events, bindCtaTracking() for delegated .calc-cta clicks, and pushExport() for copy/download.

Calculator instrumentation (all 5 calcs)

  • Emit calculator_interaction with calculator_type (tb_payg, tb_pc, tbmq_payg, tbmq_pc, tbmq_perp) and calculator_total.
  • The 3 TBMQ calcs (calc-tbmq-payg, calc-tbmq-pc, calc-tbmq-perp) are newly instrumented.
  • Interaction events are gated behind a track flag so they fire only on real user input, never on init / open / reset.

New events

  • calculator_cta_click — footer CTA conversion (all 5), reporting the total (and plan, for the ThingsBoard calcs) live at click time.
  • calculator_open — the 2 modal calculators (tb_payg, tb_pc).
  • calculator_export — copy and download summary (all 5).

FAQ (src/pages/pricing/index.astro)

  • faq_node_interaction now also fires when a question is opened via deep link, and on accordion opens only (not collapses).
  • New faq_copy_link event on copy-link clicks.

Fixes & cleanup

  • tb_pc: added calculator_profile_count / calculator_profile_truncated.
  • Enterprise calculator_plan set to 'Enterprise' in both tb_payg and tb_pc (previously the plan name / empty string).
  • Removed always-null legacy fields from the ThingsBoard calcs (calculator_messages, calculator_messages_unit, calculator_instances_monthly; tb_payg also dropped its always-null calculator_extra_storage_cost and calculator_profile_*_json block — tb_pc keeps both since they carry real values there).
  • EU cloud plan gtmIds suffixed with _EU (src/data/pricing/tb-cloud.ts, 5 plans); added gtmIds to the tb-pc billing toggle (src/pages/pricing/index.astro).

Testing

  • ESLint clean; pnpm build:fast passed.

Follow-up (marketing)

The GTM container triggers and GA4 tag config for these new events (calculator_interaction, calculator_cta_click, calculator_open, calculator_export, faq_node_interaction, faq_copy_link) are owned by marketing and tracked in a separate Jira ticket — not part of this PR.

- Add shared helper src/scripts/pricing/calc-analytics.ts: per-calculator
  debounced calculator_interaction pusher (3s settle, one timer each) with
  flush() on modal close + pagehide/visibilitychange, immediate
  pushCalculatorEvent, delegated bindCtaTracking, and pushExport.
- Emit calculator_interaction with a calculator_type discriminator and
  calculator_total across all 5 calculators; newly instrument the 3 TBMQ
  calcs (payg, pc, perp), which previously had no analytics.
- Gate calculator_interaction behind a track flag so it fires only on real
  user input, not on init / open / reset.
- Add calculator_cta_click (footer CTA, all 5), calculator_open (2 modal
  calcs: tb_payg, tb_pc), and calculator_export (copy/download, all 5).
- FAQ: fire faq_node_interaction on deep-link open too, and on accordion
  opens only (not collapses); add faq_copy_link event
  (src/pages/pricing/index.astro).
- tb_pc: add calculator_profile_count/_truncated. Set enterprise
  calculator_plan to 'Enterprise' in both tb_payg and tb_pc.
- Drop always-null legacy fields from the TB calcs; suffix EU cloud plan
  gtmIds with _EU; add gtmIds to the tb-pc billing toggle.

@vvlladd28 vvlladd28 left a comment

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.

Review summary

Reviewed 8 changed files in feat(pricing): instrument pricing calculators and FAQ with analytics events. Left 6 comment(s) inline.

The instrumentation itself looks correct: I verified that calculator_total matches the displayed total in every calculator (including the annual * 0.9 discount in tb-pc/tbmq-pc), that no early-return path skips the total/plan capture, that the { track?: boolean } gating keeps init/reset/open from firing interaction events, that makeModalController supports the new onClose flush hook, and that the FAQ deep-link and accordion pushes don't double-fire. Most comments are about the shared helper drawing its boundary one notch too low, leaving the same per-calculator pattern duplicated five times. One behavioral question on the page-hide flush is worth a look before merge.


This review was auto-generated. Findings may contain errors — please verify before applying changes.

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.

Comment thread src/scripts/pricing/calc-analytics.ts Outdated

// 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() {

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.

The calculator_type discriminator ('tb_payg', etc.) ends up repeated 4–5 times inside each calc file — in the interaction payload, the calculator_open push, the bindCtaTracking call, and both pushExport calls — and it's typed as a bare string everywhere, so a typo in any one silently mis-buckets that event in GA4. Would it be cleaner to pass the type once when constructing the pusher (e.g. makeInteractionPusher('tb_payg'), merged into every push) and export a type CalculatorType = 'tb_payg' | 'tb_pc' | 'tbmq_payg' | 'tbmq_pc' | 'tbmq_perp' used across bindCtaTracking/pushExport? That declares the full set in one place and makes a mistyped value fail to compile.

Comment thread src/scripts/pricing/calc-analytics.ts Outdated
const push = (payload: Record<string, unknown>) => {
pending = payload;
if (timer) clearTimeout(timer);
timer = setTimeout(flush, 3000);

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.

The 3000ms settle is a bare literal here, and its rationale ("mirrors the ThingsBoard calculators' 3s settle") is copy-pasted as a comment into three of the tbmq calc files. Since this helper is meant to be the single home for the debounce, a named const SETTLE_MS = 3000 would let those per-file comments shrink to "debounced via makeInteractionPusher" instead of restating the 3s value in four spots that can drift apart.

Comment thread src/scripts/pricing/calc-tbmq-perp.ts Outdated
const calcAnalytics = makeInteractionPusher();
// Last computed one-time license price, captured for the footer CTA push.
let _lastTotal: number | null = null;
function sendGTM(total: number, track = true) {

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.

This sendGTM(total, track = true) diverges from the other four calculators: elsewhere sendGTM(total) is a pure pusher and _lastTotal / the track gate are handled in calc()/calculate(), but here sendGTM also captures _lastTotal as a side effect and owns the gate via a second parameter. Same logical operation, different signature and responsibility split — a maintainer touching the analytics has to relearn the pattern per file. Aligning perp with the others (capture _lastTotal in calc(), gate with opts?.track, keep sendGTM push-only) would make all five read identically.

Comment thread src/scripts/pricing/calc-tb-pc.ts Outdated
});
// Last settled total + plan, captured wherever sendPcGTM is called, so the
// footer CTA click reports the value showing at click time.
let _pcLastTotal: number | null = null;

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.

The "last settled total/plan" state is named differently in each calculator — _pcLastTotal/_pcLastPlan here (module-level), lastTotal/lastPlan in tb-payg, _lastTotal in the tbmq files (some underscore-prefixed, some not, some module-level vs function-local). Since it's the same concept implemented five times, settling on one name and scope would make the parallel structure obvious to the next editor.

Comment thread src/scripts/pricing/calc-tbmq-payg.ts Outdated
setTimeout(() => btn.classList.remove('copied'), 2000);
};
navigator.clipboard.writeText(text).then(flashCopied).catch(() => {});
pushExport('tbmq_payg', 'copy', { calculator_total: lastTotal });

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.

The copy/download handler pair — query [data-calc-copy]/[data-calc-download], build the summary, push the export event, flash "copied" / create-and-click a Blob anchor — is near-identical across all five calc files, differing only in filename and the extra fields. It's the largest copy-paste block the shared helper didn't fold in. A small bindExportButtons(root, calculatorType, { buildText, filename, getExtra }) in calc-analytics.ts would collapse five duplicated blocks into one and make the export event fire consistently (today each file independently decides whether to push before or after the clipboard write).

Addresses PR thingsboard#509 review comments (C2-C6):
- Add CalculatorType union; makeInteractionPusher(type) injects
  calculator_type once per calc (no more repeated bare-string literals —
  a typo now fails to compile). Type bindCtaTracking/pushExport/new
  pushCalculatorOpen against it.
- Add SETTLE_MS constant as the single home for the 3s debounce.
- Extract bindExportButtons() — collapses the 5 near-identical
  copy/download handler blocks into one shared helper.
- Align calc-tbmq-perp sendGTM to push-only (matches the other four).
- Unify last-settled-state naming to lastTotal/lastPlan across all 5.

Net -37 lines. ESLint clean. C1 (visibilitychange flush) kept by design —
it's the reliable mobile/bfcache flush signal; over-count is deduped
downstream in GA4/BigQuery.
@rusikv rusikv requested a review from vvlladd28 June 25, 2026 15:25
@vvlladd28 vvlladd28 merged commit 8763d51 into thingsboard:develop Jun 25, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants