diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx
index f69869550..a20d0f4b8 100644
--- a/src/components/Footer.jsx
+++ b/src/components/Footer.jsx
@@ -6,6 +6,7 @@ import { Transition } from '@headlessui/react'
import { Button } from '@/components/Button'
import {apiNavigation, flattenNavItems} from '@/components/NavigationAPI'
import {docsNavigation} from "@/components/NavigationDocs";
+import {useCookieConsent} from '@/components/cookie-consent/CookieConsentProvider'
function CheckIcon(props) {
return (
@@ -227,11 +228,20 @@ function SocialLink({ href, icon: Icon, children }) {
}
function SmallPrint() {
+ const { openCookieSettings } = useCookieConsent()
+
return (
-
- © Copyright {new Date().getFullYear()}. All rights reserved.
-
+
+
© Copyright {new Date().getFullYear()}. All rights reserved.
+
+
Follow us on X
diff --git a/src/components/GoogleTagManager.jsx b/src/components/GoogleTagManager.jsx
index 3d32abe79..f41f028b7 100644
--- a/src/components/GoogleTagManager.jsx
+++ b/src/components/GoogleTagManager.jsx
@@ -4,10 +4,14 @@ import Script from "next/script";
// Google Tag Manager ID
const GTM_ID = "GTM-PGWDPDN3";
-export const GoogleTagManagerHeadScript = () => {
+export const GoogleTagManager = ({ consentGiven }) => {
+ if (!consentGiven) {
+ return null;
+ }
+
return (
);
};
-
-export const GoogleTageManagerBodyScript = () => {
- return (
-
- );
-};
diff --git a/src/components/Matomo.jsx b/src/components/Matomo.jsx
index d7ba56a99..c8d3f428d 100644
--- a/src/components/Matomo.jsx
+++ b/src/components/Matomo.jsx
@@ -1,16 +1,50 @@
import Script from "next/script";
+import { useEffect, useRef } from 'react'
+import { useRouter } from 'next/router'
export function MatomoTagManager({ consentGiven }) {
+ const router = useRouter()
+ const isFirstNavigation = useRef(true)
+
+ useEffect(() => {
+ if (!consentGiven) return
+
+ // The container tracks the initial hard load. Subsequent Next.js routes
+ // are emitted after the new document title has committed so Matomo does
+ // not associate the previous page's title with the new URL.
+ if (isFirstNavigation.current) {
+ isFirstNavigation.current = false
+ return
+ }
+
+ const id = window.setTimeout(() => {
+ window._mtm = window._mtm || []
+ window._mtm.push({
+ event: 'mtm.SpaPageView',
+ 'mtm.pageUrl': window.location.href,
+ 'mtm.pageTitle': document.title,
+ })
+ }, 0)
+
+ return () => window.clearTimeout(id)
+ }, [consentGiven, router.asPath])
+
+ if (!consentGiven) {
+ return null
+ }
+
return (
);
diff --git a/src/components/cookie-consent/CookieConsentProvider.jsx b/src/components/cookie-consent/CookieConsentProvider.jsx
index 828f45d76..a7f27cf9c 100644
--- a/src/components/cookie-consent/CookieConsentProvider.jsx
+++ b/src/components/cookie-consent/CookieConsentProvider.jsx
@@ -2,14 +2,28 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { useRouter } from 'next/router'
const STORAGE_KEY = 'cookie-consent'
-const ACCEPT_EXPIRY_DAYS = 90
-const DECLINE_EXPIRY_DAYS = 1
+const CONSENT_EXPIRY_DAYS = 180
+
+const TRACKING_COOKIE_PREFIXES = [
+ '_ga',
+ '_gid',
+ '_hj',
+ '_clck',
+ '_clsk',
+ '_gcl_',
+ '_pk_',
+ '__hst',
+ 'hubspotutk',
+ 'messagesUtk',
+]
const CookieConsentContext = createContext({
isAccepted: false,
+ isDeclined: false,
showConsent: false,
acceptCookies: () => {},
declineCookies: () => {},
+ openCookieSettings: () => {},
})
function getStoredConsent() {
@@ -40,9 +54,34 @@ function storeConsent(value, days) {
} catch {}
}
+function removeTrackingCookies() {
+ const cookieNames = document.cookie
+ .split(';')
+ .map((cookie) => cookie.split('=')[0].trim())
+ .filter(Boolean)
+
+ for (const name of cookieNames) {
+ if (!TRACKING_COOKIE_PREFIXES.some((prefix) => name.startsWith(prefix))) {
+ continue
+ }
+
+ const expiredCookie = `${name}=; Max-Age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`
+ document.cookie = expiredCookie
+ document.cookie = `${expiredCookie}; domain=${window.location.hostname}`
+ if (
+ window.location.hostname === 'netbird.io' ||
+ window.location.hostname.endsWith('.netbird.io')
+ ) {
+ document.cookie = `${expiredCookie}; domain=.netbird.io`
+ }
+ }
+}
+
export function CookieConsentProvider({ children }) {
const router = useRouter()
- const [consent, setConsent] = useState(() => getStoredConsent())
+ // Resolve browser storage after hydration so the server and the first client
+ // render agree. Analytics remains disabled while the choice is loading.
+ const [consent, setConsent] = useState(null)
const [showConsent, setShowConsent] = useState(false)
useEffect(() => {
@@ -59,32 +98,44 @@ export function CookieConsentProvider({ children }) {
}, [router.pathname])
const acceptCookies = useCallback(() => {
- storeConsent('accepted', ACCEPT_EXPIRY_DAYS)
+ storeConsent('accepted', CONSENT_EXPIRY_DAYS)
setConsent('accepted')
setShowConsent(false)
-
- // Enable Matomo cookies
- window._paq = window._paq || []
- window._paq.push(['setCookieConsentGiven'])
}, [])
const declineCookies = useCallback(() => {
- storeConsent('declined', DECLINE_EXPIRY_DAYS)
+ const trackersWereActive = consent === 'accepted'
+
+ storeConsent('declined', CONSENT_EXPIRY_DAYS)
setConsent('declined')
setShowConsent(false)
- // Tell Matomo to forget consent and delete its cookies
- window._paq = window._paq || []
- window._paq.push(['forgetCookieConsentGiven'])
+ removeTrackingCookies()
+
+ // Scripts already loaded by an accepted visitor cannot be reliably
+ // unloaded. Reload into the persisted declined state so no optional
+ // analytics script is mounted again.
+ if (trackersWereActive) {
+ window._paq = window._paq || []
+ window._paq.push(['forgetConsentGiven'])
+ window._paq.push(['deleteCookies'])
+ window.location.reload()
+ }
+ }, [consent])
+
+ const openCookieSettings = useCallback(() => {
+ setShowConsent(true)
}, [])
return (
{children}
diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx
index 05768aef8..7eecd23dc 100644
--- a/src/pages/_app.jsx
+++ b/src/pages/_app.jsx
@@ -15,6 +15,7 @@ import {dom} from "@fortawesome/fontawesome-svg-core";
import {AnnouncementBannerProvider} from "@/components/announcement-banner/AnnouncementBannerProvider";
import {ImageZoom} from "@/components/ImageZoom";
import {MatomoTagManager} from "@/components/Matomo";
+import {GoogleTagManager} from "@/components/GoogleTagManager";
import {CookieConsentProvider, useCookieConsent} from "@/components/cookie-consent/CookieConsentProvider";
import {CookieConsent} from "@/components/cookie-consent/CookieConsent";
@@ -33,6 +34,7 @@ function AppInner({ Component, pageProps }) {
return (
<>
+
{router.route.startsWith('/ipa') ?
diff --git a/src/pages/_document.jsx b/src/pages/_document.jsx
index 9eec0d4a4..f3815091f 100644
--- a/src/pages/_document.jsx
+++ b/src/pages/_document.jsx
@@ -1,5 +1,4 @@
import { Head, Html, Main, NextScript } from 'next/document'
-import {GoogleTageManagerBodyScript, GoogleTagManagerHeadScript} from "@/components/GoogleTagManager";
const modeScript = `
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
@@ -40,12 +39,10 @@ export default function Document() {
return (
-
-