-
Notifications
You must be signed in to change notification settings - Fork 18
nuxt: Implement cookie banner and analytics tracking #5064
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
b5d913f
Clean up duplicated PostHog cleaning logic
Yndira-E 6b91c73
clear LinkedIn ad cookies across all domain variants on consent withd…
Yndira-E 2c4e5f0
Create single source of truth for analytics scripts across Eleventy a…
Yndira-E bfd003a
inline nuxt dev command into dev script
Yndira-E File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { readFileSync } from 'fs' | ||
| import { resolve } from 'path' | ||
|
|
||
| const headHtml = readFileSync(resolve(process.cwd(), '../src/_includes/analytics/head.html'), 'utf-8') | ||
| const bodyHtml = readFileSync(resolve(process.cwd(), '../src/_includes/analytics/body.html'), 'utf-8') | ||
| .replace('{{ POSTHOG_APIKEY }}', process.env.POSTHOG_APIKEY || '') | ||
|
|
||
| export default defineNitroPlugin((nitroApp) => { | ||
| if (process.env.NODE_ENV !== 'production') return | ||
|
|
||
| nitroApp.hooks.hook('render:html', (html) => { | ||
| // Guard against double injection from dev mode plugin reloads | ||
| if (html.bodyAppend.some(s => s.includes('cc.min.js'))) return | ||
| html.head.push(headHtml) | ||
| html.bodyAppend.push(bodyHtml) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| <!-- Scroll depth + pageleave tracking. | ||
| These are local calculations only — no data leaves the browser. | ||
| capture() is a no-op when PostHog is not loaded (no analytics consent). --> | ||
| <script> | ||
| const NO_SCROLL_HEIGHT = Math.min(1, (window.innerHeight + window.pageYOffset)/document.body.offsetHeight) | ||
| var MAX_CONTENT_VIEWED = Math.min(1, (window.innerHeight + window.pageYOffset)/document.body.offsetHeight) | ||
|
|
||
| addEventListener("scroll", (event) => { | ||
| const contentViewed = Math.min(1, (window.innerHeight + window.pageYOffset)/document.body.offsetHeight) | ||
| if (contentViewed > MAX_CONTENT_VIEWED) { | ||
| MAX_CONTENT_VIEWED = contentViewed | ||
| } | ||
| }); | ||
|
|
||
| function emitPageLeave () { | ||
| var viewed | ||
| var scrolled | ||
| if (NO_SCROLL_HEIGHT === 1) { | ||
| viewed = 1 | ||
| scrolled = 0 | ||
| } else { | ||
| viewed = MAX_CONTENT_VIEWED | ||
| scrolled = (MAX_CONTENT_VIEWED - NO_SCROLL_HEIGHT) / (1 - NO_SCROLL_HEIGHT) | ||
| } | ||
|
|
||
| capture('$pageleave', { | ||
| content_scrolled: scrolled, | ||
| content_viewed: viewed | ||
| }) | ||
| } | ||
|
|
||
| window.addEventListener && | ||
| window.addEventListener('onpagehide' in self ? 'pagehide' : 'unload', emitPageLeave) | ||
|
|
||
| function capture (eventName, props) { | ||
| if (window.posthog) { | ||
| posthog.capture(eventName, props); | ||
| } | ||
| } | ||
| </script> | ||
|
|
||
| <!-- PostHog — only loads when the user accepts analytics cookies. | ||
| No stub, no network calls, no cookies before consent. --> | ||
| <script type="text/plain" data-category="analytics"> | ||
| !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]); | ||
| posthog.init('{{ POSTHOG_APIKEY }}', { | ||
| api_host: 'https://eu.i.posthog.com', | ||
| person_profiles: 'always', | ||
| capture_pageleave: false, | ||
| disable_surveys: true | ||
| }); | ||
| posthog.register({ user_agent_string: window.navigator.userAgent }); | ||
| </script> | ||
|
|
||
| <!-- Revoke PostHog on rejection/withdrawal. --> | ||
| <script type="text/plain" data-category="!analytics"> | ||
| if (window.posthog) { | ||
| posthog.opt_out_capturing(); | ||
| posthog.reset(); | ||
| // Switch to memory mode to avoid any new persistence after withdrawal. | ||
| // Cookies are cleared by autoClear in cookieconsent-config.js. | ||
| posthog.set_config({ persistence: 'memory' }); | ||
|
|
||
| var posthogStorageKeyPattern = /^(ph_[^\s]+_posthog|__ph_opt_in_out_[^\s]+)$/; | ||
| try { | ||
| for (var i = localStorage.length - 1; i >= 0; i--) { | ||
| var localKey = localStorage.key(i); | ||
| if (localKey && posthogStorageKeyPattern.test(localKey)) { | ||
| localStorage.removeItem(localKey); | ||
| } | ||
| } | ||
| for (var j = sessionStorage.length - 1; j >= 0; j--) { | ||
| var sessionKey = sessionStorage.key(j); | ||
| if (sessionKey && posthogStorageKeyPattern.test(sessionKey)) { | ||
| sessionStorage.removeItem(sessionKey); | ||
| } | ||
| } | ||
| } catch (e) {} | ||
| } | ||
| </script> | ||
|
|
||
| <!-- HubSpot Tracking --> | ||
| <script> | ||
| window._ffLoadChat = false | ||
|
|
||
| if ( | ||
| window.sessionStorage?.getItem("chatInProgress") === null && | ||
| window.location.hash !== "#hs-chat-open" | ||
| ) { | ||
| window.hsConversationsSettings = { | ||
| loadImmediately: false, | ||
| } | ||
|
|
||
| window.addEventListener("load", (event) => { | ||
| let loaded = false | ||
| function loadHubSpotChat(event) { | ||
| if (loaded) return | ||
| loaded = true | ||
| window._ffLoadChat = true | ||
| setTimeout(() => { | ||
| window.HubSpotConversations?.widget?.load() | ||
| }, 1) | ||
| } | ||
|
|
||
| window.addEventListener("mousemove", loadHubSpotChat, { once: true }) | ||
| window.addEventListener("scroll", loadHubSpotChat, { once: true }) | ||
| }) | ||
| } | ||
|
|
||
| window.hsConversationsOnReady = [ | ||
| () => { | ||
| if (window._ffLoadChat) { | ||
| window.HubSpotConversations.widget.load() | ||
| } | ||
| window.HubSpotConversations.on("conversationStarted", () => { | ||
| window.sessionStorage?.setItem("chatInProgress", "true") | ||
| }) | ||
| }, | ||
| ] | ||
| </script> | ||
|
|
||
| <!-- Cookie banner (must load before any consent-gated scripts) --> | ||
| <script defer type="text/javascript" src="/js/cc.min.js"></script> | ||
|
|
||
| <!-- HS tracker - consent-gated (analytics: tracking + chat widget runtime) --> | ||
| <script type="text/plain" data-category="analytics" async id="hs-script-loader" src="//js-eu1.hs-scripts.com/26586079.js"></script> | ||
|
|
||
| <!-- HubSpot Consent Listener --> | ||
| <script type="text/javascript"> | ||
| function addHubSpotConsentListener() { | ||
| var _hsp = window._hsp = window._hsp || []; | ||
|
|
||
| _hsp.push(['addPrivacyConsentListener', function(consent) { | ||
| consent.categories.analytics = CookieConsent.acceptedCategory('analytics'); | ||
| consent.categories.advertisement = CookieConsent.acceptedCategory('ads'); | ||
| consent.categories.functionality = CookieConsent.acceptedCategory('functional'); | ||
| }]); | ||
| } | ||
|
|
||
| window.addEventListener('load', (event) => { | ||
| addHubSpotConsentListener(); | ||
| }); | ||
| </script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| <!-- Google Consent --> | ||
| <script> | ||
| window.dataLayer = window.dataLayer || []; | ||
| function gtag(){dataLayer.push(arguments);} | ||
|
|
||
| // Set consent defaults before GTM fires any tags. | ||
| // - If cc_cookie records a prior ACCEPTANCE: grant immediately so GTM's | ||
| // "All Pages" trigger fires GA4 on page load without relying on the | ||
| // broken consent-queue mechanism. | ||
| // - If cc_cookie records a prior REJECTION or no stored decision: deny globally. | ||
| (function() { | ||
| var deniedConsent = { | ||
| 'analytics_storage': 'denied', | ||
| 'ad_storage': 'denied', | ||
| 'ad_user_data': 'denied', | ||
| 'ad_personalization': 'denied', | ||
| 'functionality_storage': 'denied', | ||
| 'personalization_storage': 'denied', | ||
| 'security_storage': 'granted' | ||
| }; | ||
| try { | ||
| var ccMatch = document.cookie.match(/(?:^|;\s*)cc_cookie=([^;]+)/); | ||
| if (ccMatch) { | ||
| var stored = JSON.parse(decodeURIComponent(ccMatch[1])); | ||
| if (stored && Array.isArray(stored.categories)) { | ||
| var consentState = Object.assign({}, deniedConsent); | ||
| if (stored.categories.includes('analytics')) { | ||
| consentState['analytics_storage'] = 'granted'; | ||
| } | ||
| if (stored.categories.includes('ads')) { | ||
| consentState['ad_storage'] = 'granted'; | ||
| consentState['ad_user_data'] = 'granted'; | ||
| consentState['ad_personalization'] = 'granted'; | ||
| consentState['personalization_storage'] = 'granted'; | ||
| } | ||
| if (stored.categories.includes('functional')) { | ||
| consentState['functionality_storage'] = 'granted'; | ||
| } | ||
| gtag('consent', 'default', consentState); | ||
| return; | ||
| } | ||
| } | ||
| } catch(e) {} | ||
| gtag('consent', 'default', deniedConsent); | ||
| })(); | ||
| </script> | ||
| <!-- Google Tag Manager --> | ||
| <script>(function (w, d, s, l, i) { | ||
| w[l] = w[l] || []; w[l].push({ | ||
| 'gtm.start': | ||
| new Date().getTime(), event: 'gtm.js' | ||
| }); var f = d.getElementsByTagName(s)[0], | ||
| j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : ''; j.async = true; j.src = | ||
| 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f); | ||
| })(window, document, 'script', 'dataLayer', 'GTM-55L36797');</script> | ||
| <!-- End Google Tag Manager --> | ||
| <!-- HubSpot - block tracking by default until consent --> | ||
| <script> | ||
| var _hsq = window._hsq = window._hsq || []; | ||
| _hsq.push(['doNotTrack']); | ||
| </script> | ||
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Yndira-E I don't quite understand why we wouldnt depend on the SEO package from Nuxt?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main reason for this approach is to keep a single source of truth for consent and analytics (GTM, Consent Mode, PostHog and HubSpot) while Eleventy and Nuxt coexist.
Once the migration is complete and Eleventy is no longer part of the stack, we can revisit the implementation and evaluate whether Nuxt-native solutions make more sense.