From d41f74319e347c8ec06af7bdba83b22f92b90afd Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Fri, 14 Aug 2026 13:13:09 +0200 Subject: [PATCH 1/4] signup-popup-window --- .eleventy.js | 1 + nuxt/nuxt.config.ts | 3 ++ src/_data/site.json | 2 +- src/_includes/layouts/base.njk | 2 + src/js/signup-popup.js | 91 ++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/js/signup-popup.js 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..f5e8c3f2bd 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 }, + // Experimental: open 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/_data/site.json b/src/_data/site.json index 7dc5e71f85..f412959d25 100644 --- a/src/_data/site.json +++ b/src/_data/site.json @@ -1,6 +1,6 @@ { "baseURL": "https://flowfuse.com", - "appURL": "https://app.flowfuse.com", + "appURL": "http://localhost:3000", "jobBoard": "https://boards.greenhouse.io/flowfuse", "messaging": { "tagLine": "The Edge-Native Platform for Industrial Applications", diff --git a/src/_includes/layouts/base.njk b/src/_includes/layouts/base.njk index bf62556b3b..7bb626ce42 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..95669711a9 --- /dev/null +++ b/src/js/signup-popup.js @@ -0,0 +1,91 @@ +// 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', () => { + let overlay = null + + function showOverlay (popup) { + 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', () => { + if (!popup.closed) { + popup.close() + } + hideOverlay() + }) + document.body.appendChild(overlay) + } + + function hideOverlay () { + if (overlay) { + overlay.remove() + overlay = null + } + } + + document.querySelectorAll('a[href*="/account/create"]').forEach((link) => { + link.addEventListener('click', (event) => { + const isDesktopOrTablet = window.matchMedia('(min-width: 768px)').matches + if (!isDesktopOrTablet) { + return + } + + event.preventDefault() + + 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( + link.href, + 'flowfuse-signup', + `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no` + ) + + if (!popup) { + return + } + + showOverlay(popup) + popup.focus() + + const pollClosed = setInterval(() => { + if (popup.closed) { + clearInterval(pollClosed) + hideOverlay() + } + }, 100) + + // On macOS (and similar window managers), the first click on an + // unfocused window only refocuses it — it doesn't reach the + // overlay's own click handler. That refocus fires this 'focus' + // event immediately, so treat it as the "click outside" signal: + // close the popup and drop the overlay right away, in one click. + window.addEventListener('focus', () => { + if (!overlay) { + return + } + if (!popup.closed) { + popup.close() + } + clearInterval(pollClosed) + hideOverlay() + }) + }) + }) +}) From a3cce90681dc85e5d695a8433f6e3dc4d42c9d55 Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Tue, 18 Aug 2026 11:38:11 +0200 Subject: [PATCH 2/4] Key popup layout off a query param instead of window.opener --- nuxt/nuxt.config.ts | 2 +- src/_includes/layouts/base.njk | 2 +- src/js/signup-popup.js | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index f5e8c3f2bd..bcc399b859 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -291,7 +291,7 @@ 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 }, - // Experimental: open sign-up in a small popup window on desktop/tablet. + // 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 7bb626ce42..12a5426a8e 100644 --- a/src/_includes/layouts/base.njk +++ b/src/_includes/layouts/base.njk @@ -158,7 +158,7 @@ eleventyComputed: - + {%- if not DEV_MODE -%} diff --git a/src/js/signup-popup.js b/src/js/signup-popup.js index 95669711a9..c00582edc6 100644 --- a/src/js/signup-popup.js +++ b/src/js/signup-popup.js @@ -36,6 +36,13 @@ document.addEventListener('DOMContentLoaded', () => { 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') + const width = 420 const height = Math.min(900, window.innerHeight * 0.75) @@ -52,7 +59,7 @@ document.addEventListener('DOMContentLoaded', () => { const top = viewportTop + (window.innerHeight - height) / 2 const popup = window.open( - link.href, + popupUrl.href, 'flowfuse-signup', `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no` ) From cd07d723999c9a80be73d385f3f934a9b0d9916d Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Tue, 25 Aug 2026 18:36:37 +0200 Subject: [PATCH 3/4] Point sign-up popup at production instead of localhost --- src/_data/site.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_data/site.json b/src/_data/site.json index f412959d25..7dc5e71f85 100644 --- a/src/_data/site.json +++ b/src/_data/site.json @@ -1,6 +1,6 @@ { "baseURL": "https://flowfuse.com", - "appURL": "http://localhost:3000", + "appURL": "https://app.flowfuse.com", "jobBoard": "https://boards.greenhouse.io/flowfuse", "messaging": { "tagLine": "The Edge-Native Platform for Industrial Applications", From 24a6c0dccd1fd855617d9dacb6f82bcdbb844f94 Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Tue, 25 Aug 2026 19:33:51 +0200 Subject: [PATCH 4/4] Fix overlay leak, listener leak, and missing SPA-rendered links in signup popup --- src/js/signup-popup.js | 175 ++++++++++++++++++++++------------------- 1 file changed, 96 insertions(+), 79 deletions(-) diff --git a/src/js/signup-popup.js b/src/js/signup-popup.js index c00582edc6..4e1d77959a 100644 --- a/src/js/signup-popup.js +++ b/src/js/signup-popup.js @@ -2,97 +2,114 @@ // over the visible page content, with a dark overlay behind it. // On mobile, leaves the link's default navigation untouched. document.addEventListener('DOMContentLoaded', () => { - let overlay = null + // 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 showOverlay (popup) { - overlay = document.createElement('div') + 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', () => { - if (!popup.closed) { - popup.close() - } - hideOverlay() - }) + overlay.addEventListener('click', closeSession) document.body.appendChild(overlay) + + const pollClosed = setInterval(() => { + if (popup.closed) { + closeSession() + } + }, 100) + + session = { popup, overlay, pollClosed } + popup.focus() } - function hideOverlay () { - if (overlay) { - overlay.remove() - overlay = null + // 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 } - } - document.querySelectorAll('a[href*="/account/create"]').forEach((link) => { - link.addEventListener('click', (event) => { - const isDesktopOrTablet = window.matchMedia('(min-width: 768px)').matches - if (!isDesktopOrTablet) { - 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') - - 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( - popupUrl.href, - 'flowfuse-signup', - `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no` - ) - - if (!popup) { - 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') - showOverlay(popup) - popup.focus() - - const pollClosed = setInterval(() => { - if (popup.closed) { - clearInterval(pollClosed) - hideOverlay() - } - }, 100) - - // On macOS (and similar window managers), the first click on an - // unfocused window only refocuses it — it doesn't reach the - // overlay's own click handler. That refocus fires this 'focus' - // event immediately, so treat it as the "click outside" signal: - // close the popup and drop the overlay right away, in one click. - window.addEventListener('focus', () => { - if (!overlay) { - return - } - if (!popup.closed) { - popup.close() - } - clearInterval(pollClosed) - hideOverlay() - }) - }) + 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) })