diff --git a/.eleventy.js b/.eleventy.js
index 01105ef345..94f317a883 100644
--- a/.eleventy.js
+++ b/.eleventy.js
@@ -141,6 +141,7 @@ module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy("src/js/ai-expert-modal.js");
eleventyConfig.addPassthroughCopy("src/js/hm-promo-banner.js");
eleventyConfig.addPassthroughCopy("src/js/nav-tracking.js");
+ eleventyConfig.addPassthroughCopy("src/js/signup-popup.js");
// Watch content images for the image pipeline
eleventyConfig.addWatchTarget("src/**/*.{svg,webp,png,jpeg,gif}");
diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts
index 9ec6ed4072..bcc399b859 100644
--- a/nuxt/nuxt.config.ts
+++ b/nuxt/nuxt.config.ts
@@ -291,6 +291,9 @@ export default defineNuxtConfig({
// Explicit nav-click tracking. Source is src/js/nav-tracking.js;
// prod:eleventy-nuxt copies the 11ty output into nuxt/public/.
{ src: '/js/nav-tracking.js', defer: true },
+ // Opens sign-up in a small popup window on desktop/tablet.
+ // Source is src/js/signup-popup.js; copied the same way as nav-tracking.js.
+ { src: '/js/signup-popup.js', defer: true },
]
}
},
diff --git a/src/_includes/layouts/base.njk b/src/_includes/layouts/base.njk
index bf62556b3b..12a5426a8e 100644
--- a/src/_includes/layouts/base.njk
+++ b/src/_includes/layouts/base.njk
@@ -158,6 +158,8 @@ eleventyComputed:
+
+
{%- if not DEV_MODE -%}
{% include "analytics/head.html" %}
diff --git a/src/js/signup-popup.js b/src/js/signup-popup.js
new file mode 100644
index 0000000000..4e1d77959a
--- /dev/null
+++ b/src/js/signup-popup.js
@@ -0,0 +1,115 @@
+// Opens the sign-up page in a small popup window on desktop/tablet, centered
+// over the visible page content, with a dark overlay behind it.
+// On mobile, leaves the link's default navigation untouched.
+document.addEventListener('DOMContentLoaded', () => {
+ // Tracks the one popup/overlay pair that can be open at a time, so
+ // there's a single thing to clean up instead of separate mutable
+ // variables that a second click could overwrite and orphan.
+ let session = null
+
+ function closeSession () {
+ if (!session) {
+ return
+ }
+ if (!session.popup.closed) {
+ session.popup.close()
+ }
+ clearInterval(session.pollClosed)
+ session.overlay.remove()
+ session = null
+ }
+
+ function openPopup (href) {
+ if (session) {
+ if (!session.popup.closed) {
+ // Already have one open - bring it forward instead of
+ // opening a second popup and orphaning this one's overlay.
+ session.popup.focus()
+ return
+ }
+ closeSession()
+ }
+
+ const width = 420
+ const height = Math.min(900, window.innerHeight * 0.75)
+
+ // outerWidth/outerHeight include browser chrome (toolbars, a
+ // vertical tab strip, etc). Subtracting innerWidth/innerHeight
+ // estimates that chrome so we can center over the visible page
+ // content instead of the full browser window.
+ const chromeWidth = window.outerWidth - window.innerWidth
+ const chromeHeight = window.outerHeight - window.innerHeight
+ const viewportLeft = window.screenX + chromeWidth
+ const viewportTop = window.screenY + chromeHeight
+
+ const left = viewportLeft + (window.innerWidth - width) / 2
+ const top = viewportTop + (window.innerHeight - height) / 2
+
+ const popup = window.open(
+ href,
+ 'flowfuse-signup',
+ `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no`
+ )
+
+ if (!popup) {
+ return
+ }
+
+ const overlay = document.createElement('div')
+ overlay.setAttribute('id', 'signup-popup-overlay')
+ overlay.style.position = 'fixed'
+ overlay.style.inset = '0'
+ overlay.style.background = 'rgba(0, 0, 0, 0.45)'
+ overlay.style.zIndex = '9999'
+ overlay.addEventListener('click', closeSession)
+ document.body.appendChild(overlay)
+
+ const pollClosed = setInterval(() => {
+ if (popup.closed) {
+ closeSession()
+ }
+ }, 100)
+
+ session = { popup, overlay, pollClosed }
+ popup.focus()
+ }
+
+ // Delegated on `document` (rather than binding each matching link once)
+ // so this also catches anchors rendered after this script ran - e.g.
+ // Nuxt's client-side route navigation swapping in a new CtaSignUp link
+ // without a full page load.
+ document.addEventListener('click', (event) => {
+ const link = event.target.closest('a[href*="/account/create"]')
+ if (!link) {
+ return
+ }
+
+ const isDesktopOrTablet = window.matchMedia('(min-width: 768px)').matches
+ if (!isDesktopOrTablet) {
+ return
+ }
+
+ event.preventDefault()
+
+ // Explicit signal for the product to key its popup-specific
+ // layout off, instead of `window.opener` — that's also set for
+ // an ordinary ctrl/cmd-click "open in new tab", which isn't
+ // this popup at all.
+ const popupUrl = new URL(link.href)
+ popupUrl.searchParams.set('context', 'popup')
+
+ openPopup(popupUrl.href)
+ })
+
+ // Deliberate trade-off, not an oversight: closing on any window focus
+ // (e.g. switching tabs/apps and coming back) can cancel an in-progress,
+ // not-yet-submitted sign-up. That's judged preferable to the
+ // alternative - the popup falling behind this window with no auto-close,
+ // which leaves the overlay covering the page with no visible popup and
+ // no clear reason why, reading as the site being stuck rather than a
+ // sign-up that's still open one window over. Nothing of substance is
+ // lost by closing early here: this only ever covers the initial,
+ // unsubmitted form step. Added once (not per click) so listeners don't
+ // accumulate across retries.
+ window.addEventListener('focus', closeSession)
+})