-
Notifications
You must be signed in to change notification settings - Fork 26
feat(pricing): instrument pricing calculators and FAQ with analytics events #509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+376
−186
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', () => { | ||
| 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()); | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
visibilitychange→hiddenfires 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 secondcalculator_interactionfires for what's really one session, which partly undercuts the per-config dedup the debounce is meant to provide.pagehidealready 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 onpagehidebe closer to the intended "one configuration counts once"?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keeping the
visibilitychange → hiddenflush by design. It's the more reliable "last chance to send" signal — on mobile / bfcachepagehidefrequently doesn't fire (app backgrounding, OS tab discard), whereasvisibilitychange → hiddendoes. 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_interactioncarries 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.