From 82e6f2769fe076d6d71f6bbb4614af386003f98f Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:45:00 +0200 Subject: [PATCH] Add GDPR-compliant Hotjar tracking with consent banner --- app/app.vue | 4 + app/components/CookieConsentBanner.vue | 401 +++++++++++++++++++++++++ app/composables/useCookieConsent.ts | 79 +++++ plugins/hotjar.client.ts | 79 +++++ 4 files changed, 563 insertions(+) create mode 100644 app/app.vue create mode 100644 app/components/CookieConsentBanner.vue create mode 100644 app/composables/useCookieConsent.ts create mode 100644 plugins/hotjar.client.ts diff --git a/app/app.vue b/app/app.vue new file mode 100644 index 0000000..b825d35 --- /dev/null +++ b/app/app.vue @@ -0,0 +1,4 @@ + diff --git a/app/components/CookieConsentBanner.vue b/app/components/CookieConsentBanner.vue new file mode 100644 index 0000000..c53670f --- /dev/null +++ b/app/components/CookieConsentBanner.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/app/composables/useCookieConsent.ts b/app/composables/useCookieConsent.ts new file mode 100644 index 0000000..726652a --- /dev/null +++ b/app/composables/useCookieConsent.ts @@ -0,0 +1,79 @@ +import { computed, watch } from 'vue'; + +type CookieConsentPreferences = { + necessary: true; + analytics: boolean; +}; + +type CookieConsentValue = { + version: number; + updatedAt: string; + preferences: CookieConsentPreferences; +}; + +const CONSENT_COOKIE_NAME = 'osq-cookie-consent'; +const CONSENT_VERSION = 1; +const SIX_MONTHS_IN_SECONDS = 60 * 60 * 24 * 180; + +export const useCookieConsent = () => { + const consentCookie = useCookie(CONSENT_COOKIE_NAME, { + sameSite: 'lax', + path: '/', + maxAge: SIX_MONTHS_IN_SECONDS, + secure: !process.dev, + }); + + if (consentCookie.value && consentCookie.value.version !== CONSENT_VERSION) { + consentCookie.value = null; + } + + const consentState = useState('cookie-consent', () => consentCookie.value ?? null); + + watch( + consentState, + (value) => { + consentCookie.value = value; + }, + { deep: true } + ); + + const hasConsent = computed(() => consentState.value !== null); + + const analyticsEnabled = computed(() => { + if (!consentState.value) { + return false; + } + + return consentState.value.preferences.analytics === true; + }); + + const savePreferences = (preferences: { analytics: boolean }) => { + consentState.value = { + version: CONSENT_VERSION, + updatedAt: new Date().toISOString(), + preferences: { + necessary: true, + analytics: preferences.analytics, + }, + }; + }; + + const acceptAll = () => savePreferences({ analytics: true }); + + const rejectAll = () => savePreferences({ analytics: false }); + + const resetConsent = () => { + consentState.value = null; + consentCookie.value = null; + }; + + return { + consent: consentState, + hasConsent, + analyticsEnabled, + acceptAll, + rejectAll, + savePreferences, + resetConsent, + }; +}; diff --git a/plugins/hotjar.client.ts b/plugins/hotjar.client.ts new file mode 100644 index 0000000..28ba0b5 --- /dev/null +++ b/plugins/hotjar.client.ts @@ -0,0 +1,79 @@ +import { watch } from 'vue'; + +declare global { + interface Window { + hj?: ((...args: unknown[]) => void) & { q?: unknown[][] }; + _hjSettings?: { hjid: number; hjsv: number }; + _hjOptOut?: boolean; + } +} + +const HOTJAR_ID = 6522897; +const HOTJAR_VERSION = 6; +const HOTJAR_SCRIPT_ID = 'hotjar-tracking-script'; + +const injectHotjar = () => { + if (typeof window === 'undefined') { + return; + } + + if (window.hj && window._hjSettings?.hjid === HOTJAR_ID) { + return; + } + + window.hj = + window.hj || + function (...args: unknown[]) { + (window.hj!.q = window.hj!.q || []).push(args); + }; + + window._hjSettings = { hjid: HOTJAR_ID, hjsv: HOTJAR_VERSION }; + + if (document.getElementById(HOTJAR_SCRIPT_ID)) { + return; + } + + const head = document.head || document.getElementsByTagName('head')[0]; + if (!head) { + return; + } + + const script = document.createElement('script'); + script.id = HOTJAR_SCRIPT_ID; + script.async = true; + script.src = `https://static.hotjar.com/c/hotjar-${HOTJAR_ID}.js?sv=${HOTJAR_VERSION}`; + head.appendChild(script); +}; + +export default defineNuxtPlugin(() => { + if (!import.meta.client) { + return; + } + + const { analyticsEnabled } = useCookieConsent(); + const hasLoaded = useState('hotjar-loaded', () => false); + + const loadIfNecessary = () => { + if (!hasLoaded.value) { + injectHotjar(); + hasLoaded.value = true; + window._hjOptOut = false; + } + }; + + if (analyticsEnabled.value) { + loadIfNecessary(); + } + + watch( + analyticsEnabled, + (allowed) => { + if (allowed) { + loadIfNecessary(); + } else if (typeof window !== 'undefined') { + window._hjOptOut = true; + } + }, + { immediate: false } + ); +});