Choose a time to talk
30-minute session with our team.
diff --git a/nuxt/composables/useCapture.ts b/nuxt/composables/useCapture.ts
index 70f4d4139e..155dab5ac3 100644
--- a/nuxt/composables/useCapture.ts
+++ b/nuxt/composables/useCapture.ts
@@ -3,9 +3,11 @@
// PostHog isn't loaded (no analytics consent). Guarded here too so components
// can call this during SSR/tests without a `window`.
export function useCapture () {
- return function capture (event: string, props?: Record
) {
+ return function capture (event: string, props?: Record, options?: Record) {
if (typeof window === 'undefined') return
- const win = window as unknown as { capture?: (event: string, props?: Record) => void }
- if (typeof win.capture === 'function') win.capture(event, props)
+ const win = window as unknown as {
+ capture?: (event: string, props?: Record, options?: Record) => void
+ }
+ if (typeof win.capture === 'function') win.capture(event, props, options)
}
}
diff --git a/nuxt/composables/useIdentify.ts b/nuxt/composables/useIdentify.ts
new file mode 100644
index 0000000000..3012390006
--- /dev/null
+++ b/nuxt/composables/useIdentify.ts
@@ -0,0 +1,19 @@
+type PosthogWindow = Window & {
+ posthog?: {
+ identify: (distinctId: string, properties?: Record) => void
+ get_property: (key: string) => unknown
+ }
+}
+
+// Skips re-identifying an already-identified session, so a second booking in the same
+// browser doesn't silently re-point its whole history at a different identity.
+export function useIdentify () {
+ return function identify (email: string, properties?: Record) {
+ if (typeof window === 'undefined') return
+ const posthog = (window as PosthogWindow).posthog
+ if (!posthog || posthog.get_property('$user_state') === 'identified') return
+
+ const normalizedEmail = email.trim().toLowerCase()
+ posthog.identify(normalizedEmail, { email: normalizedEmail, ...properties })
+ }
+}
diff --git a/nuxt/lib/custom-cta-destinations.ts b/nuxt/lib/custom-cta-destinations.ts
index 5c01bfabcc..dbaf872f40 100644
--- a/nuxt/lib/custom-cta-destinations.ts
+++ b/nuxt/lib/custom-cta-destinations.ts
@@ -1,4 +1,5 @@
import { CTA_DESTINATIONS, normalizeHref } from './cta-destinations'
+import site from '../data/site.json'
// Registry for one-off CTA destinations that aren't one of the five reserved
// ones (see cta-destinations.ts) but still deserve one fixed PostHog event -
@@ -33,7 +34,7 @@ import { CTA_DESTINATIONS, normalizeHref } from './cta-destinations'
// `position`), not an attempt at an identical payload shape.
export const CUSTOM_CTA_DESTINATIONS = {
hubspotMeeting: {
- href: 'https://meetings-eu1.hubspot.com/michael-davis/round-robin-sales-team',
+ href: site.meetings.salesRoundRobin,
event: 'calendar_fallback_cta_clicked',
},
communityForum: {
diff --git a/nuxt/lib/hubspot-meeting-message.mjs b/nuxt/lib/hubspot-meeting-message.mjs
new file mode 100644
index 0000000000..021468c4a0
--- /dev/null
+++ b/nuxt/lib/hubspot-meeting-message.mjs
@@ -0,0 +1,21 @@
+// Parses HubSpot's undocumented meetings-embed postMessage shapes, kept pure and testable
+// so a format change breaks a test here instead of going silently quiet in the component.
+
+/**
+ * @param {unknown} data
+ * @returns {{ type: 'booked', email: string | null, name: string | null } | { type: 'resize', height: number } | { type: 'unknown' }}
+ */
+export function parseMeetingMessage (data) {
+ if (!data || typeof data !== 'object') return { type: 'unknown' }
+
+ if ('meetingBookSucceeded' in data && data.meetingBookSucceeded) {
+ const contact = data.meetingsPayload?.bookingResponse?.postResponse?.contact
+ return { type: 'booked', email: contact?.email ?? null, name: contact?.name ?? null }
+ }
+
+ if ('height' in data && typeof data.height === 'number') {
+ return { type: 'resize', height: data.height }
+ }
+
+ return { type: 'unknown' }
+}
diff --git a/nuxt/lib/hubspot-meeting-message.test.mjs b/nuxt/lib/hubspot-meeting-message.test.mjs
new file mode 100644
index 0000000000..a5ff2eddee
--- /dev/null
+++ b/nuxt/lib/hubspot-meeting-message.test.mjs
@@ -0,0 +1,52 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import { parseMeetingMessage } from './hubspot-meeting-message.mjs'
+
+test('a booking-succeeded message returns the contact email and name', () => {
+ // Shape captured from a real test booking on meetings-eu1.hubspot.com - not documented
+ // by HubSpot anywhere, so this fixture is the actual contract, not a guess. Values
+ // replaced with fake ones; only the shape matters for the test.
+ const data = {
+ meetingBookSucceeded: true,
+ meetingsPayload: {
+ bookingResponse: {
+ postResponse: {
+ bookedOffline: false,
+ contact: { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', fullName: '', name: 'Jane Doe' },
+ organizer: { firstName: 'Sam', lastName: 'Rep', email: '', fullName: '', name: 'Sam Rep' },
+ },
+ },
+ formGuid: '00000000-0000-0000-0000-000000000000',
+ linkType: 'ROUND_ROBIN_CALENDAR',
+ userSlug: 'sam-rep/round-robin-sales-team',
+ },
+ }
+
+ assert.deepEqual(parseMeetingMessage(data), { type: 'booked', email: 'jane@example.com', name: 'Jane Doe' })
+})
+
+test('a booking-succeeded message with no contact still reports booked, with nulls', () => {
+ assert.deepEqual(
+ parseMeetingMessage({ meetingBookSucceeded: true }),
+ { type: 'booked', email: null, name: null }
+ )
+})
+
+test('meetingBookSucceeded: false is not a booking', () => {
+ assert.deepEqual(parseMeetingMessage({ meetingBookSucceeded: false }), { type: 'unknown' })
+})
+
+test('a resize message returns its height', () => {
+ assert.deepEqual(parseMeetingMessage({ height: 640 }), { type: 'resize', height: 640 })
+})
+
+test('a non-numeric height is not a resize message', () => {
+ assert.deepEqual(parseMeetingMessage({ height: '640' }), { type: 'unknown' })
+})
+
+test('an unrelated or empty message is unknown', () => {
+ assert.deepEqual(parseMeetingMessage({ someOtherKey: true }), { type: 'unknown' })
+ assert.deepEqual(parseMeetingMessage(null), { type: 'unknown' })
+ assert.deepEqual(parseMeetingMessage('a string'), { type: 'unknown' })
+})
diff --git a/nuxt/lib/hubspot-meeting-tracker.mjs b/nuxt/lib/hubspot-meeting-tracker.mjs
new file mode 100644
index 0000000000..b86937b7f0
--- /dev/null
+++ b/nuxt/lib/hubspot-meeting-tracker.mjs
@@ -0,0 +1,47 @@
+// Pure state machine for one HubSpotMeetings embed's lifecycle - no DOM/window access, so
+// it's testable without a browser. HubSpotMeetings.vue wires DOM/postMessage events to
+// these methods and only calls capture() when a method returns true.
+
+export function createMeetingTracker () {
+ let booked = false
+ let reported = false
+ let stepCount = 0
+ let lastHeight = null
+
+ return {
+ /** A resize message's height. Returns true for a new value, false for a repeat. */
+ recordResize (height) {
+ if (height === lastHeight) return false
+ lastHeight = height
+ stepCount += 1
+ return true
+ },
+ /** Returns true the first time a booking succeeds, false for a resent message. */
+ recordBooked () {
+ if (booked) return false
+ booked = true
+ return true
+ },
+ /**
+ * Returns true if an abandonment should be reported: not already reported, not
+ * booked, and past the embed's own first render (step 1 is automatic on load, not
+ * something the visitor did).
+ */
+ recordAbandonment () {
+ if (reported || booked || stepCount <= 1) return false
+ reported = true
+ return true
+ },
+ /**
+ * A bfcache restore resumes this exact state rather than remounting, so a
+ * departure that already reported stays reportable if the visitor leaves again
+ * without booking. This does not un-send the first abandoned event - a session can
+ * still end up with both abandoned and booked; treat that as "abandoned without a
+ * later booked", not as a bug to suppress here.
+ */
+ resumeFromBfcache () {
+ reported = false
+ },
+ get stepCount () { return stepCount },
+ }
+}
diff --git a/nuxt/lib/hubspot-meeting-tracker.test.mjs b/nuxt/lib/hubspot-meeting-tracker.test.mjs
new file mode 100644
index 0000000000..6303dbc5a1
--- /dev/null
+++ b/nuxt/lib/hubspot-meeting-tracker.test.mjs
@@ -0,0 +1,66 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import { createMeetingTracker } from './hubspot-meeting-tracker.mjs'
+
+test('a repeat height does not count as progress', () => {
+ const tracker = createMeetingTracker()
+ assert.equal(tracker.recordResize(640), true)
+ assert.equal(tracker.recordResize(640), false)
+ assert.equal(tracker.stepCount, 1)
+})
+
+test('recordBooked is true once, false for a resent message', () => {
+ const tracker = createMeetingTracker()
+ assert.equal(tracker.recordBooked(), true)
+ assert.equal(tracker.recordBooked(), false)
+})
+
+test('abandonment is not reported at step 1 (the embed\'s own first render)', () => {
+ const tracker = createMeetingTracker()
+ tracker.recordResize(528)
+ assert.equal(tracker.recordAbandonment(), false)
+})
+
+test('abandonment is reported past step 1', () => {
+ const tracker = createMeetingTracker()
+ tracker.recordResize(528)
+ tracker.recordResize(640)
+ assert.equal(tracker.recordAbandonment(), true)
+})
+
+test('abandonment is not reported twice', () => {
+ const tracker = createMeetingTracker()
+ tracker.recordResize(528)
+ tracker.recordResize(640)
+ assert.equal(tracker.recordAbandonment(), true)
+ assert.equal(tracker.recordAbandonment(), false)
+})
+
+test('abandonment is not reported once booked', () => {
+ const tracker = createMeetingTracker()
+ tracker.recordResize(528)
+ tracker.recordResize(640)
+ tracker.recordBooked()
+ assert.equal(tracker.recordAbandonment(), false)
+})
+
+test('resumeFromBfcache allows a second abandonment after a second departure', () => {
+ const tracker = createMeetingTracker()
+ tracker.recordResize(528)
+ tracker.recordResize(640)
+ assert.equal(tracker.recordAbandonment(), true)
+ tracker.resumeFromBfcache()
+ tracker.recordResize(690)
+ assert.equal(tracker.recordAbandonment(), true)
+})
+
+test('resumeFromBfcache does not re-arm abandonment once booked', () => {
+ const tracker = createMeetingTracker()
+ tracker.recordResize(528)
+ tracker.recordResize(640)
+ tracker.recordAbandonment()
+ tracker.recordBooked()
+ tracker.resumeFromBfcache()
+ assert.equal(tracker.recordAbandonment(), false)
+})
diff --git a/nuxt/pages/book-demo/index.vue b/nuxt/pages/book-demo/index.vue
index 6420417f33..2ffe003b63 100644
--- a/nuxt/pages/book-demo/index.vue
+++ b/nuxt/pages/book-demo/index.vue
@@ -21,7 +21,7 @@ useSeoMeta({
description="See how you'd build, deploy, and govern operational applications across your own plants and production lines."
:highlights="highlights"
>
-
+
diff --git a/nuxt/pages/contact-us/index.vue b/nuxt/pages/contact-us/index.vue
index a38b139d64..69f6a783ae 100644
--- a/nuxt/pages/contact-us/index.vue
+++ b/nuxt/pages/contact-us/index.vue
@@ -1,10 +1,12 @@