Skip to content
Closed
3 changes: 0 additions & 3 deletions nuxt/assets/js/cookieconsent-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,6 @@ function applyAnalyticsConsent (options) {
syncHubSpotConsent(accepted);

if (accepted) {
if (typeof window._ffLoadMeetings === 'function') {
window._ffLoadMeetings();
}
window._ffLoadChat = true;
if (window.HubSpotConversations) {
window.HubSpotConversations.widget.load();
Expand Down
110 changes: 79 additions & 31 deletions nuxt/components/HubSpotMeetings.vue
Original file line number Diff line number Diff line change
@@ -1,58 +1,106 @@
<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.
// The HubSpot meetings scheduler, with a "choose a time" panel that stands in for it when
// analytics consent hasn't been given yet. Always the shared sales calendar
// (site.meetings.salesRoundRobin).
//
// 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.
// Consent-gated and MUST STAY THAT WAY: listens for vanilla-cookieconsent's own
// cc:onConsent/cc:onChange DOM events, plus a direct check on mount for consent already
// stored from an earlier visit, since that event can otherwise fire before this mounts.
// Withdrawing consent removes the embed again.
//
// 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 }>()
// HubSpot's script only scans for containers once, when it loads, so each mount asks it for
// an iframe in this component's own container. The fallback stays up until that iframe is
// actually in the container, which HubSpot inserts some time after create() returns.
import site from '../data/site.json'

const SCRIPT_SRC = 'https://static.hsappstatic.net/MeetingsEmbed/ex/MeetingsEmbedCode.js'

const embedded = ref(false)
const container = ref<HTMLElement>()
const containerId = `hs-meetings-${useId()}`

const meetingsSrc = (() => {
const url = new URL(site.meetings.salesRoundRobin)
url.searchParams.set('embed', 'true')
return url.toString()
})()

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

let observer: MutationObserver | undefined
let pendingScript: HTMLScriptElement | undefined

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

function stopWaitingForScript() {
pendingScript?.removeEventListener('load', createEmbed)
pendingScript = undefined
}

function createEmbed() {
stopWaitingForScript()
const create = (window as EmbedWindow).hbspt?.meetings?.create
if (create && container.value) create(`#${CSS.escape(containerId)}`)
}

function loadEmbed() {
if (embedded.value) return
const existing = document.querySelector('script[src*="MeetingsEmbedCode.js"]')
if (!existing) {
const script = document.createElement('script')
script.src = 'https://static.hsappstatic.net/MeetingsEmbed/ex/MeetingsEmbedCode.js'
script.onload = () => { embedded.value = true }
document.head.appendChild(script)
} else {
embedded.value = true
if (!container.value || container.value.querySelector('iframe')) return
if ((window as EmbedWindow).hbspt?.meetings?.create) {
createEmbed()
return
}
let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC}"]`)
if (!script) {
const injected = document.createElement('script')
injected.src = SCRIPT_SRC
// Dropped on failure so the next mount tries again.
injected.onerror = () => injected.remove()
document.head.appendChild(injected)
script = injected
}
if (pendingScript === script) return
stopWaitingForScript()
pendingScript = script
script.addEventListener('load', createEmbed, { once: true })
}

function removeEmbed() {
stopWaitingForScript()
container.value?.replaceChildren()
}

function syncWithConsent() {
if ((window as EmbedWindow).CookieConsent?.acceptedCategory?.('analytics')) loadEmbed()
else removeEmbed()
}

// 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.
onMounted(() => {
;(window as ConsentWindow)._ffLoadMeetings = loadEmbed
observer = new MutationObserver(() => {
embedded.value = !!container.value?.querySelector('iframe')
})
if (container.value) observer.observe(container.value, { childList: true })
syncWithConsent()
window.addEventListener('cc:onConsent', syncWithConsent)
window.addEventListener('cc:onChange', syncWithConsent)
})

onUnmounted(() => {
;(window as ConsentWindow)._ffLoadMeetings = null
observer?.disconnect()
stopWaitingForScript()
window.removeEventListener('cc:onConsent', syncWithConsent)
window.removeEventListener('cc:onChange', syncWithConsent)
})
</script>

<template>
<div>
<div class="meetings-iframe-container -mb-20 md:-mb-6" :data-src="props.dataSrc" />
<!-- Negative margin only once the iframe is up: on the empty container it clips the fallback. -->
<div :id="containerId" ref="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
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,4 @@ This isn't just theoretical: [Energinet, Denmark's national transmission system

Virtual Power Plants are the future of the energy grid, but building them without the right tools is slow, brittle, and expensive. Low-code platforms like **FlowFuse** change that. They offer a practical, scalable, and secure way to integrate DERs, manage fleet operations, and coordinate distributed energy like never before.

Ready to revolutionize your energy infrastructure? Start building your VPP with [FlowFuse's free trial](https://app.flowfuse.com/account/create) or [Book a Demo](https://meetings-eu1.hubspot.com/michael-davis/round-robin-michael-omar-kasheef?utm_campa%5B%E2%80%A6%5D113138546=&utm_content=113138546&utm_source=hs_automation&uuid=67e4a958-c21e-4463-8eb4-647cc2386930) to see how leading energy companies are orchestrating thousands of DERs with low-code automation.
Ready to revolutionize your energy infrastructure? Start building your VPP with [FlowFuse's free trial](https://app.flowfuse.com/account/create) or [Book a Demo](/book-demo/) to see how leading energy companies are orchestrating thousands of DERs with low-code automation.
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, ctaDestinationKey, 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, ctaDestinationKey, normalizeHref } from './cta-destin
// `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
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 />
</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">
import site from '../../data/site.json'

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: 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 />
</div>
</div>
</div>
Expand Down
Loading