Skip to content
Merged
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
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
118 changes: 85 additions & 33 deletions nuxt/components/HubSpotMeetings.vue
Original file line number Diff line number Diff line change
@@ -1,58 +1,110 @@
<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 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 }>()
// HubSpot meetings scheduler, loaded only with analytics consent. MUST STAY THAT WAY.
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 containerKey = ref(0)
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
let requested = false

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) return
requested = true
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
injected.onerror = () => injected.remove()
document.head.appendChild(injected)
script = injected
}
if (pendingScript === script) return
stopWaitingForScript()
pendingScript = script
script.addEventListener('load', createEmbed, { once: 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.
// A new container makes an iframe HubSpot inserts late land in the detached old one.
function removeEmbed() {
stopWaitingForScript()
if (!requested) return
requested = false
embedded.value = false
containerKey.value++
}

function hasAnalyticsConsent() {
return !!(window as EmbedWindow).CookieConsent?.acceptedCategory?.('analytics')
}

function syncWithConsent() {
if (!hasAnalyticsConsent()) removeEmbed()
else nextTick(() => { if (hasAnalyticsConsent()) loadEmbed() })
}

watch(container, (el) => {
observer?.disconnect()
if (el) observer?.observe(el, { childList: true })
}, { flush: 'post' })

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" />
<div :id="containerId" :key="containerKey" 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