Skip to content
Draft
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
97 changes: 77 additions & 20 deletions nuxt/components/HubSpotMeetings.vue
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
<script setup lang="ts">
// src/_includes/hubspot/hs-book-meeting.njk plus the non-form branch of
// hubspot/consent-fallback.njk: the HubSpot meetings scheduler, with the "choose a time"
// panel that stands in for it.
// panel that stands in for it. Defaults to the shared sales calendar
// (site.meetings.salesRoundRobin); dataSrc overrides it for a campaign-specific one.
//
// The embed is gated on analytics consent and MUST STAY THAT WAY. The .njk defined
// `window._ffLoadMeetings`, and nuxt/assets/js/cookieconsent-config.js calls it only once the
// visitor accepts; until then the embed is never injected. This registers the same global
// rather than loading on mount, so declining analytics still means no third-party
// scheduler script.
//
// The panel below is therefore not an error state: it is what a visitor who has not
// accepted analytics sees, and it gives them a way to book anyway. It is rendered
// unhidden and replaced once the embed is up, which is what the .njk did.
const props = defineProps<{ dataSrc: string }>()
// The embed is gated on analytics consent and MUST STAY THAT WAY: window._ffLoadMeetings is
// what nuxt/assets/js/cookieconsent-config.js calls once the visitor accepts, or on a later
// page load where consent is already stored - the latter is also checked directly on mount,
// since that call can otherwise race this component's own mount and get silently dropped.
import site from '../data/site.json'
import { parseMeetingMessage } from '../lib/hubspot-meeting-message.mjs'

const props = defineProps<{ position: string, dataSrc?: string }>()

const capture = useCapture()
const identify = useIdentify()
const embedded = ref(false)

type ConsentWindow = Window & {
const meetingsSrc = (() => {
const url = new URL(props.dataSrc ?? site.meetings.salesRoundRobin)
url.searchParams.set('embed', 'true')
return url.toString()
})()
const meetingsOrigin = new URL(meetingsSrc).origin

type EmbedWindow = Window & {
_ffLoadMeetings?: (() => void) | null
CookieConsent?: { showPreferences: () => void }
CookieConsent?: { showPreferences: () => void, acceptedCategory?: (category: string) => boolean }
hbspt?: { meetings?: { create: (selector: string) => unknown } }
}

function showCookiePreferences() {
;(window as ConsentWindow).CookieConsent?.showPreferences()
;(window as EmbedWindow).CookieConsent?.showPreferences()
}

function loadEmbed() {
Expand All @@ -34,25 +43,73 @@ function loadEmbed() {
script.onload = () => { embedded.value = true }
document.head.appendChild(script)
} else {
// Confirmed live (browser back/forward after an earlier visit): the script only
// scans for .meetings-iframe-container on its own load, so a later mount's fresh,
// empty container is otherwise never populated. This re-runs that scan for it.
;(window as EmbedWindow).hbspt?.meetings?.create('.meetings-iframe-container')
embedded.value = true
}
}

// Registered for cookieconsent-config.js to call on accept, exactly as the .njk did.
// It also calls it on load when consent is already stored, so an existing acceptance is
// covered without this component reading cookies itself.
// stepCount is a rough proxy for progress, not a real step index or click count - the
// embed's resize messages can fire more than once per click, or zero times per click (e.g.
// a window resize) - so it's a property on the outcome event, not an event of its own.
let booked = false
let stepCount = 0
let lastHeight: number | null = null
let reported = false

function handleMeetingMessage(event: MessageEvent) {
if (event.origin !== meetingsOrigin) return
const parsed = parseMeetingMessage(event.data)

if (parsed.type === 'booked') {
if (booked) return
booked = true
if (parsed.email) identify(parsed.email, { name: parsed.name ?? undefined })
capture('hubspot-meeting-booked', { position: props.position, step_count: stepCount })
return
}

if (parsed.type === 'resize' && parsed.height !== lastHeight) {
lastHeight = parsed.height
stepCount += 1
}
}

// step 1 is the embed's own first render (email-entry screen), automatic on load - not
// something the visitor did. > 1 means they got past it. pagehide covers a tab close or
// reload the same way body.html's $pageleave does; `reported` stops it firing twice if
// this also unmounts via in-site navigation.
function reportAbandonment() {
if (reported || booked || stepCount <= 1) return
reported = true
capture('hubspot-meeting-abandoned', { position: props.position, step_count: stepCount })
}

onMounted(() => {
;(window as ConsentWindow)._ffLoadMeetings = loadEmbed
const win = window as EmbedWindow
win._ffLoadMeetings = loadEmbed
if (win.CookieConsent?.acceptedCategory?.('analytics')) loadEmbed()

window.addEventListener('message', handleMeetingMessage)
window.addEventListener('pagehide', reportAbandonment)
})

onUnmounted(() => {
;(window as ConsentWindow)._ffLoadMeetings = null
;(window as EmbedWindow)._ffLoadMeetings = null
window.removeEventListener('message', handleMeetingMessage)
window.removeEventListener('pagehide', reportAbandonment)
reportAbandonment()
})
</script>

<template>
<div>
<div class="meetings-iframe-container -mb-20 md:-mb-6" :data-src="props.dataSrc" />
<!-- The negative margin only makes sense once the real iframe is loaded, to tuck away
HubSpot's own excess bottom whitespace - applied while empty, it pulls the fallback
panel up into this container's parent's overflow-hidden and clips its top edge. -->
<div class="meetings-iframe-container" :class="{ '-mb-20 md:-mb-6': embedded }" :data-src="meetingsSrc" />
<div v-if="!embedded" class="ff-hubspot-consent-fallback text-center border bg-indigo-900 rounded-lg px-6 pt-8 pb-4">
<h4 class="text-white font-medium">Choose a time to talk</h4>
<p class="text-indigo-200">30-minute session with our team.</p>
Expand Down
19 changes: 19 additions & 0 deletions nuxt/composables/useIdentify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
type PosthogWindow = Window & {
posthog?: {
identify: (distinctId: string, properties?: Record<string, unknown>) => 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<string, unknown>) {
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 })
}
}
3 changes: 2 additions & 1 deletion nuxt/lib/custom-cta-destinations.ts
Original file line number Diff line number Diff line change
@@ -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 -
Expand Down Expand Up @@ -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: {
Expand Down
21 changes: 21 additions & 0 deletions nuxt/lib/hubspot-meeting-message.mjs
Original file line number Diff line number Diff line change
@@ -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' }
}
52 changes: 52 additions & 0 deletions nuxt/lib/hubspot-meeting-message.test.mjs
Original file line number Diff line number Diff line change
@@ -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' })
})
2 changes: 1 addition & 1 deletion nuxt/pages/book-demo/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<HubSpotMeetings data-src="https://meetings-eu1.hubspot.com/michael-davis/round-robin-sales-team?embed=true" />
<HubSpotMeetings position="hero" />
</MqlContactPage>

<div class="container m-auto max-w-5xl px-6 pb-20">
Expand Down
4 changes: 3 additions & 1 deletion nuxt/pages/contact-us/index.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script setup lang="ts">
const resolveHref = useResolveHref()

const otherChannels = [
{
title: 'Want to talk it through live?',
description: 'Book a session and we\'ll go through your setup together.',
buttonText: 'Book a session',
buttonLink: 'https://meetings-eu1.hubspot.com/michael-davis/round-robin-sales-team',
buttonLink: resolveHref('site:meetings.salesRoundRobin'),
icon: 'calendar',
},
{
Expand Down
4 changes: 1 addition & 3 deletions nuxt/pages/landing/tulip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,6 @@ const STORIES_HEADING = {
urlText: 'See more customer stories',
}

const MEETINGS_SRC = 'https://meetings-eu1.hubspot.com/michael-davis/round-robin-michael-omar-kasheef?embed=true'

const { data: stories } = await useAsyncData('tulip-stories', () =>
queryCollection('stories').select('path', 'title', 'image', 'logo', 'story', 'date')
.order('date', 'DESC').limit(3).all()
Expand Down Expand Up @@ -224,7 +222,7 @@ useHead({ meta: [{ name: 'robots', content: 'noindex' }] })
<h2 class="mb-8 max-md:text-center">Ready to Make the Wiser Decision?</h2>
<p>See how FlowFuse can connect ALL your data sources to Tulip. Book your demo now.</p>
<div class="my-10">
<HubSpotMeetings :data-src="MEETINGS_SRC" />
<HubSpotMeetings position="form" />
</div>
</div>
</div>
Expand Down
Loading