feat(pricing): instrument pricing calculators and FAQ with analytics events#509
Conversation
- 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
left a comment
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
visibilitychange → hidden 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"?
There was a problem hiding this comment.
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.
|
|
||
| // 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() { |
There was a problem hiding this comment.
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.
| const push = (payload: Record<string, unknown>) => { | ||
| pending = payload; | ||
| if (timer) clearTimeout(timer); | ||
| timer = setTimeout(flush, 3000); |
There was a problem hiding this comment.
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.
| 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) { |
There was a problem hiding this comment.
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.
| }); | ||
| // 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; |
There was a problem hiding this comment.
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.
| setTimeout(() => btn.classList.remove('copied'), 2000); | ||
| }; | ||
| navigator.clipboard.writeText(text).then(flashCopied).catch(() => {}); | ||
| pushExport('tbmq_payg', 'copy', { calculator_total: lastTotal }); |
There was a problem hiding this comment.
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.
Summary
Adds consistent, deduplicated analytics across the pricing page: all 5 calculators now emit a
calculator_interactionevent with acalculator_typediscriminator andcalculator_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-debouncedcalculator_interactionpusher per calculator (only the last settled config fires), withflush()to send immediately.pagehide/visibilitychange(hidden) listeners drain all pending pushes before unload.pushCalculatorEvent()for immediate events,bindCtaTracking()for delegated.calc-ctaclicks, andpushExport()for copy/download.Calculator instrumentation (all 5 calcs)
calculator_interactionwithcalculator_type(tb_payg,tb_pc,tbmq_payg,tbmq_pc,tbmq_perp) andcalculator_total.calc-tbmq-payg,calc-tbmq-pc,calc-tbmq-perp) are newly instrumented.trackflag 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_interactionnow also fires when a question is opened via deep link, and on accordion opens only (not collapses).faq_copy_linkevent on copy-link clicks.Fixes & cleanup
tb_pc: addedcalculator_profile_count/calculator_profile_truncated.calculator_planset to'Enterprise'in bothtb_paygandtb_pc(previously the plan name / empty string).calculator_messages,calculator_messages_unit,calculator_instances_monthly;tb_paygalso dropped its always-nullcalculator_extra_storage_costandcalculator_profile_*_jsonblock —tb_pckeeps both since they carry real values there).gtmIds suffixed with_EU(src/data/pricing/tb-cloud.ts, 5 plans); addedgtmIds to the tb-pc billing toggle (src/pages/pricing/index.astro).Testing
pnpm build:fastpassed.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.