diff --git a/docs/content/docs/1.guides/3.consent.md b/docs/content/docs/1.guides/3.consent.md index 4261b90d..21953542 100644 --- a/docs/content/docs/1.guides/3.consent.md +++ b/docs/content/docs/1.guides/3.consent.md @@ -1,51 +1,48 @@ --- title: Consent Management -description: Learn how to get user consent before loading scripts. +description: Gate scripts behind user consent and drive vendor-native consent APIs through a typed per-script `consent` object. --- ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"} Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent) or [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent) on [StackBlitz](https://stackblitz.com). :: -## Background +## Two complementary primitives -Many third-party scripts include tracking cookies that require user consent under privacy laws. Nuxt Scripts simplifies this process with the [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable, allowing scripts to load only after you receive user consent. +Nuxt Scripts ships two consent primitives that work together: -## Usage +1. **[`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent)**: a binary load gate. The script only starts loading after consent is granted. +2. **Per-script `consent` object** returned from every consent-aware `useScriptX()`{lang="ts"}. A vendor-native, typed API for granting, revoking, or updating consent categories at runtime. Paired with each script's `defaultConsent` option for the initial policy applied *before* the vendor's init call. -The [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable offers flexible interaction options suitable for various scenarios. +Each vendor exposes its own consent dialect (Google Consent Mode v2 for GA/GTM/Bing, binary grant/revoke for Meta, three-state for TikTok, `setConsentGiven`/`forgetConsentGiven` for Matomo, `opt_in`/`opt_out` for Mixpanel/PostHog, cookie toggle for Clarity). You wire each explicitly. -See the [API](/docs/api/use-script-trigger-consent) docs for full details on the available options. +## Binary load gate -### Accepting as a Function - -The easiest way to make use of [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} is by invoking the `accept` method when user consent is granted. - -For an example of how you might lay out your code to handle this, see the following: +The simplest usage matches the classic cookie-banner flow: load the script only after the user clicks accept. ::code-group ```ts [utils/cookie.ts] -export const agreedToCookiesScriptConsent = useScriptTriggerConsent() +export const scriptsConsent = useScriptTriggerConsent() ``` ```vue [app.vue] ``` ```vue [components/cookie-banner.vue] @@ -53,56 +50,183 @@ import { agreedToCookiesScriptConsent } from '#imports' :: -### Accepting as a resolvable boolean +### Reactive source -Alternatively, you can pass a reactive reference to the consent state to the [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable. This will automatically load the script when the consent state is `true`. +Pass a `Ref`{lang="html"} if an external store owns the state. ```ts const agreedToCookies = ref(false) -useScript('https://www.google-analytics.com/analytics.js', { - trigger: useScriptTriggerConsent({ - consent: agreedToCookies - }) -}) +const consent = useScriptTriggerConsent({ consent: agreedToCookies }) ``` -### Revoking Consent +### Revoking -You can revoke consent after it has been granted using the `revoke` method. Use the reactive `consented` ref to track the current consent state. - -```vue [components/cookie-banner.vue] - +Consent revocation flips the reactive `consented` ref. Once the load-gate promise has resolved the script has loaded; watch `consented` if you need to tear down on revoke. +```vue ``` -### Delaying script load after consent +### Delaying the load after consent + +```ts +const consent = useScriptTriggerConsent({ + consent: agreedToCookies, + postConsentTrigger: () => new Promise(resolve => + setTimeout(resolve, 3000), + ), +}) +``` -There may be instances where you want to trigger the script load after a certain event, only if the user has consented. +## Per-script consent API -For this you can use the `postConsentTrigger`, which shares the same API as `trigger` from the [`useScript()`{lang="ts"}](/docs/api/use-script){lang="ts"} composable. +Every consent-aware `useScriptX()`{lang="ts"} returns a `consent` object typed to the vendor's native API. Combine it with `defaultConsent` for the initial policy (applied in `clientInit` before the vendor fires its first call) and call `consent.*` from your cookie banner to update. ```ts -const agreedToCookies = ref(false) -useScript('https://www.google-analytics.com/analytics.js', { - trigger: useScriptTriggerConsent({ - consent: agreedToCookies, - // load 3 seconds after consent is granted - postConsentTrigger: () => new Promise(resolve => - setTimeout(resolve, 3000), - ), +const { consent } = useScriptGoogleAnalytics({ + id: 'G-XXXXXXXX', + defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' }, +}) + +function onAcceptAll() { + consent.update({ + ad_storage: 'granted', + ad_user_data: 'granted', + ad_personalization: 'granted', + analytics_storage: 'granted', }) +} +``` + +### Per-vendor surface + +| Script | `defaultConsent` | Runtime `consent.*` | +|---|---|---| +| Google Analytics | `Partial`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} | +| Google Tag Manager | `Partial`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} | +| Bing UET | `{ ad_storage }` | `consent.update({ ad_storage })`{lang="ts"} | +| Meta Pixel | `'granted' \| 'denied'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} | +| TikTok Pixel | `'granted' \| 'denied' \| 'hold'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} / `consent.hold()`{lang="ts"} | +| Matomo | `'required' \| 'given' \| 'not-required'` | `consent.give()`{lang="ts"} / `consent.forget()`{lang="ts"} *(requires `defaultConsent: 'required'` or `'given'`)* | +| Mixpanel | `'opt-in' \| 'opt-out'` | `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} | +| PostHog | `'opt-in' \| 'opt-out'` | `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} | +| Clarity | `boolean \| Record`{lang="html"} | `consent.set(value)`{lang="ts"} | + +See each script's registry page for notes on lossy projections and vendor caveats. + +### Fanning out to multiple scripts + +When one cookie banner drives several vendors, wire them explicitly in your accept handler. No magic, fully typed, no lossy remapping: + +```ts +const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) +const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) +const matomo = useScriptMatomoAnalytics({ cloudId: 'foo.matomo.cloud', defaultConsent: 'required' }) + +function onAcceptAll() { + ga.consent.update({ + ad_storage: 'granted', + ad_user_data: 'granted', + ad_personalization: 'granted', + analytics_storage: 'granted', + }) + meta.consent.grant() + matomo.consent.give() +} + +function onDeclineAll() { + meta.consent.revoke() + matomo.consent.forget() +} +``` + +### Granular categories + +If users can toggle categories individually (analytics, marketing, functional), the same pattern applies; each script gets only the categories it understands: + +```ts +function savePreferences(choices: { analytics: boolean, marketing: boolean }) { + ga.consent.update({ + analytics_storage: choices.analytics ? 'granted' : 'denied', + ad_storage: choices.marketing ? 'granted' : 'denied', + ad_user_data: choices.marketing ? 'granted' : 'denied', + ad_personalization: choices.marketing ? 'granted' : 'denied', + }) + if (choices.marketing) + meta.consent.grant() + else meta.consent.revoke() + if (choices.analytics) + matomo.consent.give() + else matomo.consent.forget() +} +``` + +## Third-party CMP recipes + +When a dedicated Consent Management Platform owns the UI, bridge its events into each script's `consent` API. + +### OneTrust + +```ts +const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) +const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) + +onNuxtReady(() => { + function apply() { + const groups = (window as any).OnetrustActiveGroups as string | undefined + if (!groups) + return + const analytics = groups.includes('C0002') + const marketing = groups.includes('C0004') + ga.consent.update({ + analytics_storage: analytics ? 'granted' : 'denied', + ad_storage: marketing ? 'granted' : 'denied', + ad_user_data: marketing ? 'granted' : 'denied', + ad_personalization: marketing ? 'granted' : 'denied', + }) + if (marketing) + meta.consent.grant() + else meta.consent.revoke() + } + + apply() + window.addEventListener('OneTrustGroupsUpdated', apply) +}) +``` + +### Cookiebot + +```ts +const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) +const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) + +onNuxtReady(() => { + function apply() { + const cb = (window as any).Cookiebot + if (!cb?.consent) + return + ga.consent.update({ + analytics_storage: cb.consent.statistics ? 'granted' : 'denied', + ad_storage: cb.consent.marketing ? 'granted' : 'denied', + ad_user_data: cb.consent.marketing ? 'granted' : 'denied', + ad_personalization: cb.consent.marketing ? 'granted' : 'denied', + }) + if (cb.consent.marketing) + meta.consent.grant() + else meta.consent.revoke() + } + + apply() + window.addEventListener('CookiebotOnAccept', apply) + window.addEventListener('CookiebotOnDecline', apply) }) ``` diff --git a/docs/content/scripts/bing-uet.md b/docs/content/scripts/bing-uet.md index da016ea1..47103fe9 100644 --- a/docs/content/scripts/bing-uet.md +++ b/docs/content/scripts/bing-uet.md @@ -57,22 +57,18 @@ function trackSignup() { ### Consent Mode -Bing UET supports [advanced consent mode](https://help.ads.microsoft.com/#apex/ads/en/60119/1-500). Use `onBeforeUetStart` to set the default consent state before the script loads. If consent is denied, UET only sends anonymous data. +Bing UET supports [advanced consent mode](https://help.ads.microsoft.com/#apex/ads/en/60119/1-500). Only `ad_storage` is honoured; set the initial state with `defaultConsent` and update at runtime via `consent.update()`{lang="ts"}: ```vue ``` + +`onBeforeUetStart` remains available for any other pre-load setup. diff --git a/docs/content/scripts/clarity.md b/docs/content/scripts/clarity.md index bd9f3004..8fbc7b23 100644 --- a/docs/content/scripts/clarity.md +++ b/docs/content/scripts/clarity.md @@ -18,3 +18,39 @@ links: ::script-types :: + +## Consent Mode + +Clarity supports a cookie consent toggle (boolean) or an advanced consent vector (record). Set the initial value with `defaultConsent` and call `consent.set()`{lang="ts"} at runtime: + +```vue + +``` + +`consent.set()`{lang="ts"} also accepts Clarity's advanced consent vector for fine-grained cookie categories: + +```ts +const { consent } = useScriptClarity({ + id: 'YOUR_PROJECT_ID', + defaultConsent: { + ad_storage: 'denied', + analytics_storage: 'granted', + }, +}) + +consent.set({ + ad_storage: 'granted', + analytics_storage: 'granted', +}) +``` + +See [Clarity cookie consent](https://learn.microsoft.com/en-us/clarity/setup-and-installation/cookie-consent) for details. diff --git a/docs/content/scripts/google-analytics.md b/docs/content/scripts/google-analytics.md index 858539c8..5dbcc5ee 100644 --- a/docs/content/scripts/google-analytics.md +++ b/docs/content/scripts/google-analytics.md @@ -30,6 +30,44 @@ proxy.gtag('event', 'page_view') The proxy exposes the `gtag` and `dataLayer` properties, and you should use them following Google Analytics best practices. +### Consent Mode + +Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', ...)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime: + +```vue + +``` + +`consent.update()`{lang="ts"} accepts any `Partial`{lang="ts"}; missing categories stay at their current value. For pre-`gtag('js')`{lang="ts"} setup beyond consent defaults, `onBeforeGtagStart` remains available as a general escape hatch. + ### Customer/Consumer ID Tracking For e-commerce or multi-tenant applications where you need to track customer-specific analytics alongside your main tracking: diff --git a/docs/content/scripts/google-tag-manager.md b/docs/content/scripts/google-tag-manager.md index 9e139992..a227e5fb 100644 --- a/docs/content/scripts/google-tag-manager.md +++ b/docs/content/scripts/google-tag-manager.md @@ -42,29 +42,15 @@ useScriptEventPage(({ title, path }) => { }) ``` -## Configuring GTM before it starts +## Consent Mode -[`useScriptGoogleTagManager()`{lang="ts"}](/scripts/google-tag-manager){lang="ts"} initializes Google Tag Manager by itself. This means it pushes the `js`, `config` and the `gtm.start` events for you. - -If you need to configure GTM before it starts, for example [setting the consent mode](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic), you have two options: - -### Option 1: Using `defaultConsent` in nuxt.config (Recommended) - -If you're configuring GTM in `nuxt.config`, use the `defaultConsent` option. See the [Default consent mode](#loading-globally) example above. - -### Option 2: Using `onBeforeGtmStart` callback - -If you're calling [`useScriptGoogleTagManager()`{lang="ts"}](/scripts/google-tag-manager){lang="ts"} with the ID directly in a component (not in nuxt.config), use the `onBeforeGtmStart` hook which runs right before the `gtm.start` event is pushed. - -::callout{icon="i-heroicons-exclamation-triangle" color="warning"} -`onBeforeGtmStart` only works when the GTM ID is passed directly to [`useScriptGoogleTagManager()`{lang="ts"}](/scripts/google-tag-manager){lang="ts"}, not when configured globally in nuxt.config. For global config, use the `defaultConsent` option instead. -:: +Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime. ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"} Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent) or [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent) on [StackBlitz](https://stackblitz.com). :: -#### Consent Mode v2 Signals +### Consent Mode v2 Signals | Signal | Purpose | |--------|---------| @@ -73,61 +59,46 @@ Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts | `ad_personalization` | Personalized ads (remarketing) | | `analytics_storage` | Cookies for analytics | -#### Updating Consent +### Example -When the user accepts, call `gtag('consent', 'update', ...)`{lang="ts"}: +```vue + ``` +`onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`). + ::script-types :: diff --git a/docs/content/scripts/matomo-analytics.md b/docs/content/scripts/matomo-analytics.md index a9ed6591..6ffbf816 100644 --- a/docs/content/scripts/matomo-analytics.md +++ b/docs/content/scripts/matomo-analytics.md @@ -83,6 +83,40 @@ useScriptMatomoAnalytics({ }) ``` +## Consent Mode + +Matomo has a built-in [tracking-consent API](https://developer.matomo.org/guides/tracking-consent) gated by `requireConsent`. Set `defaultConsent` to arm the gate at registration, then call `consent.give()`{lang="ts"} / `consent.forget()`{lang="ts"} at runtime. + +### `defaultConsent` + +| Value | Behaviour | +|-------|-----------| +| `'required'` | Pushes `['requireConsent']`. Nothing is tracked until the user opts in. | +| `'given'` | Pushes `['requireConsent']` then `['setConsentGiven']`. Tracking starts immediately. | +| `'not-required'` | Default Matomo behaviour (no consent gating). | + +::callout{icon="i-heroicons-exclamation-triangle" color="warning"} +`consent.give()`{lang="ts"} / `consent.forget()`{lang="ts"} are **no-ops unless `defaultConsent: 'required'` or `'given'` was set at registration** -- Matomo ignores `setConsentGiven` / `forgetConsentGiven` when `requireConsent` hasn't been pushed. A dev-only warning fires if you forget. +:: + +### Example + +```vue + +``` + ### Using Matomo Whitelabel For Matomo Whitelabel, set `trackerUrl` and `scriptInput.src` to customize tracking. diff --git a/docs/content/scripts/meta-pixel.md b/docs/content/scripts/meta-pixel.md index 22d42463..23d5e290 100644 --- a/docs/content/scripts/meta-pixel.md +++ b/docs/content/scripts/meta-pixel.md @@ -20,3 +20,25 @@ Nuxt Scripts provides a registry script composable [`useScriptMetaPixel()`{lang= ::script-types :: + +## Consent Mode + +Meta Pixel exposes a binary consent toggle. Set the initial state with `defaultConsent` (fires `fbq('consent', 'grant'|'revoke')`{lang="ts"} before `fbq('init', id)`{lang="ts"}) and call `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} at runtime: + +```vue + +``` + +See [Meta's consent docs](https://www.facebook.com/business/help/1151321516677370) for details. diff --git a/docs/content/scripts/mixpanel-analytics.md b/docs/content/scripts/mixpanel-analytics.md index 7735708d..b3cc4fe3 100644 --- a/docs/content/scripts/mixpanel-analytics.md +++ b/docs/content/scripts/mixpanel-analytics.md @@ -69,3 +69,36 @@ proxy.mixpanel.register({ }) ``` + +## Consent Mode + +Mixpanel exposes [`opt_in_tracking` / `opt_out_tracking`](https://docs.mixpanel.com/docs/privacy/opt-out-of-tracking). Set the boot-time default with `defaultConsent` and call `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} at runtime. + +### `defaultConsent` + +| Value | Behaviour | +|-------|-----------| +| `'opt-in'` | Starts opted in. | +| `'opt-out'` | Calls `mixpanel.init(..., { opt_out_tracking_by_default: true })`{lang="ts"} so the SDK boots opted out. | + +::callout{icon="i-heroicons-information-circle"} +Use `defaultConsent: 'opt-out'` when you need the SDK to boot opted out. The runtime `consent.optOut()`{lang="ts"} calls `opt_out_tracking()`{lang="ts"} **after** init, which is weaker than the boot-time flag; any events captured between init and the opt-out call are still sent. +:: + +### Example + +```vue + +``` diff --git a/docs/content/scripts/posthog.md b/docs/content/scripts/posthog.md index 3334e83b..b13e9292 100644 --- a/docs/content/scripts/posthog.md +++ b/docs/content/scripts/posthog.md @@ -101,6 +101,54 @@ onLoaded(({ posthog }) => { }) ``` +## Consent Mode + +PostHog exposes [`opt_in_capturing` / `opt_out_capturing`](https://posthog.com/docs/privacy/opting-out). Set the boot-time default with `defaultConsent` and call `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} at runtime. + +### `defaultConsent` + +| Value | Behaviour | +|-------|-----------| +| `'opt-in'` | Calls `posthog.opt_in_capturing()`{lang="ts"} immediately after init. | +| `'opt-out'` | Calls `posthog.init(..., { opt_out_capturing_by_default: true })`{lang="ts"} so the SDK boots opted out. | + +::callout{icon="i-heroicons-information-circle"} +Use `defaultConsent: 'opt-out'` when you need the SDK to boot opted out. The runtime `consent.optOut()`{lang="ts"} calls `opt_out_capturing()`{lang="ts"} **after** init, which is weaker than the boot-time flag; any events captured between init and the opt-out call are still sent. +:: + +### Example + +```vue + +``` + +Configuring PostHog globally in `nuxt.config`: + +```ts +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY', + defaultConsent: 'opt-out', + } + } + } +}) +``` + ## Disabling Session Recording ```ts diff --git a/docs/content/scripts/tiktok-pixel.md b/docs/content/scripts/tiktok-pixel.md index f2787e24..d35513ec 100644 --- a/docs/content/scripts/tiktok-pixel.md +++ b/docs/content/scripts/tiktok-pixel.md @@ -48,5 +48,27 @@ export default defineNuxtConfig({ }) ``` +## Consent Mode + +TikTok Pixel exposes a three-state consent API: grant, revoke, or hold (defer the decision). Set the initial state with `defaultConsent` and call `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} / `consent.hold()`{lang="ts"} at runtime: + +```vue + +``` + +See the [TikTok cookie consent docs](https://business-api.tiktok.com/portal/docs?id=1739585600931842) for the full behaviour. + ::script-types :: diff --git a/examples/cookie-consent/app.vue b/examples/cookie-consent/app.vue index 6595efd3..11e4c7bb 100644 --- a/examples/cookie-consent/app.vue +++ b/examples/cookie-consent/app.vue @@ -1,35 +1,27 @@